/** * Tests for the incremental table-relocation engine * (`migrations/helpers/relocation.ts`), driven with migration 298's * `MEMORY_JOBS_RELOCATION` spec. * * What this locks in: * 1. `stageTableForRelocation` drops an empty source, renames a populated * one aside to `__relocating`, and is idempotent across re-runs. * 2. `drainStagedTable` copies the rows worth keeping into the target file, * purges the rest without copying, applies the spec's per-column * transforms (`running` → `pending`), and drops the staging table — so a * heavy table moves in bounded awaited batches rather than one blocking * shot. * * The drain runs through `runAsyncSqlite`, which targets the memory file * directly (sqlite3 subprocess where available; in-process transient * connection otherwise) — independent of the daemon connection, which no longer * ATTACHes the dedicated files. */ import { describe, expect, test } from "bun:test"; const { getSqlite, getMemorySqlite } = await import("../../../../persistence/db-connection.js"); const { initializeDb } = await import("../../../../persistence/db-init.js"); const { drainStagedTable, stageTableForRelocation } = await import("../../../../persistence/migrations/helpers/relocation.js"); const { MEMORY_JOBS_RELOCATION } = await import("../../../../persistence/migrations/298-move-memory-jobs-to-memory-db.js"); const { INJECTION_EVENTS_RELOCATION } = await import("../../../../persistence/migrations/326-move-injection-events-to-memory-db.js"); const { ACTIVATION_LOGS_RELOCATION } = await import("../../../../persistence/migrations/336-move-memory-v2-activation-logs-to-memory-db.js"); const { RECALL_LOGS_RELOCATION } = await import("../../../../persistence/migrations/337-move-memory-recall-logs-to-memory-db.js"); const { MEMORY_V3_SELECTIONS_RELOCATION } = await import("../../../../persistence/migrations/338-move-memory-v3-selections-to-memory-db.js"); const { ACTIVATION_SESSIONS_RELOCATION } = await import("../../../../persistence/migrations/339-move-activation-sessions-to-memory-db.js"); const { ACTIVATION_STATE_RELOCATION } = await import("../../../../persistence/migrations/343-move-activation-state-to-memory-db.js"); const { CONVERSATION_GRAPH_MEMORY_STATE_RELOCATION } = await import("../../../../persistence/migrations/344-move-conversation-graph-memory-state-to-memory-db.js"); const { MEMORY_V3_EVER_INJECTED_RELOCATION } = await import("../../../../persistence/migrations/345-move-memory-v3-ever-injected-to-memory-db.js"); const { MEMORY_RETROSPECTIVE_STATE_RELOCATION } = await import("../../../../persistence/migrations/346-move-memory-retrospective-state-to-memory-db.js"); const { MEMORY_GRAPH_NODES_RELOCATION, MEMORY_GRAPH_EDGES_RELOCATION, MEMORY_GRAPH_TRIGGERS_RELOCATION, MEMORY_GRAPH_NODE_EDITS_RELOCATION, } = await import("../../../../persistence/migrations/349-move-memory-graph-tables-to-memory-db.js"); const { MEMORY_SEGMENTS_RELOCATION } = await import("../../../../persistence/migrations/357-move-memory-segments-to-memory-db.js"); const { MEMORY_EMBEDDINGS_RELOCATION } = await import("../../../../persistence/migrations/358-move-memory-embeddings-to-memory-db.js"); const { MEMORY_SUMMARIES_RELOCATION } = await import("../../../../persistence/migrations/359-move-memory-summaries-to-memory-db.js"); await initializeDb(); function existsInMain(name: string): boolean { return ( getSqlite() .query( `SELECT name FROM main.sqlite_master WHERE type='table' AND name = ?`, ) .get(name) != null ); } const MEMORY_JOBS_COLUMNS = ` id TEXT PRIMARY KEY, type TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, deferrals INTEGER NOT NULL DEFAULT 0, run_after INTEGER NOT NULL, last_error TEXT, started_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL`; describe("stageTableForRelocation", () => { test("drops an empty source and reports no drain needed", () => { const sqlite = getSqlite(); sqlite.exec(`DROP TABLE IF EXISTS main.reloc_probe`); sqlite.exec(`DROP TABLE IF EXISTS main."reloc_probe__relocating"`); sqlite.exec(`CREATE TABLE main.reloc_probe (id INTEGER PRIMARY KEY)`); expect(stageTableForRelocation(sqlite, "reloc_probe")).toBe(false); expect(existsInMain("reloc_probe")).toBe(false); expect(existsInMain("reloc_probe__relocating")).toBe(false); }); test("renames a populated source aside, idempotently", () => { const sqlite = getSqlite(); sqlite.exec(`DROP TABLE IF EXISTS main.reloc_probe`); sqlite.exec(`DROP TABLE IF EXISTS main."reloc_probe__relocating"`); sqlite.exec(`CREATE TABLE main.reloc_probe (id INTEGER PRIMARY KEY)`); sqlite.exec(`INSERT INTO main.reloc_probe VALUES (1)`); expect(stageTableForRelocation(sqlite, "reloc_probe")).toBe(true); expect(existsInMain("reloc_probe")).toBe(false); expect(existsInMain("reloc_probe__relocating")).toBe(true); // Re-running with the staging table already present is a safe no-op. expect(stageTableForRelocation(sqlite, "reloc_probe")).toBe(true); const row = sqlite .query< { id: number }, [] >(`SELECT id FROM main."reloc_probe__relocating"`) .get(); expect(row?.id).toBe(1); sqlite.exec(`DROP TABLE IF EXISTS main."reloc_probe__relocating"`); }); }); describe("memory_jobs drain", () => { test("copies pending/running rows, purges terminal rows, drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate: empty live queue, fresh populated staging table. memory.exec(`DELETE FROM memory_jobs`); sqlite.exec(`DROP TABLE IF EXISTS main."memory_jobs__relocating"`); sqlite.exec( `CREATE TABLE main."memory_jobs__relocating" (${MEMORY_JOBS_COLUMNS})`, ); const insert = sqlite.prepare( `INSERT INTO main."memory_jobs__relocating" (id, type, payload, status, run_after, created_at, updated_at) VALUES (?, 'embed_segment', '{}', ?, 0, 0, 0)`, ); insert.run("seed-keep-1", "pending"); insert.run("seed-keep-2", "pending"); insert.run("seed-keep-3", "running"); insert.run("seed-term-1", "completed"); insert.run("seed-term-2", "completed"); insert.run("seed-term-3", "failed"); await drainStagedTable(sqlite, MEMORY_JOBS_RELOCATION); // Staging dropped. expect(existsInMain("memory_jobs__relocating")).toBe(false); // Exactly the three keepers landed in the memory database; the terminal // rows were purged without being copied, and the in-flight `running` row // was reset to `pending` so the worker can re-claim it in its new home. const kept = memory .query< { id: string; status: string }, [] >(`SELECT id, status FROM memory_jobs WHERE id LIKE 'seed-%' ORDER BY id`) .all(); expect(kept).toEqual([ { id: "seed-keep-1", status: "pending" }, { id: "seed-keep-2", status: "pending" }, { id: "seed-keep-3", status: "pending" }, ]); }); }); describe("memory_v2_injection_events drain", () => { test("copies in-window rows, purges rows older than the read window, drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate: empty live table, fresh populated staging table. memory.exec(`DELETE FROM memory_v2_injection_events`); sqlite.exec( `DROP TABLE IF EXISTS main."memory_v2_injection_events__relocating"`, ); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_v2_injection_events__relocating" ( id INTEGER PRIMARY KEY, slug TEXT NOT NULL, injected_at INTEGER NOT NULL ) `); const now = Date.now(); const readWindowMs = 18 * 24 * 60 * 60 * 1000; // 6 half-lives of 3 days const insert = sqlite.prepare( `INSERT INTO main."memory_v2_injection_events__relocating" (id, slug, injected_at) VALUES (?, ?, ?)`, ); insert.run(1, "fresh-a", now - 1000); insert.run(2, "fresh-b", now - readWindowMs / 2); insert.run(3, "stale-a", now - readWindowMs - 24 * 60 * 60 * 1000); insert.run(4, "stale-b", now - 2 * readWindowMs); await drainStagedTable(sqlite, INJECTION_EVENTS_RELOCATION); // Staging dropped. expect(existsInMain("memory_v2_injection_events__relocating")).toBe(false); // Only the in-window rows landed in the memory database, ids preserved; // rows past the score read window were purged without being copied. const kept = memory .query< { id: number; slug: string }, [] >(`SELECT id, slug FROM memory_v2_injection_events ORDER BY id`) .all(); expect(kept).toEqual([ { id: 1, slug: "fresh-a" }, { id: 2, slug: "fresh-b" }, ]); }); }); describe("memory_v3_selections drain", () => { test("copies every row (full copy) and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate: empty live table, fresh populated staging table with the // full post-283 column set. memory.exec(`DELETE FROM memory_v3_selections`); sqlite.exec(`DROP TABLE IF EXISTS main."memory_v3_selections__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_v3_selections__relocating" ( conversation_id TEXT NOT NULL, turn INTEGER NOT NULL, slug TEXT NOT NULL, source TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, message_id TEXT, section_ordinal INTEGER, section_title TEXT, PRIMARY KEY (conversation_id, turn, slug) ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_v3_selections__relocating" (conversation_id, turn, slug, source, pinned, created_at, message_id, section_ordinal, section_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); insert.run("conv-1", 1, "page-a", "needle", 0, 1_000, "msg-1", 2, "Head"); insert.run("conv-1", 2, "page-b", "core", 1, 2_000, null, null, null); await drainStagedTable(sqlite, MEMORY_V3_SELECTIONS_RELOCATION); expect(existsInMain("memory_v3_selections__relocating")).toBe(false); // Both rows landed intact, secondary attributes included. const kept = memory .query( `SELECT conversation_id, turn, slug, source, pinned, created_at, message_id, section_ordinal, section_title FROM memory_v3_selections WHERE conversation_id = 'conv-1' ORDER BY turn`, ) .all(); expect(kept).toEqual([ { conversation_id: "conv-1", turn: 1, slug: "page-a", source: "needle", pinned: 0, created_at: 1_000, message_id: "msg-1", section_ordinal: 2, section_title: "Head", }, { conversation_id: "conv-1", turn: 2, slug: "page-b", source: "core", pinned: 1, created_at: 2_000, message_id: null, section_ordinal: null, section_title: null, }, ]); }); test("a pre-283 legacy source NULL-fills the missing columns", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Staging table shaped like migration 268's original schema — no // message_id / section_ordinal / section_title columns. memory.exec(`DELETE FROM memory_v3_selections`); sqlite.exec(`DROP TABLE IF EXISTS main."memory_v3_selections__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_v3_selections__relocating" ( conversation_id TEXT NOT NULL, turn INTEGER NOT NULL, slug TEXT NOT NULL, source TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, PRIMARY KEY (conversation_id, turn, slug) ) `); sqlite .prepare( `INSERT INTO main."memory_v3_selections__relocating" (conversation_id, turn, slug, source, pinned, created_at) VALUES (?, ?, ?, ?, ?, ?)`, ) .run("conv-legacy", 7, "page-old", "needle", 0, 5_000); await drainStagedTable(sqlite, MEMORY_V3_SELECTIONS_RELOCATION); expect(existsInMain("memory_v3_selections__relocating")).toBe(false); const row = memory .query( `SELECT slug, created_at, message_id, section_ordinal, section_title FROM memory_v3_selections WHERE conversation_id = 'conv-legacy'`, ) .get(); expect(row).toEqual({ slug: "page-old", created_at: 5_000, message_id: null, section_ordinal: null, section_title: null, }); }); }); describe("activation_sessions drain", () => { test("copies every row and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; memory.exec(`DELETE FROM activation_sessions`); sqlite.exec(`DROP TABLE IF EXISTS main."activation_sessions__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."activation_sessions__relocating" ( conversation_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL ) `); const insert = sqlite.prepare( `INSERT INTO main."activation_sessions__relocating" (conversation_id, created_at) VALUES (?, ?)`, ); insert.run("conv-a", 1_000); insert.run("conv-b", 2_000); await drainStagedTable(sqlite, ACTIVATION_SESSIONS_RELOCATION); expect(existsInMain("activation_sessions__relocating")).toBe(false); const kept = memory .query( `SELECT conversation_id, created_at FROM activation_sessions ORDER BY conversation_id`, ) .all(); expect(kept).toEqual([ { conversation_id: "conv-a", created_at: 1_000 }, { conversation_id: "conv-b", created_at: 2_000 }, ]); }); }); describe("memory_v2_activation_logs drain", () => { test("copies every row, drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate: empty live table, fresh populated staging table. memory.exec(`DELETE FROM memory_v2_activation_logs`); sqlite.exec( `DROP TABLE IF EXISTS main."memory_v2_activation_logs__relocating"`, ); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_v2_activation_logs__relocating" ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, message_id TEXT, turn INTEGER NOT NULL, mode TEXT NOT NULL, concepts_json TEXT NOT NULL, skills_json TEXT NOT NULL, config_json TEXT NOT NULL, created_at INTEGER NOT NULL ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_v2_activation_logs__relocating" (id, conversation_id, message_id, turn, mode, concepts_json, skills_json, config_json, created_at) VALUES (?, 'conv-1', ?, ?, 'router', '[]', '[]', '{}', ?)`, ); insert.run("act-1", "msg-1", 1, 1_000); insert.run("act-2", null, 2, 2_000); await drainStagedTable(sqlite, ACTIVATION_LOGS_RELOCATION); // Staging dropped; the full-copy spec preserved every row and column. expect(existsInMain("memory_v2_activation_logs__relocating")).toBe(false); const kept = memory .query< { id: string; message_id: string | null; turn: number }, [] >(`SELECT id, message_id, turn FROM memory_v2_activation_logs ORDER BY id`) .all(); expect(kept).toEqual([ { id: "act-1", message_id: "msg-1", turn: 1 }, { id: "act-2", message_id: null, turn: 2 }, ]); }); }); describe("memory_recall_logs drain", () => { test("copies every row, NULL-fills query_context on a pre-211 source, drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate: empty live table, fresh populated staging table shaped // like a legacy source that predates the query_context column. memory.exec(`DELETE FROM memory_recall_logs`); sqlite.exec(`DROP TABLE IF EXISTS main."memory_recall_logs__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_recall_logs__relocating" ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, message_id TEXT, enabled INTEGER NOT NULL, degraded INTEGER NOT NULL, provider TEXT, model TEXT, degradation_json TEXT, semantic_hits INTEGER NOT NULL, merged_count INTEGER NOT NULL, selected_count INTEGER NOT NULL, tier1_count INTEGER NOT NULL, tier2_count INTEGER NOT NULL, hybrid_search_latency_ms INTEGER NOT NULL, sparse_vector_used INTEGER NOT NULL, injected_tokens INTEGER NOT NULL, latency_ms INTEGER NOT NULL, top_candidates_json TEXT NOT NULL, injected_text TEXT, reason TEXT, created_at INTEGER NOT NULL ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_recall_logs__relocating" (id, conversation_id, message_id, enabled, degraded, semantic_hits, merged_count, selected_count, tier1_count, tier2_count, hybrid_search_latency_ms, sparse_vector_used, injected_tokens, latency_ms, top_candidates_json, created_at) VALUES (?, 'conv-1', ?, 1, 0, 3, 2, 1, 1, 0, 100, 0, 300, 150, '[]', ?)`, ); insert.run("rec-1", "msg-1", 1_000); insert.run("rec-2", null, 2_000); await drainStagedTable(sqlite, RECALL_LOGS_RELOCATION); // Staging dropped; both rows copied with the absent legacy column // NULL-filled. expect(existsInMain("memory_recall_logs__relocating")).toBe(false); const kept = memory .query< { id: string; message_id: string | null; query_context: string | null }, [] >(`SELECT id, message_id, query_context FROM memory_recall_logs ORDER BY id`) .all(); expect(kept).toEqual([ { id: "rec-1", message_id: "msg-1", query_context: null }, { id: "rec-2", message_id: null, query_context: null }, ]); }); }); describe("activation_state drain", () => { test("copies every row (full copy) and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate: empty live table, fresh populated staging table shaped like // the post-241 source (no FK — the memory DB has no conversations table). memory.exec(`DELETE FROM activation_state`); sqlite.exec(`DROP TABLE IF EXISTS main."activation_state__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."activation_state__relocating" ( conversation_id TEXT PRIMARY KEY, message_id TEXT NOT NULL, state_json TEXT NOT NULL, ever_injected_json TEXT NOT NULL DEFAULT '[]', current_turn INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ) `); const insert = sqlite.prepare( `INSERT INTO main."activation_state__relocating" (conversation_id, message_id, state_json, ever_injected_json, current_turn, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, ); insert.run( "conv-a", "msg-a", '{"alice":0.5}', '[{"slug":"alice","turn":1}]', 3, 1_000, ); insert.run("conv-b", "msg-b", "{}", "[]", 0, 2_000); await drainStagedTable(sqlite, ACTIVATION_STATE_RELOCATION); expect(existsInMain("activation_state__relocating")).toBe(false); const kept = memory .query( `SELECT conversation_id, message_id, state_json, ever_injected_json, current_turn, updated_at FROM activation_state WHERE conversation_id IN ('conv-a', 'conv-b') ORDER BY conversation_id`, ) .all(); expect(kept).toEqual([ { conversation_id: "conv-a", message_id: "msg-a", state_json: '{"alice":0.5}', ever_injected_json: '[{"slug":"alice","turn":1}]', current_turn: 3, updated_at: 1_000, }, { conversation_id: "conv-b", message_id: "msg-b", state_json: "{}", ever_injected_json: "[]", current_turn: 0, updated_at: 2_000, }, ]); }); }); describe("conversation_graph_memory_state drain", () => { test("copies every row (full copy) and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; memory.exec(`DELETE FROM conversation_graph_memory_state`); sqlite.exec( `DROP TABLE IF EXISTS main."conversation_graph_memory_state__relocating"`, ); sqlite.exec(/*sql*/ ` CREATE TABLE main."conversation_graph_memory_state__relocating" ( conversation_id TEXT PRIMARY KEY, state_json TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); const insert = sqlite.prepare( `INSERT INTO main."conversation_graph_memory_state__relocating" (conversation_id, state_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, ); insert.run("conv-a", '{"turn":4}', 1_000, 1_500); insert.run("conv-b", "{}", 2_000, 2_000); await drainStagedTable(sqlite, CONVERSATION_GRAPH_MEMORY_STATE_RELOCATION); expect(existsInMain("conversation_graph_memory_state__relocating")).toBe( false, ); const kept = memory .query( `SELECT conversation_id, state_json, created_at, updated_at FROM conversation_graph_memory_state WHERE conversation_id IN ('conv-a', 'conv-b') ORDER BY conversation_id`, ) .all(); expect(kept).toEqual([ { conversation_id: "conv-a", state_json: '{"turn":4}', created_at: 1_000, updated_at: 1_500, }, { conversation_id: "conv-b", state_json: "{}", created_at: 2_000, updated_at: 2_000, }, ]); }); }); describe("memory_v3_ever_injected drain", () => { test("copies every row of the composite-PK table, drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate: empty live table, fresh populated staging table. memory.exec(`DELETE FROM memory_v3_ever_injected`); sqlite.exec( `DROP TABLE IF EXISTS main."memory_v3_ever_injected__relocating"`, ); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_v3_ever_injected__relocating" ( conversation_id TEXT NOT NULL, slug TEXT NOT NULL, injected_at INTEGER NOT NULL, bytes INTEGER NOT NULL DEFAULT 0, pruned_at INTEGER, PRIMARY KEY (conversation_id, slug) ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_v3_ever_injected__relocating" (conversation_id, slug, injected_at, bytes, pruned_at) VALUES (?, ?, ?, ?, ?)`, ); insert.run("conv-1", "topics/page-a", 1_000, 100, null); insert.run("conv-1", "topics/page-b", 2_000, 250, 3_000); await drainStagedTable(sqlite, MEMORY_V3_EVER_INJECTED_RELOCATION); // Staging dropped; the full-copy spec preserved every row, pruned state // included. expect(existsInMain("memory_v3_ever_injected__relocating")).toBe(false); const kept = memory .query( `SELECT conversation_id, slug, injected_at, bytes, pruned_at FROM memory_v3_ever_injected WHERE conversation_id = 'conv-1' ORDER BY slug`, ) .all(); expect(kept).toEqual([ { conversation_id: "conv-1", slug: "topics/page-a", injected_at: 1_000, bytes: 100, pruned_at: null, }, { conversation_id: "conv-1", slug: "topics/page-b", injected_at: 2_000, bytes: 250, pruned_at: 3_000, }, ]); }); }); describe("memory_retrospective_state drain", () => { test("copies every row (full copy) and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; memory.exec(`DELETE FROM memory_retrospective_state`); sqlite.exec( `DROP TABLE IF EXISTS main."memory_retrospective_state__relocating"`, ); // Staging shaped like a post-281 source, including remembered_log. sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_retrospective_state__relocating" ( conversation_id TEXT PRIMARY KEY, last_processed_message_id TEXT NOT NULL, last_run_at INTEGER NOT NULL, remembered_log TEXT ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_retrospective_state__relocating" (conversation_id, last_processed_message_id, last_run_at, remembered_log) VALUES (?, ?, ?, ?)`, ); insert.run("conv-1", "m1", 1_000, '["saved one"]'); insert.run("conv-2", "", 2_000, null); await drainStagedTable(sqlite, MEMORY_RETROSPECTIVE_STATE_RELOCATION); expect(existsInMain("memory_retrospective_state__relocating")).toBe(false); const kept = memory .query( `SELECT conversation_id, last_processed_message_id, last_run_at, remembered_log FROM memory_retrospective_state WHERE conversation_id IN ('conv-1', 'conv-2') ORDER BY conversation_id`, ) .all(); expect(kept).toEqual([ { conversation_id: "conv-1", last_processed_message_id: "m1", last_run_at: 1_000, remembered_log: '["saved one"]', }, { conversation_id: "conv-2", last_processed_message_id: "", last_run_at: 2_000, remembered_log: null, }, ]); }); test("a pre-281 legacy source NULL-fills remembered_log", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Staging shaped like migration 245's original schema — no remembered_log. memory.exec(`DELETE FROM memory_retrospective_state`); sqlite.exec( `DROP TABLE IF EXISTS main."memory_retrospective_state__relocating"`, ); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_retrospective_state__relocating" ( conversation_id TEXT PRIMARY KEY, last_processed_message_id TEXT NOT NULL, last_run_at INTEGER NOT NULL ) `); sqlite .prepare( `INSERT INTO main."memory_retrospective_state__relocating" (conversation_id, last_processed_message_id, last_run_at) VALUES (?, ?, ?)`, ) .run("conv-legacy", "m7", 5_000); await drainStagedTable(sqlite, MEMORY_RETROSPECTIVE_STATE_RELOCATION); expect(existsInMain("memory_retrospective_state__relocating")).toBe(false); const row = memory .query( `SELECT last_processed_message_id, last_run_at, remembered_log FROM memory_retrospective_state WHERE conversation_id = 'conv-legacy'`, ) .get(); expect(row).toEqual({ last_processed_message_id: "m7", last_run_at: 5_000, remembered_log: null, }); }); }); describe("memory graph cluster drain", () => { test("drains nodes then children into memory, preserving the intra-cluster cascade", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; // Clean slate on the memory side (children before parents — FK order), then // rebuild the four staging tables on main shaped like the post-205/206 // source. Staging tables carry no FK; the cascade lives on the memory side. memory.exec(`DELETE FROM memory_graph_node_edits`); memory.exec(`DELETE FROM memory_graph_triggers`); memory.exec(`DELETE FROM memory_graph_edges`); memory.exec(`DELETE FROM memory_graph_nodes`); for (const t of [ "memory_graph_nodes", "memory_graph_edges", "memory_graph_triggers", "memory_graph_node_edits", ]) { sqlite.exec(`DROP TABLE IF EXISTS main."${t}__relocating"`); } sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_graph_nodes__relocating" ( id TEXT PRIMARY KEY, content TEXT NOT NULL, type TEXT NOT NULL, created INTEGER NOT NULL, last_accessed INTEGER NOT NULL, last_consolidated INTEGER NOT NULL, emotional_charge TEXT NOT NULL, fidelity TEXT NOT NULL DEFAULT 'vivid', confidence REAL NOT NULL, significance REAL NOT NULL, stability REAL NOT NULL DEFAULT 14, reinforcement_count INTEGER NOT NULL DEFAULT 0, last_reinforced INTEGER NOT NULL, source_conversations TEXT NOT NULL DEFAULT '[]', source_type TEXT NOT NULL DEFAULT 'inferred', narrative_role TEXT, part_of_story TEXT, scope_id TEXT NOT NULL DEFAULT 'default', event_date INTEGER, image_refs TEXT ); CREATE TABLE main."memory_graph_edges__relocating" ( id TEXT PRIMARY KEY, source_node_id TEXT NOT NULL, target_node_id TEXT NOT NULL, relationship TEXT NOT NULL, weight REAL NOT NULL DEFAULT 1.0, created INTEGER NOT NULL ); CREATE TABLE main."memory_graph_triggers__relocating" ( id TEXT PRIMARY KEY, node_id TEXT NOT NULL, type TEXT NOT NULL, schedule TEXT, condition TEXT, condition_embedding BLOB, threshold REAL, event_date INTEGER, ramp_days INTEGER, follow_up_days INTEGER, recurring INTEGER NOT NULL DEFAULT 0, consumed INTEGER NOT NULL DEFAULT 0, cooldown_ms INTEGER, last_fired INTEGER ); CREATE TABLE main."memory_graph_node_edits__relocating" ( id TEXT PRIMARY KEY, node_id TEXT NOT NULL, previous_content TEXT NOT NULL, new_content TEXT NOT NULL, source TEXT NOT NULL, conversation_id TEXT, created INTEGER NOT NULL ); `); const insertNode = sqlite.prepare( `INSERT INTO main."memory_graph_nodes__relocating" (id, content, type, created, last_accessed, last_consolidated, emotional_charge, confidence, significance, last_reinforced, scope_id) VALUES (?, ?, 'semantic', 0, 0, 0, '{}', 0.5, 0.5, 0, 'default')`, ); insertNode.run("n1", "first"); insertNode.run("n2", "second"); sqlite .prepare( `INSERT INTO main."memory_graph_edges__relocating" (id, source_node_id, target_node_id, relationship, weight, created) VALUES (?, ?, ?, 'reminds-of', 1.0, 0)`, ) .run("e1", "n1", "n2"); sqlite .prepare( `INSERT INTO main."memory_graph_triggers__relocating" (id, node_id, type, recurring, consumed) VALUES (?, ?, 'semantic', 0, 0)`, ) .run("t1", "n1"); sqlite .prepare( `INSERT INTO main."memory_graph_node_edits__relocating" (id, node_id, previous_content, new_content, source, created) VALUES (?, ?, 'old', 'new', 'user', 0)`, ) .run("ed1", "n1"); // Parent first, then children: draining edges/triggers/edits before nodes // would fail FK enforcement on the memory connection (no node to point at). await drainStagedTable(sqlite, MEMORY_GRAPH_NODES_RELOCATION); await drainStagedTable(sqlite, MEMORY_GRAPH_EDGES_RELOCATION); await drainStagedTable(sqlite, MEMORY_GRAPH_TRIGGERS_RELOCATION); await drainStagedTable(sqlite, MEMORY_GRAPH_NODE_EDITS_RELOCATION); for (const t of [ "memory_graph_nodes", "memory_graph_edges", "memory_graph_triggers", "memory_graph_node_edits", ]) { expect(existsInMain(`${t}__relocating`)).toBe(false); } expect( memory.query(`SELECT id FROM memory_graph_nodes ORDER BY id`).all(), ).toEqual([{ id: "n1" }, { id: "n2" }]); expect(memory.query(`SELECT id FROM memory_graph_edges`).all()).toEqual([ { id: "e1" }, ]); expect(memory.query(`SELECT id FROM memory_graph_triggers`).all()).toEqual([ { id: "t1" }, ]); expect( memory.query(`SELECT id FROM memory_graph_node_edits`).all(), ).toEqual([{ id: "ed1" }]); // The intra-cluster ON DELETE CASCADE was recreated on the memory side and // the memory connection enforces foreign keys: deleting n1 removes its edge, // trigger, and edit, while the unrelated n2 survives. memory.exec(`DELETE FROM memory_graph_nodes WHERE id = 'n1'`); expect( memory.query(`SELECT id FROM memory_graph_nodes ORDER BY id`).all(), ).toEqual([{ id: "n2" }]); expect( memory .query< { c: number }, [] >(`SELECT COUNT(*) AS c FROM memory_graph_edges`) .get(), ).toEqual({ c: 0 }); expect( memory .query< { c: number }, [] >(`SELECT COUNT(*) AS c FROM memory_graph_triggers`) .get(), ).toEqual({ c: 0 }); expect( memory .query< { c: number }, [] >(`SELECT COUNT(*) AS c FROM memory_graph_node_edits`) .get(), ).toEqual({ c: 0 }); }); }); describe("memory_segments drain", () => { test("copies every row (full copy, including the unmapped scope_id) and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; memory.exec(`DELETE FROM memory_segments`); sqlite.exec(`DROP TABLE IF EXISTS main."memory_segments__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_segments__relocating" ( id TEXT PRIMARY KEY, message_id TEXT NOT NULL, conversation_id TEXT NOT NULL, role TEXT NOT NULL, segment_index INTEGER NOT NULL, text TEXT NOT NULL, token_estimate INTEGER NOT NULL, scope_id TEXT NOT NULL DEFAULT 'default', content_hash TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_segments__relocating" (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, content_hash, created_at, updated_at) VALUES (?, ?, ?, 'user', ?, ?, 3, ?, ?, 0, 0)`, ); insert.run("seg-1", "msg-1", "conv-1", 0, "first", "scope-a", "h1"); insert.run("seg-2", "msg-1", "conv-1", 1, "second", "default", null); await drainStagedTable(sqlite, MEMORY_SEGMENTS_RELOCATION); expect(existsInMain("memory_segments__relocating")).toBe(false); const kept = memory .query( `SELECT id, message_id, conversation_id, segment_index, text, scope_id, content_hash FROM memory_segments ORDER BY segment_index`, ) .all(); expect(kept).toEqual([ { id: "seg-1", message_id: "msg-1", conversation_id: "conv-1", segment_index: 0, text: "first", scope_id: "scope-a", content_hash: "h1", }, { id: "seg-2", message_id: "msg-1", conversation_id: "conv-1", segment_index: 1, text: "second", scope_id: "default", content_hash: null, }, ]); }); }); describe("memory_embeddings drain", () => { test("copies every polymorphic row (full copy) and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; memory.exec(`DELETE FROM memory_embeddings`); sqlite.exec(`DROP TABLE IF EXISTS main."memory_embeddings__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_embeddings__relocating" ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL, target_id TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, dimensions INTEGER NOT NULL, vector_json TEXT, vector_blob BLOB, content_hash TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE (target_type, target_id, provider, model) ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_embeddings__relocating" (id, target_type, target_id, provider, model, dimensions, vector_json, vector_blob, content_hash, created_at, updated_at) VALUES (?, ?, ?, 'p', 'm', 3, ?, NULL, ?, 0, 0)`, ); insert.run("emb-1", "segment", "seg-1", "[0.1,0.2,0.3]", "h1"); insert.run("emb-2", "summary", "sum-1", "[0.4,0.5,0.6]", null); await drainStagedTable(sqlite, MEMORY_EMBEDDINGS_RELOCATION); expect(existsInMain("memory_embeddings__relocating")).toBe(false); const kept = memory .query( `SELECT id, target_type, target_id, provider, model, dimensions, vector_json, content_hash FROM memory_embeddings ORDER BY id`, ) .all(); expect(kept).toEqual([ { id: "emb-1", target_type: "segment", target_id: "seg-1", provider: "p", model: "m", dimensions: 3, vector_json: "[0.1,0.2,0.3]", content_hash: "h1", }, { id: "emb-2", target_type: "summary", target_id: "sum-1", provider: "p", model: "m", dimensions: 3, vector_json: "[0.4,0.5,0.6]", content_hash: null, }, ]); }); }); describe("memory_summaries drain", () => { test("copies every row (full copy, including the unmapped scope_id) and drops staging", async () => { const sqlite = getSqlite(); const memory = getMemorySqlite()!; memory.exec(`DELETE FROM memory_summaries`); sqlite.exec(`DROP TABLE IF EXISTS main."memory_summaries__relocating"`); sqlite.exec(/*sql*/ ` CREATE TABLE main."memory_summaries__relocating" ( id TEXT PRIMARY KEY, scope TEXT NOT NULL, scope_key TEXT NOT NULL, summary TEXT NOT NULL, token_estimate INTEGER NOT NULL, start_at INTEGER NOT NULL, end_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL DEFAULT 1, scope_id TEXT NOT NULL DEFAULT 'default', UNIQUE (scope, scope_key) ) `); const insert = sqlite.prepare( `INSERT INTO main."memory_summaries__relocating" (id, scope, scope_key, summary, token_estimate, start_at, end_at, created_at, updated_at, version, scope_id) VALUES (?, 'conversation', ?, ?, 10, 0, 0, 0, ?, ?, ?)`, ); insert.run("sum-1", "conv-1", "first summary", 100, 2, "scope-a"); insert.run("sum-2", "conv-2", "second summary", 200, 1, "default"); await drainStagedTable(sqlite, MEMORY_SUMMARIES_RELOCATION); expect(existsInMain("memory_summaries__relocating")).toBe(false); const kept = memory .query( `SELECT id, scope, scope_key, summary, updated_at, version, scope_id FROM memory_summaries ORDER BY id`, ) .all(); expect(kept).toEqual([ { id: "sum-1", scope: "conversation", scope_key: "conv-1", summary: "first summary", updated_at: 100, version: 2, scope_id: "scope-a", }, { id: "sum-2", scope: "conversation", scope_key: "conv-2", summary: "second summary", updated_at: 200, version: 1, scope_id: "default", }, ]); }); });