/** * Idempotent schema install + vector-dimension safety check. * * Called once at agents-library startup by host's `memoryStore.js` singleton * factory. Safe to call multiple times โ€” every statement is `IF NOT EXISTS`. */ import type { Pool } from 'pg'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { DEFAULT_MEMORY_DIMENSIONS, DEFAULT_MEMORY_TABLE } from './constants'; import { getMemoryEmbedder } from './embeddings'; export interface MigrationOptions { pool: Pool; table?: string; /** If true, skip the live embedder probe (tests). */ skipEmbedderProbe?: boolean; } function resolveSchemaSqlPath(): string { // Works under both ESM (import.meta.url) and compiled CJS (fall back to __dirname). const meta = import.meta as unknown as { url?: string }; const metaUrl = typeof meta.url === 'string' ? meta.url : undefined; if (metaUrl) { return join(dirname(fileURLToPath(metaUrl)), 'schema.sql'); } const globalDirname = (globalThis as unknown as { __dirname?: string }) .__dirname; return join(globalDirname ?? process.cwd(), 'schema.sql'); } export async function runMemoryMigration( opts: MigrationOptions ): Promise { const table = opts.table ?? DEFAULT_MEMORY_TABLE; const schemaPath = resolveSchemaSqlPath(); const schemaSql = readFileSync(schemaPath, 'utf8').replace( /agent_memories/g, table ); // Drop the legacy `(agent_id, path)` unique constraint BEFORE running // the schema SQL. The schema creates the new // `(agent_id, user_id, path)` NULLS-NOT-DISTINCT constraint, which // must not collide with the old one. Dropping by the legacy name is a // no-op on fresh installs where the constraint never existed, and on // upgrades it cleanly frees the unique-index slot so the new // constraint can be installed. await opts.pool .query( `ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_agent_path_uq` ) .catch(() => undefined); await opts.pool.query(schemaSql); // Adopt legacy rows if the table pre-existed with an older shape. // Idempotent โ€” every statement is no-op-safe on a fresh schema. await opts.pool .query(`ALTER TABLE ${table} ALTER COLUMN user_id DROP NOT NULL`) .catch(() => undefined); await opts.pool.query( `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` ); await opts.pool.query( `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS last_user_id TEXT` ); // Belt-and-suspenders: if the schema CREATE was skipped because the // table already existed AND the new constraint was absent, add it // explicitly. Swallow duplicate-object errors on happy-path re-runs. await opts.pool .query( `ALTER TABLE ${table} ADD CONSTRAINT ${table}_agent_user_path_uq UNIQUE NULLS NOT DISTINCT (agent_id, user_id, path)` ) .catch(() => undefined); if (opts.skipEmbedderProbe) return; // Vector dimension sanity check โ€” refuses to serve if the column width // disagrees with the live embedder and the table has data. See plan ยง3. const embedder = getMemoryEmbedder(); const liveDim = embedder.dimensions || DEFAULT_MEMORY_DIMENSIONS; // pgvector stores the declared width directly in atttypmod (unlike varchar // which uses atttypmod = N + VARHDRSZ). Reading it raw gives the true dim. const dimResult = await opts.pool.query( ` SELECT atttypmod AS dim FROM pg_attribute WHERE attrelid = to_regclass($1) AND attname = 'embedding' `, [table] ); const columnDim = dimResult.rows[0]?.dim; if (columnDim && Number(columnDim) !== liveDim) { const countRes = await opts.pool.query( `SELECT COUNT(*)::int AS n FROM ${table}` ); const rowCount = Number(countRes.rows[0]?.n ?? 0); if (rowCount === 0) { await opts.pool.query( `ALTER TABLE ${table} ALTER COLUMN embedding TYPE VECTOR(${liveDim})` ); } else { throw new Error( `Memory vector dimension mismatch: column is VECTOR(${columnDim}) but ` + `embedder "${embedder.model}" produces ${liveDim}-d vectors, and ${rowCount} ` + `rows exist. Refusing to serve memory. Admin must run a reindex job.` ); } } }