diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index b90f9e5c5..edadf35ff 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -78,7 +78,7 @@ function openRawDb(path) { loadProvider(); return new DatabaseSync(path); } -const SCHEMA_VERSION = 38; +const SCHEMA_VERSION = 39; function indexExists(db, name) { return !!db .prepare( @@ -576,6 +576,16 @@ function initSchema(db, fileBacked) { tags TEXT NOT NULL DEFAULT '[]' ) `); + // content_hash is queried on every insert for deduplication; without an + // index the lookup becomes a full table scan as ingestion volume grows. + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memory_sources_content_hash ON memory_sources(content_hash)", + ); + // Category GROUP BY queries (e.g. /sf memory stats) need a covering + // index that filters active memories and groups by category. + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(superseded_by, category)", + ); db.exec(` CREATE TABLE IF NOT EXISTS milestones ( id TEXT PRIMARY KEY, @@ -2046,6 +2056,20 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 39) { + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memory_sources_content_hash ON memory_sources(content_hash)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(superseded_by, category)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 39, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 4774328e5..122e86603 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -201,7 +201,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 38); + assert.equal(version.version, 39); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", @@ -285,3 +285,26 @@ test("openDatabase_memories_table_has_tags_column", () => { assert.equal(tagsCol.type, "TEXT"); assert.equal(tagsCol.dflt_value, "'[]'"); }); + +test("openDatabase_memory_indexes_exist", () => { + assert.equal(openDatabase(":memory:"), true); + const db = getDatabase(); + const indexes = db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name IN ('memories', 'memory_sources')", + ) + .all(); + const names = indexes.map((r) => r.name); + assert.ok( + names.includes("idx_memories_active"), + "should have idx_memories_active", + ); + assert.ok( + names.includes("idx_memories_category"), + "should have idx_memories_category", + ); + assert.ok( + names.includes("idx_memory_sources_content_hash"), + "should have idx_memory_sources_content_hash", + ); +}); diff --git a/src/resources/extensions/sf/tests/uok-message-bus.test.mjs b/src/resources/extensions/sf/tests/uok-message-bus.test.mjs index 97bfe700b..9fb2ea3b3 100644 --- a/src/resources/extensions/sf/tests/uok-message-bus.test.mjs +++ b/src/resources/extensions/sf/tests/uok-message-bus.test.mjs @@ -6,7 +6,9 @@ import { afterEach, test } from "vitest"; import { closeDatabase, getUokMessageBusMetrics, + getUokMessagesForAgent, insertUokMessage, + openDatabase, } from "../sf-db.js"; import { AgentInbox, MessageBus } from "../uok/message-bus.js"; @@ -274,3 +276,24 @@ test("getUokMessageBusMetrics_ignores_reads_by_non_recipient_for_unread_count", assert.equal(m.totalMessages, 1); assert.equal(m.totalUnread, 1); }); + +test("messageBus_send_when_switching_projects_uses_current_project_db", () => { + const first = makeProject(); + const second = makeProject(); + + new MessageBus(first).send("agent-a", "agent-b", "first"); + new MessageBus(second).send("agent-a", "agent-b", "second"); + + closeDatabase(); + openDatabase(join(first, ".sf", "sf.db")); + assert.deepEqual( + getUokMessagesForAgent("agent-b").map((message) => message.body), + ["first"], + ); + closeDatabase(); + openDatabase(join(second, ".sf", "sf.db")); + assert.deepEqual( + getUokMessagesForAgent("agent-b").map((message) => message.body), + ["second"], + ); +}); diff --git a/src/resources/extensions/sf/uok/message-bus.js b/src/resources/extensions/sf/uok/message-bus.js index e80d72b5c..05d2a3fba 100644 --- a/src/resources/extensions/sf/uok/message-bus.js +++ b/src/resources/extensions/sf/uok/message-bus.js @@ -20,7 +20,6 @@ import { getUokMessageReadIds, getUokMessagesForAgent, insertUokMessage, - isDbAvailable, markUokMessageRead, openDatabase, } from "../sf-db.js"; @@ -34,7 +33,6 @@ function deterministicMessageId(key) { } function ensureDb(basePath) { - if (isDbAvailable()) return; const dir = sfRoot(basePath); mkdirSync(dir, { recursive: true }); openDatabase(join(dir, "sf.db"));