import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { type DrizzleDb, getSqliteFrom, } from "../../../../../persistence/db-connection.js"; import { clearStoredDb, setStoredDb, } from "../../../../../persistence/db-singleton.js"; import { migrateActivationState } from "../../../../../persistence/migrations/232-activation-state.js"; import * as schema from "../../../../../persistence/schema/index.js"; import type { ActivationState } from "../../substrate/types.js"; import { clearEverInjected, forkActivationState, hydrate, save, seedForkActivationState, } from "../activation-store.js"; // The store resolves `activation_state` through the dedicated memory // connection (the `memory` singleton slot). Each test installs a fresh // in-memory DB carrying the relocated table's schema into that slot. function createTestDb(): DrizzleDb { const sqlite = new Database(":memory:"); sqlite.exec("PRAGMA journal_mode=WAL"); sqlite.exec("PRAGMA foreign_keys = ON"); const db = drizzle(sqlite, { schema }); // Migration uses the checkpoints table for crash recovery — bootstrap it. getSqliteFrom(db).exec(/*sql*/ ` CREATE TABLE IF NOT EXISTS memory_checkpoints ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ) `); migrateActivationState(db); return db; } function buildState(overrides: Partial = {}): ActivationState { return { messageId: "msg-1", state: { "alice-prefers-vscode": 0.42, "bob-coffee-order": 0.18 }, everInjected: [ { slug: "alice-prefers-vscode", turn: 1 }, { slug: "bob-coffee-order", turn: 2 }, ], currentTurn: 3, updatedAt: 1_700_000_000_000, ...overrides, }; } let db: DrizzleDb; beforeEach(() => { db = createTestDb(); setStoredDb("memory", db, () => {}); }); afterEach(() => { clearStoredDb("memory"); }); describe("activation-store", () => { describe("hydrate", () => { test("returns null when no row exists", async () => { expect(await hydrate("conv-missing")).toBeNull(); }); test("round-trips state through save + hydrate", async () => { const state = buildState(); await save("conv-1", state); const loaded = await hydrate("conv-1"); expect(loaded).toEqual(state); }); test("rejects rows whose state_json values are not numbers", async () => { const raw = getSqliteFrom(db); raw .query( /*sql*/ `INSERT INTO activation_state (conversation_id, message_id, state_json, ever_injected_json, current_turn, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, ) .run("conv-bad", "msg-x", '{"slug-a": "not-a-number"}', "[]", 0, 1); await expect(hydrate("conv-bad")).rejects.toThrow(); }); }); describe("save", () => { test("upserts on conflict (second save replaces first)", async () => { await save("conv-1", buildState({ currentTurn: 1 })); await save( "conv-1", buildState({ messageId: "msg-2", state: { "carla-likes-vim": 0.9 }, everInjected: [{ slug: "carla-likes-vim", turn: 5 }], currentTurn: 5, updatedAt: 1_700_000_001_000, }), ); const loaded = await hydrate("conv-1"); expect(loaded).toEqual({ messageId: "msg-2", state: { "carla-likes-vim": 0.9 }, everInjected: [{ slug: "carla-likes-vim", turn: 5 }], currentTurn: 5, updatedAt: 1_700_000_001_000, }); }); test("persists empty state map and ever-injected list", async () => { const state = buildState({ state: {}, everInjected: [] }); await save("conv-empty", state); const loaded = await hydrate("conv-empty"); expect(loaded).toEqual(state); }); }); describe("forkActivationState", () => { test("copies parent state to a new conversation id", async () => { const parentState = buildState(); await save("conv-parent", parentState); forkActivationState("conv-parent", "conv-child"); const child = await hydrate("conv-child"); expect(child).toEqual(parentState); // Parent is untouched. const parentAfter = await hydrate("conv-parent"); expect(parentAfter).toEqual(parentState); }); test("is a no-op when the parent has no state", async () => { forkActivationState("conv-parent-missing", "conv-child"); expect(await hydrate("conv-child")).toBeNull(); }); test("forking onto an existing child overwrites it", async () => { const parentState = buildState({ currentTurn: 7 }); await save("conv-parent", parentState); await save("conv-child", buildState({ currentTurn: 99 })); forkActivationState("conv-parent", "conv-child"); const child = await hydrate("conv-child"); expect(child?.currentTurn).toBe(7); }); }); describe("seedForkActivationState", () => { test("seeds ever-injected from inherited slugs with fresh counters", async () => { seedForkActivationState("conv-child", ["alice-prefers-vscode", "bob"]); const child = await hydrate("conv-child"); expect(child?.state).toEqual({}); expect(child?.currentTurn).toBe(0); expect(child?.everInjected).toEqual([ { slug: "alice-prefers-vscode", turn: 0 }, { slug: "bob", turn: 0 }, ]); }); test("is a no-op when no slugs were inherited", async () => { seedForkActivationState("conv-child", []); expect(await hydrate("conv-child")).toBeNull(); }); test("re-seeding the same fork id does not throw (retry safety)", async () => { seedForkActivationState("conv-child", ["alice-prefers-vscode"]); // The memory-DB write is outside the main-DB fork transaction, so a // rolled-back-and-retried fork re-runs this seed with the same id. The // second insert must ignore the conflict rather than fail. expect(() => seedForkActivationState("conv-child", ["alice-prefers-vscode"]), ).not.toThrow(); const child = await hydrate("conv-child"); expect(child?.everInjected).toEqual([ { slug: "alice-prefers-vscode", turn: 0 }, ]); }); }); describe("clearEverInjected", () => { test("empties the everInjected list", () => { const state = buildState({ everInjected: [ { slug: "slug-a", turn: 1 }, { slug: "slug-b", turn: 2 }, { slug: "slug-c", turn: 3 }, ], }); const result = clearEverInjected(state); expect(result.everInjected).toEqual([]); }); test("clears entries even when their turn exceeds currentTurn — the SIGKILL drift case", () => { // Regression: under turn-bounded eviction, entries with turn > // currentTurn survived forever. A non-graceful shutdown can persist // everInjected entries with high turn values, then a restart restores // the tracker from an older snapshot with a lower currentTurn. const state = buildState({ currentTurn: 5, everInjected: [ { slug: "slug-a", turn: 10 }, { slug: "slug-b", turn: 20 }, ], }); const result = clearEverInjected(state); expect(result.everInjected).toEqual([]); }); test("returns a new object — does not mutate the input", () => { const state = buildState({ everInjected: [{ slug: "slug-a", turn: 1 }], }); const result = clearEverInjected(state); expect(result.everInjected).toEqual([]); expect(state.everInjected).toEqual([{ slug: "slug-a", turn: 1 }]); expect(result).not.toBe(state); }); test("preserves every other field on the state", () => { const state = buildState(); const result = clearEverInjected(state); expect(result.messageId).toBe(state.messageId); expect(result.state).toEqual(state.state); expect(result.currentTurn).toBe(state.currentTurn); expect(result.updatedAt).toBe(state.updatedAt); }); }); }); describe("activation-store — degraded memory database", () => { // Install a connection with no underlying sqlite client so the store's // memory-DB accessor resolves to null: reads report no state and writes // no-op rather than throwing into the turn. beforeEach(() => { setStoredDb("memory", { $client: null } as unknown as DrizzleDb, () => {}); }); afterEach(() => { clearStoredDb("memory"); }); test("hydrate reports no state and save/fork no-op without throwing", async () => { expect(await hydrate("conv-1")).toBeNull(); expect(() => forkActivationState("conv-parent", "conv-child"), ).not.toThrow(); await expect(save("conv-1", buildState())).resolves.toBeUndefined(); expect(await hydrate("conv-1")).toBeNull(); }); });