diff --git a/packages/memory-host-sdk/src/host/memory-schema.test.ts b/packages/memory-host-sdk/src/host/memory-schema.test.ts index d731797294c..a8485657c55 100644 --- a/packages/memory-host-sdk/src/host/memory-schema.test.ts +++ b/packages/memory-host-sdk/src/host/memory-schema.test.ts @@ -111,6 +111,41 @@ describe("memory index schema", () => { } }); + it("upgrades canonical source tables keyed only by path", () => { + const db = new DatabaseSync(":memory:"); + try { + db.exec(` + CREATE TABLE memory_index_sources ( + path TEXT PRIMARY KEY, + source TEXT NOT NULL DEFAULT 'memory', + hash TEXT NOT NULL, + mtime INTEGER NOT NULL, + size INTEGER NOT NULL + ); + INSERT INTO memory_index_sources VALUES ('shared.md', 'memory', 'memory-hash', 10, 20); + `); + + ensureMemoryIndexSchema({ + db, + cacheEnabled: false, + ftsEnabled: false, + }); + + db.prepare( + "INSERT INTO memory_index_sources (path, source, hash, mtime, size) VALUES (?, ?, ?, ?, ?)", + ).run("shared.md", "sessions", "session-hash", 30, 40); + + expect( + db.prepare("SELECT path, source, hash FROM memory_index_sources ORDER BY source").all(), + ).toEqual([ + { path: "shared.md", source: "memory", hash: "memory-hash" }, + { path: "shared.md", source: "sessions", hash: "session-hash" }, + ]); + } finally { + db.close(); + } + }); + it("leaves unrelated generic tables untouched", () => { const db = new DatabaseSync(":memory:"); try { diff --git a/packages/memory-host-sdk/src/host/memory-schema.ts b/packages/memory-host-sdk/src/host/memory-schema.ts index 6b84d092d72..47fd22ef86d 100644 --- a/packages/memory-host-sdk/src/host/memory-schema.ts +++ b/packages/memory-host-sdk/src/host/memory-schema.ts @@ -21,6 +21,8 @@ const LEGACY_MEMORY_INDEX_TRIGGERS = [ "memory_chunks_revision_after_delete", ] as const; +const MEMORY_INDEX_SOURCE_COLUMNS = ["path", "source", "hash", "mtime", "size"] as const; + function tableHasExactColumns( db: DatabaseSync, tableName: string, @@ -31,6 +33,33 @@ function tableHasExactColumns( return columns.size === expected.length && expected.every((column) => columns.has(column)); } +function tablePrimaryKeyColumns(db: DatabaseSync, tableName: string): string[] { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ + name?: unknown; + pk?: unknown; + }>; + return rows + .flatMap((row) => + typeof row.name === "string" && typeof row.pk === "number" && row.pk > 0 + ? [{ name: row.name, pk: row.pk }] + : [], + ) + .toSorted((left, right) => left.pk - right.pk) + .map((row) => row.name); +} + +function tableHasPrimaryKey( + db: DatabaseSync, + tableName: string, + expectedColumns: readonly string[], +): boolean { + const columns = tablePrimaryKeyColumns(db, tableName); + return ( + columns.length === expectedColumns.length && + columns.every((column, index) => column === expectedColumns[index]) + ); +} + function assertLegacyRowsCopied(db: DatabaseSync, query: string, tableName: string): void { const row = db.prepare(query).get() as { missing?: unknown } | undefined; if (Number(row?.missing ?? 0) > 0) { @@ -38,6 +67,46 @@ function assertLegacyRowsCopied(db: DatabaseSync, query: string, tableName: stri } } +function migrateCanonicalMemoryIndexSourcesPrimaryKey(db: DatabaseSync): void { + if ( + !tableHasExactColumns(db, MEMORY_INDEX_SOURCES_TABLE, MEMORY_INDEX_SOURCE_COLUMNS) || + tableHasPrimaryKey(db, MEMORY_INDEX_SOURCES_TABLE, ["path", "source"]) + ) { + return; + } + if (!tableHasPrimaryKey(db, MEMORY_INDEX_SOURCES_TABLE, ["path"])) { + return; + } + + db.exec("SAVEPOINT migrate_memory_index_sources_primary_key"); + try { + db.exec(` + DROP TRIGGER IF EXISTS memory_index_sources_revision_after_insert; + DROP TRIGGER IF EXISTS memory_index_sources_revision_after_update; + DROP TRIGGER IF EXISTS memory_index_sources_revision_after_delete; + + ALTER TABLE ${MEMORY_INDEX_SOURCES_TABLE} + RENAME TO memory_index_sources_path_pk_migration; + CREATE TABLE ${MEMORY_INDEX_SOURCES_TABLE} ( + path TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'memory', + hash TEXT NOT NULL, + mtime INTEGER NOT NULL, + size INTEGER NOT NULL, + PRIMARY KEY (path, source) + ); + INSERT INTO ${MEMORY_INDEX_SOURCES_TABLE} (path, source, hash, mtime, size) + SELECT path, source, hash, mtime, size FROM memory_index_sources_path_pk_migration; + DROP TABLE memory_index_sources_path_pk_migration; + RELEASE migrate_memory_index_sources_primary_key; + `); + } catch (err) { + db.exec("ROLLBACK TO migrate_memory_index_sources_primary_key"); + db.exec("RELEASE migrate_memory_index_sources_primary_key"); + throw err; + } +} + function migrateLegacyMemoryIndexTables(db: DatabaseSync): void { const hasLegacyCoreTables = tableHasExactColumns(db, "meta", ["key", "value"]) && @@ -218,6 +287,9 @@ export function ensureMemoryIndexSchema(params: { revision INTEGER NOT NULL ); INSERT OR IGNORE INTO ${MEMORY_INDEX_STATE_TABLE} (id, revision) VALUES (1, 0); + `); + migrateCanonicalMemoryIndexSourcesPrimaryKey(params.db); + params.db.exec(` CREATE TRIGGER IF NOT EXISTS memory_index_sources_revision_after_insert AFTER INSERT ON ${MEMORY_INDEX_SOURCES_TABLE}