agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
738 lines • 36.7 kB
JavaScript
;
/**
* adapters/memory/pgVector — the target our own port has named since 2.x.
*
* `MemoryStore`'s docstring has listed the backends it was designed for from
* the beginning — *"Every storage backend (InMemory, Redis, DynamoDB,
* **Postgres**, Bedrock AgentCore) implements this interface"* — and named the
* query, twice, in the places an implementer would look: *"**Postgres**:
* multi-row INSERT … ON CONFLICT DO UPDATE"* for `putMany`, *"**pgvector**:
* `ORDER BY embedding <=> query LIMIT k`"* for `search`. Every one of those
* sentences was true about the design and false about the shipped package.
* This is the adapter that makes them the same sentence.
*
* It matters more than one row in a table of backends. Postgres is the database
* most teams already run, `pgvector` is an extension away, and a corpus that
* lives beside the application's own data inherits its backups, its failover,
* its access control and its migrations. `sqliteVectorStore` is one machine;
* `s3VectorsStore` is serverless and eventually consistent; this is the one for
* a fleet that already has a database.
*
* ── The table, and why this does not create it ──────────────────────────────
* A `vector(N)` column fixes N at creation, and N is a fact about your
* embedder. Creating the table implicitly would pick that number — and the
* index type, and the operator class — on your behalf, in a migration you never
* reviewed, in a database whose DDL is usually somebody's job. So the schema is
* yours to run, and this store REFUSES a table that is missing rather than
* silently answering "no matches" against nothing:
*
* ```sql
* CREATE EXTENSION IF NOT EXISTS vector;
*
* -- 1024 = your embedder's dimensions. bedrockEmbedder() default: 1024.
* -- openaiEmbedder() default: 1536. staticEmbedder(): 256.
* CREATE TABLE af_vectors (
* namespace TEXT NOT NULL,
* id TEXT NOT NULL,
* value JSONB NOT NULL,
* metadata JSONB,
* embedding vector(1024),
* embedder_fp TEXT,
* version INTEGER NOT NULL,
* created_at BIGINT NOT NULL,
* updated_at BIGINT NOT NULL,
* last_accessed_at BIGINT NOT NULL,
* access_count INTEGER NOT NULL,
* ttl BIGINT,
* tier TEXT,
* source JSONB,
* embedding_model TEXT,
* PRIMARY KEY (namespace, id)
* );
*
* -- Cosine, because that is the score this port reports and every threshold
* -- in this library is calibrated on. Match the operator class to the metric.
* CREATE INDEX af_vectors_hnsw ON af_vectors
* USING hnsw (embedding vector_cosine_ops);
* CREATE INDEX af_vectors_ns ON af_vectors (namespace);
*
* -- Recognition (`seen`/`recordSignature`), usefulness feedback, and the
* -- per-namespace embedder fingerprint. Small, and each one is a port method
* -- that would otherwise have to be refused.
* CREATE TABLE af_signatures (
* namespace TEXT NOT NULL, signature TEXT NOT NULL,
* PRIMARY KEY (namespace, signature)
* );
* CREATE TABLE af_feedback (
* namespace TEXT NOT NULL, id TEXT NOT NULL,
* total DOUBLE PRECISION NOT NULL, count INTEGER NOT NULL,
* PRIMARY KEY (namespace, id)
* );
* CREATE TABLE af_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
* ```
*
* Every table and column name above is an OPTION with that value as its
* default, so this drops into a schema that already has naming conventions —
* see {@link PgVectorStoreOptions}. Identifiers are validated and quoted; a
* name that is not a plain SQL identifier is refused rather than interpolated.
*
* ── Cosine, and only cosine ─────────────────────────────────────────────────
* `search` is `1 - (embedding <=> $query::vector)` — pgvector's cosine
* DISTANCE, converted to the cosine SIMILARITY the port reports and
* `defineRAG`'s 0.7 default is calibrated on. `<->` (L2) and `<#>` (inner
* product) are deliberately not options: their ranges are not that range, and a
* number that reads like a cosine and is not one is the failure mode the whole
* fingerprint machinery exists to prevent. Build the HNSW index with
* `vector_cosine_ops` so the operator and the index agree — with the wrong
* operator class the query still returns the right answer, slowly, by scanning.
*
* ── One statement at a time, on purpose ─────────────────────────────────────
* The client here is anything with `query()` — a `pg.Pool` is the expected one,
* and a Pool hands each `query()` its own connection. `BEGIN` on one and the
* next statement on another is a transaction that silently is not one, so this
* adapter never writes multi-statement transactions. Everything that must be
* atomic is ONE statement: `putMany` is one multi-row upsert, `putIfVersion` is
* one conditional upsert, `forget` is one statement with CTEs across all four
* tables. That is a constraint that made the code better.
*
* ── Lazy peer dependency ────────────────────────────────────────────────────
* `pg` is an OPTIONAL peer dependency, required at construction time. Importing
* `agentfootprint/memory` costs nothing for consumers who never build one of
* these. Pass `client` to reuse the pool your app already has — which is the
* recommended shape, because a second pool to the same database is a second set
* of connections nobody counted.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.pgVectorStore = exports.PgVectorSchemaError = void 0;
const index_js_1 = require("../../memory/identity/index.js");
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
const writeOptions_js_1 = require("../../memory/store/writeOptions.js");
const embedderMismatch_js_1 = require("../../lib/embedderMismatch.js");
const requestLimit_js_1 = require("../../memory/store/requestLimit.js");
/**
* Raised when the database is reachable but its schema is not this store's.
*
* The law `sqliteVectorStore` states for a file, one backend over: **an
* unreadable index and an empty one are different facts, and only one of them
* is safe to answer with "no matches".** A store that treated a missing table
* as an empty corpus would answer every question from the model's own weights
* and log nothing.
*/
class PgVectorSchemaError extends Error {
code = 'ERR_PGVECTOR_SCHEMA';
/** The schema-qualified table that could not be used. */
table;
/** Columns this store needs and did not find. Empty when the table is absent entirely. */
missingColumns;
constructor(table, missingColumns, detail) {
super(`[memory] the pgVectorStore table ${table} cannot be used: ${detail}\n` +
` A missing table and an empty corpus are different facts, and only one of them is ` +
`safe to answer with "no matches" — so this refuses rather than quietly answering ` +
`every question from the model alone.\n` +
` Fix: run the CREATE TABLE from the pgVectorStore docs (it also needs ` +
`CREATE EXTENSION vector), or point \`table\`/\`columns\` at the schema you have.`);
this.name = 'PgVectorSchemaError';
this.table = table;
this.missingColumns = missingColumns;
}
}
exports.PgVectorSchemaError = PgVectorSchemaError;
const DEFAULT_COLUMNS = {
namespace: 'namespace',
id: 'id',
value: 'value',
metadata: 'metadata',
embedding: 'embedding',
embedderFp: 'embedder_fp',
version: 'version',
createdAt: 'created_at',
updatedAt: 'updated_at',
lastAccessedAt: 'last_accessed_at',
accessCount: 'access_count',
ttl: 'ttl',
tier: 'tier',
source: 'source',
embeddingModel: 'embedding_model',
};
/**
* Open a `MemoryStore` over an existing Postgres + pgvector table.
*
* @throws when `pg` is absent and no `client` was passed.
* @throws PgVectorSchemaError on the first call, when the table or a column it
* needs is not there.
* @throws EmbedderMismatchError from `put`/`putMany`/`search` when a vector
* meets a namespace built by a different embedder.
*
* @example A corpus beside the application's own data
* ```ts
* import { Pool } from 'pg';
* import { defineRAG, indexDocuments } from 'agentfootprint';
* import { pgVectorStore } from 'agentfootprint/memory';
* import { openaiEmbedder } from 'agentfootprint/providers';
*
* const store = pgVectorStore({ client: new Pool({ connectionString: process.env.DATABASE_URL }) });
* const embedder = openaiEmbedder();
*
* await indexDocuments(store, embedder, docs, { embedderId: embedder.id });
*
* const agent = Agent.create({ provider })
* .rag(defineRAG({ id: 'docs', store, embedder, embedderId: embedder.id }))
* .build();
* ```
*/
function pgVectorStore(options = {}) {
const schema = ident(options.schema ?? 'public', 'schema');
const vectorsTable = qualified(schema, ident(options.table ?? 'af_vectors', 'table'));
const signaturesTable = qualified(schema, ident(options.signaturesTable ?? 'af_signatures', 'signaturesTable'));
const feedbackTable = qualified(schema, ident(options.feedbackTable ?? 'af_feedback', 'feedbackTable'));
const metaTable = qualified(schema, ident(options.metaTable ?? 'af_index_meta', 'metaTable'));
const rawTableName = options.table ?? 'af_vectors';
const rawSchemaName = options.schema ?? 'public';
const col = resolveColumns(options.columns);
const batchSize = Math.max(1, Math.floor(options.batchSize ?? 500));
/** The 15 columns a row is written with and read back through, in one order. */
const WRITE_COLUMNS = [
col.namespace,
col.id,
col.value,
col.metadata,
col.embedding,
col.embedderFp,
col.version,
col.createdAt,
col.updatedAt,
col.lastAccessedAt,
col.accessCount,
col.ttl,
col.tier,
col.source,
col.embeddingModel,
];
const READ_COLUMNS = [
col.id,
col.value,
col.metadata,
col.version,
col.createdAt,
col.updatedAt,
col.lastAccessedAt,
col.accessCount,
col.ttl,
col.tier,
col.source,
col.embeddingModel,
]
.map(quote)
.join(', ');
let owned = false;
let client;
const connect = () => {
if (client)
return client;
if (options._client) {
client = options._client;
return client;
}
if (options.client) {
client = options.client;
return client;
}
const pg = options._pg ?? loadPg();
if (typeof pg.Pool !== 'function') {
throw new Error('pgVectorStore: `pg` is installed but `Pool` was not found. Update the driver, or ' +
'pass `client` with a pre-built pool.');
}
if (!options.connectionString) {
throw new Error('pgVectorStore: pass `client` (recommended — reuse the pool your app already has) ' +
'or `connectionString` for this store to build one.');
}
client = new pg.Pool({ connectionString: options.connectionString });
owned = true;
return client;
};
let closed = false;
const open = (verb) => {
if (closed) {
throw new Error(`[memory] the pgVectorStore for ${vectorsTable} is closed, so it cannot ${verb}. ` +
`close() is final by design — reopening the pool behind you would hide a ` +
`shutdown-ordering bug rather than surface it. Build a new store if you need one ` +
`after closing this.`);
}
};
/**
* Verify the schema ONCE, on the first call, and refuse by name.
*
* Not at construction: building a store must not require a live connection
* (a module that connects on import is a module that fails at import). Not on
* every call either — the answer cannot change under a running process
* without a migration, and paying an `information_schema` read per retrieval
* would be a tax on the hot path for a fact checked at boot.
*/
let verified;
const ensureSchema = () => {
return (verified ??= (async () => {
const db = connect();
const result = await db.query(`SELECT column_name FROM information_schema.columns ` +
`WHERE table_schema = $1 AND table_name = $2`, [rawSchemaName, rawTableName]);
const found = new Set(result.rows.map((r) => String(r['column_name'])));
if (found.size === 0) {
throw new PgVectorSchemaError(vectorsTable, [], `no such table (looked in schema '${rawSchemaName}').`);
}
const missing = WRITE_COLUMNS.filter((name) => !found.has(name));
if (missing.length > 0) {
throw new PgVectorSchemaError(vectorsTable, missing, `it exists but is missing ${missing.join(', ')} — this is a table of that name ` +
`belonging to something else, or a schema from a different release.`);
}
})());
};
const query = async (text, params) => {
await ensureSchema();
const result = await connect().query(text, params);
return result.rows ?? [];
};
// ── Fingerprints ──────────────────────────────────────────────────
const fingerprints = new Map();
const readFingerprint = async (ns) => {
const held = fingerprints.get(ns);
if (held !== undefined)
return held;
const rows = await query(`SELECT "value" FROM ${metaTable} WHERE "key" = $1`, [`fp:${ns}`]);
const value = rows[0]?.['value'];
if (typeof value !== 'string')
return undefined;
fingerprints.set(ns, value);
return value;
};
const recordFingerprint = async (ns, fp) => {
await query(`INSERT INTO ${metaTable} ("key", "value") VALUES ($1, $2) ` +
`ON CONFLICT ("key") DO UPDATE SET "value" = EXCLUDED."value"`, [`fp:${ns}`, fp]);
fingerprints.set(ns, fp);
};
/** The `sqliteVectorStore` rule, one backend over: dimensions always decide. */
const reconcile = async (ns, incoming, operation) => {
const storedText = await readFingerprint(ns);
if (storedText === undefined) {
if (operation === 'write to')
await recordFingerprint(ns, (0, embedderMismatch_js_1.fingerprintText)(incoming));
return;
}
const stored = (0, embedderMismatch_js_1.parseFingerprint)(storedText);
const conflict = (0, embedderMismatch_js_1.fingerprintConflict)(stored, incoming);
if (conflict !== null) {
throw new embedderMismatch_js_1.EmbedderMismatchError(ns, storedText, (0, embedderMismatch_js_1.fingerprintText)(incoming), conflict, operation, 'point this store at a different table');
}
if (operation === 'write to' && stored.id === undefined && incoming.id !== undefined) {
await recordFingerprint(ns, (0, embedderMismatch_js_1.fingerprintText)(incoming));
}
};
/** The 15 bound values one row is written with, in `WRITE_COLUMNS` order. */
const rowValues = (ns, entry) => {
const embedding = entry.embedding;
const fp = embedding && embedding.length > 0
? (0, embedderMismatch_js_1.fingerprintText)({
...(entry.embeddingModel !== undefined && { id: entry.embeddingModel }),
dims: embedding.length,
})
: null;
return [
ns,
entry.id,
JSON.stringify(entry.value ?? null),
entry.metadata === undefined ? null : JSON.stringify(entry.metadata),
// pgvector reads its own literal form, `'[1,2,3]'`, cast to `vector`.
embedding && embedding.length > 0 ? `[${embedding.join(',')}]` : null,
fp,
entry.version,
entry.createdAt,
entry.updatedAt,
entry.lastAccessedAt,
entry.accessCount,
entry.ttl ?? null,
entry.tier ?? null,
entry.source === undefined ? null : JSON.stringify(entry.source),
entry.embeddingModel ?? null,
];
};
/**
* The SQL type of each bound value, in `WRITE_COLUMNS` order.
*
* Every parameter is cast EXPLICITLY rather than left to inference. In an
* `INSERT … VALUES` Postgres can infer from the target column, but in the
* `INSERT … SELECT` form `putIfVersion` needs it cannot, and an uncast
* parameter arrives as text — which fails on the first integer column with
* an error about a type nobody wrote. One list, both statements, no
* inference to reason about.
*/
const CASTS = [
'text', // namespace
'text', // id
'jsonb', // value
'jsonb', // metadata
'vector', // embedding
'text', // embedder_fp
'int', // version
'bigint', // created_at
'bigint', // updated_at
'bigint', // last_accessed_at
'int', // access_count
'bigint', // ttl
'text', // tier
'jsonb', // source
'text', // embedding_model
];
/** `$1::text, $2::text, $3::jsonb, …` for one row, offset into the batch. */
const rowParams = (offset) => CASTS.map((cast, i) => `$${offset + i + 1}::${cast}`).join(', ');
const upsertSet = WRITE_COLUMNS.slice(2)
.map((name) => `${quote(name)} = EXCLUDED.${quote(name)}`)
.join(', ');
const insertColumns = WRITE_COLUMNS.map(quote).join(', ');
const conflictTarget = `(${quote(col.namespace)}, ${quote(col.id)})`;
const store = {
// Vectors in, ranked vectors out — `ORDER BY embedding <=> $query` ranks
// the embeddings this store was handed.
supportsVectorSearch: true,
ranksBy: 'vector',
async fingerprintOf(identity) {
open('report a fingerprint');
return readFingerprint((0, index_js_1.identityNamespace)(identity));
},
async get(identity, id) {
open('read an entry');
const ns = (0, index_js_1.identityNamespace)(identity);
const rows = await query(`SELECT ${READ_COLUMNS} FROM ${vectorsTable} ` +
`WHERE ${quote(col.namespace)} = $1 AND ${quote(col.id)} = $2`, [ns, id]);
const row = rows[0];
if (row === undefined)
return null;
const entry = rowToEntry(row, col);
if (entry.ttl !== undefined && entry.ttl <= Date.now())
return null;
// Decay signals, the same side effect the port documents for `get`.
await query(`UPDATE ${vectorsTable} SET ${quote(col.lastAccessedAt)} = $1, ` +
`${quote(col.accessCount)} = ${quote(col.accessCount)} + 1 ` +
`WHERE ${quote(col.namespace)} = $2 AND ${quote(col.id)} = $3`, [Date.now(), ns, id]);
return entry;
},
// Write options: none honoured (refused by name in putMany, below).
async put(identity, entry, options) {
await this.putMany(identity, [entry], options);
},
async putMany(identity, entries, options) {
open('write entries');
// The port requires an empty batch to be a no-op — callers rely on it to
// skip a round-trip on a turn that produced nothing.
if (entries.length === 0)
return;
// Nothing here is ever extracted, so a write option is refused by name —
// never dropped, which would read exactly like honouring it.
(0, writeOptions_js_1.refuseUnhonouredWriteOptions)('pgVectorStore', undefined, options, writeOptions_js_1.NO_SERVER_EXTRACTION);
const ns = (0, index_js_1.identityNamespace)(identity);
for (const entry of entries) {
const embedding = entry.embedding;
if (embedding && embedding.length > 0) {
await reconcile(ns, {
...(entry.embeddingModel !== undefined && { id: entry.embeddingModel }),
dims: embedding.length,
}, 'write to');
}
}
// ONE multi-row upsert per chunk — the statement the port's own docstring
// has named for this backend since 2.x. One statement is one implicit
// transaction, so a chunk lands whole or not at all: a HALF-INDEXED
// corpus keeps answering and quietly cannot see what did not land.
for (let i = 0; i < entries.length; i += batchSize) {
const chunk = entries.slice(i, i + batchSize);
const params = [];
const tuples = [];
for (const entry of chunk) {
tuples.push(`(${rowParams(params.length)})`);
params.push(...rowValues(ns, entry));
}
await query(`INSERT INTO ${vectorsTable} (${insertColumns}) VALUES ${tuples.join(', ')} ` +
`ON CONFLICT ${conflictTarget} DO UPDATE SET ${upsertSet}`, params);
}
},
async putIfVersion(identity, entry, expectedVersion, options) {
open('write an entry');
(0, writeOptions_js_1.refuseUnhonouredWriteOptions)('pgVectorStore', undefined, options, writeOptions_js_1.NO_SERVER_EXTRACTION);
const ns = (0, index_js_1.identityNamespace)(identity);
const embedding = entry.embedding;
if (embedding && embedding.length > 0) {
await reconcile(ns, {
...(entry.embeddingModel !== undefined && { id: entry.embeddingModel }),
dims: embedding.length,
}, 'write to');
}
// ONE statement, so the check and the write cannot be separated by
// another writer — which is the entire point of a compare-and-set, and
// is why this is not a SELECT followed by an UPDATE (a Pool would happily
// run those two on different connections).
//
// The `WHERE` on the source SELECT is what makes the absent-row case
// correct: with `expectedVersion !== 0` and no existing row, no source
// row is produced, nothing is inserted, and nothing comes back. With a
// row present, the source row IS produced, the conflict fires, and the
// `DO UPDATE … WHERE version = expected` decides.
const params = rowValues(ns, entry);
const expectedParam = `$${params.length + 1}`;
params.push(expectedVersion);
const rows = await query(`INSERT INTO ${vectorsTable} AS af_target (${insertColumns}) ` +
`SELECT ${rowParams(0)} ` +
`WHERE ${expectedParam}::int = 0 OR EXISTS (SELECT 1 FROM ${vectorsTable} AS af_probe ` +
`WHERE af_probe.${quote(col.namespace)} = $1 AND af_probe.${quote(col.id)} = $2) ` +
`ON CONFLICT ${conflictTarget} DO UPDATE SET ${upsertSet} ` +
`WHERE af_target.${quote(col.version)} = ${expectedParam}::int ` +
`RETURNING af_target.${quote(col.version)}`, params);
if (rows.length > 0)
return { applied: true };
// Advisory, and read AFTER the failed attempt rather than inside it:
// the port describes `currentVersion` as what the caller decides a retry
// against, not as a value it may assume is still current.
const current = await query(`SELECT ${quote(col.version)} FROM ${vectorsTable} ` +
`WHERE ${quote(col.namespace)} = $1 AND ${quote(col.id)} = $2`, [ns, entry.id]);
const version = current[0]?.[col.version];
return version === undefined || version === null
? { applied: false }
: { applied: false, currentVersion: num(version) };
},
async list(identity, listOptions) {
open('list entries');
const limit = (0, requestLimit_js_1.storeRequestLimit)(listOptions?.limit, 'limit') ?? 100;
const ns = (0, index_js_1.identityNamespace)(identity);
const after = listOptions?.cursor ?? '';
// Keyset pagination on the primary key: stable under concurrent writes,
// unlike OFFSET, and the cursor is the last id rather than a position.
const rows = await query(`SELECT ${READ_COLUMNS} FROM ${vectorsTable} ` +
`WHERE ${quote(col.namespace)} = $1 AND ${quote(col.id)} > $2 ` +
`ORDER BY ${quote(col.id)} LIMIT $3`, [ns, after, limit + 1]);
const tierFilter = listOptions?.tiers ? new Set(listOptions.tiers) : undefined;
const page = [];
let cursor;
for (const row of rows) {
if (page.length === limit) {
// The extra row only ever exists to prove there IS a next page.
cursor = page[page.length - 1]?.id;
break;
}
const entry = rowToEntry(row, col);
if (entry.ttl !== undefined && entry.ttl <= Date.now())
continue;
if (tierFilter && (entry.tier === undefined || !tierFilter.has(entry.tier)))
continue;
page.push(entry);
}
return { entries: page, ...(cursor !== undefined && { cursor }) };
},
async delete(identity, id) {
open('delete an entry');
await query(`DELETE FROM ${vectorsTable} WHERE ${quote(col.namespace)} = $1 AND ${quote(col.id)} = $2`, [(0, index_js_1.identityNamespace)(identity), id]);
},
async seen(identity, signature) {
open('check a signature');
const rows = await query(`SELECT 1 FROM ${signaturesTable} WHERE "namespace" = $1 AND "signature" = $2`, [(0, index_js_1.identityNamespace)(identity), signature]);
return rows.length > 0;
},
async recordSignature(identity, signature) {
open('record a signature');
await query(`INSERT INTO ${signaturesTable} ("namespace", "signature") VALUES ($1, $2) ` +
`ON CONFLICT ("namespace", "signature") DO NOTHING`, [(0, index_js_1.identityNamespace)(identity), signature]);
},
async feedback(identity, id, usefulness) {
open('record feedback');
// Non-finite values poison the aggregate; the port says adapters must
// reject them, and clamp the rest.
if (!Number.isFinite(usefulness))
return;
const clamped = Math.max(-1, Math.min(1, usefulness));
await query(`INSERT INTO ${feedbackTable} AS af_fb ("namespace", "id", "total", "count") ` +
`VALUES ($1::text, $2::text, $3::double precision, 1) ` +
`ON CONFLICT ("namespace", "id") DO UPDATE SET ` +
`"total" = af_fb."total" + EXCLUDED."total", "count" = af_fb."count" + 1`, [(0, index_js_1.identityNamespace)(identity), id, clamped]);
},
async getFeedback(identity, id) {
open('read feedback');
const rows = await query(`SELECT "total", "count" FROM ${feedbackTable} WHERE "namespace" = $1 AND "id" = $2`, [(0, index_js_1.identityNamespace)(identity), id]);
const row = rows[0];
const count = row === undefined ? 0 : num(row['count']);
if (count === 0)
return null;
return { average: num(row?.['total']) / count, count };
},
async forget(identity) {
open('forget a namespace');
const ns = (0, index_js_1.identityNamespace)(identity);
// ONE statement across all four tables. A GDPR erasure that removed the
// vectors and left the signatures behind would be a deletion that is not
// one, and a Pool cannot be trusted to keep four statements on one
// connection — so they are one statement, and therefore one transaction.
await query(`WITH cleared_vectors AS (DELETE FROM ${vectorsTable} WHERE ${quote(col.namespace)} = $1), ` +
`cleared_signatures AS (DELETE FROM ${signaturesTable} WHERE "namespace" = $1), ` +
`cleared_feedback AS (DELETE FROM ${feedbackTable} WHERE "namespace" = $1) ` +
`DELETE FROM ${metaTable} WHERE "key" = $2`, [ns, `fp:${ns}`]);
fingerprints.delete(ns);
},
async search(identity, queryVector, searchOptions) {
open('search');
const k = (0, requestLimit_js_1.storeRequestLimit)(searchOptions?.k, 'k') ?? 10;
const ns = (0, index_js_1.identityNamespace)(identity);
if (queryVector.length === 0)
return [];
// pgvector's cosine distance is undefined for a zero vector (it divides
// by the norm) and comes back as NaN, which sorts unpredictably. An
// unscoreable query is empty rather than arbitrary.
if (queryVector.every((v) => v === 0))
return [];
// Refused BEFORE the scan, on both halves of the fingerprint. A swapped
// embedder must not score against the old vectors — the numbers come
// back in the same range as real ones and no threshold separates them.
await reconcile(ns, {
...(searchOptions?.embedderId !== undefined && { id: searchOptions.embedderId }),
dims: queryVector.length,
}, 'search');
const params = [ns, `[${queryVector.join(',')}]`];
const where = [
`${quote(col.namespace)} = $1`,
`${quote(col.embedding)} IS NOT NULL`,
`(${quote(col.ttl)} IS NULL OR ${quote(col.ttl)} > ${bind(params, Date.now())})`,
];
const tiers = searchOptions?.tiers;
if (tiers && tiers.length > 0) {
where.push(`${quote(col.tier)} = ANY(${bind(params, [...tiers])}::text[])`);
}
if (searchOptions?.embedderId !== undefined) {
// A row that never named its embedder is not evidence of a different
// one — the same asymmetry the fingerprint rule uses.
const p = bind(params, searchOptions.embedderId);
where.push(`(${quote(col.embeddingModel)} IS NULL OR ${quote(col.embeddingModel)} = ${p})`);
}
if (searchOptions?.minScore !== undefined) {
const p = bind(params, searchOptions.minScore);
where.push(`1 - (${quote(col.embedding)} <=> $2::vector) >= ${p}`);
}
const limitParam = bind(params, k);
// The query the port's docstring has named for this backend since 2.x —
// `ORDER BY embedding <=> query LIMIT k` — with the distance turned into
// the cosine SIMILARITY the port reports.
const rows = await query(`SELECT ${READ_COLUMNS}, 1 - (${quote(col.embedding)} <=> $2::vector) AS af_score ` +
`FROM ${vectorsTable} WHERE ${where.join(' AND ')} ` +
`ORDER BY ${quote(col.embedding)} <=> $2::vector, ${quote(col.id)} ` +
`LIMIT ${limitParam}`, params);
return rows.map((row) => ({
entry: rowToEntry(row, col),
score: num(row['af_score']),
}));
},
async close() {
if (closed)
return;
closed = true;
if (owned)
await client?.end?.();
},
};
return store;
}
exports.pgVectorStore = pgVectorStore;
// ─── Identifiers ─────────────────────────────────────────────────────
/**
* Validate a caller-supplied identifier, or refuse it.
*
* Table and column names are the one part of a query that cannot be a bound
* parameter — they are interpolated into the SQL text. So they are checked
* against a plain-identifier shape and refused otherwise, rather than escaped
* and hoped for. A store configured from an environment variable is one
* `DB_TABLE` away from being an injection point, and "we quoted it" is not the
* answer that lets you stop thinking about it.
*/
function ident(name, option) {
if (!/^[A-Za-z_][A-Za-z0-9_$]*$/.test(name)) {
throw new TypeError(`pgVectorStore: \`${option}\` must be a plain SQL identifier — letters, digits and ` +
`underscores, not starting with a digit. Received ${JSON.stringify(name)}.\n` +
` Table and column names cannot be bound parameters; they are interpolated into the ` +
`statement, so anything else is refused rather than escaped and hoped for.`);
}
return name;
}
function quote(name) {
return `"${name}"`;
}
function qualified(schema, table) {
return `${quote(schema)}.${quote(table)}`;
}
function resolveColumns(overrides) {
const out = { ...DEFAULT_COLUMNS };
for (const key of Object.keys(DEFAULT_COLUMNS)) {
const value = overrides?.[key];
if (value !== undefined)
out[key] = ident(value, `columns.${key}`);
}
return out;
}
/** Append a value to the parameter list and return its `$n` placeholder. */
function bind(params, value) {
params.push(value);
return `$${params.length}`;
}
// ─── Row mapping ─────────────────────────────────────────────────────
/**
* `BIGINT` comes back from `pg` as a STRING by default (it does not fit in a
* JavaScript number in general), and every timestamp here is one. Coerced in
* one place, so a `createdAt` never reaches a consumer as `'1754640000000'`.
*/
function num(value) {
if (typeof value === 'number')
return value;
if (typeof value === 'string') {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
/** `JSONB` arrives already parsed from `pg`; a mock or a `TEXT` column may not. */
function json(value) {
if (typeof value !== 'string')
return value;
try {
return JSON.parse(value);
}
catch {
return value;
}
}
function rowToEntry(row, col) {
const metadata = row[col.metadata];
const source = row[col.source];
const ttl = row[col.ttl];
const tier = row[col.tier];
const model = row[col.embeddingModel];
return {
id: String(row[col.id]),
value: json(row[col.value]),
...(metadata !== null &&
metadata !== undefined && { metadata: json(metadata) }),
version: num(row[col.version]),
createdAt: num(row[col.createdAt]),
updatedAt: num(row[col.updatedAt]),
lastAccessedAt: num(row[col.lastAccessedAt]),
accessCount: num(row[col.accessCount]),
...(ttl !== null && ttl !== undefined && { ttl: num(ttl) }),
...(typeof tier === 'string' && { tier: tier }),
...(source !== null &&
source !== undefined && { source: json(source) }),
...(typeof model === 'string' && { embeddingModel: model }),
};
}
// ─── Driver loading ──────────────────────────────────────────────────
function loadPg() {
try {
return (0, lazyRequire_js_1.lazyRequire)('pg');
}
catch {
throw new Error('pgVectorStore requires the `pg` peer dependency.\n' +
' Install: npm install pg\n' +
' And in the database: CREATE EXTENSION IF NOT EXISTS vector;\n' +
' Or pass `client` with a pre-built pool — recommended, so one pool serves the ' +
'whole app.');
}
}
//# sourceMappingURL=pgVector.js.map