import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getNodeSqliteKysely } from "../infra/kysely-sync.js"; import { closeOpenClawStateDatabaseByPath } from "../state/openclaw-state-db-cache.js"; import { openOpenClawStateDatabase, runOpenClawStateWriteTransaction, } from "../state/openclaw-state-db.js"; import { createOpenClawTestState, type OpenClawTestState, } from "../test-utils/openclaw-test-state.js"; import { createPluginStateSyncKeyedStore, resetPluginStateStoreForTests, } from "./plugin-state-store.js"; import { lookupPluginStateEntry, registerPluginStateEntry } from "./plugin-state-store.kernel.js"; import { closePluginStateDatabase } from "./plugin-state-store.sqlite.js"; import { clearPluginStateStoreForTests, seedPluginStateEntriesForTests, } from "./plugin-state-store.test-helpers.js"; let testState: OpenClawTestState; beforeAll(async () => { testState = await createOpenClawTestState({ label: "plugin-state-prepared" }); }); beforeEach(() => { testState.applyEnv(); clearPluginStateStoreForTests(); }); afterEach(() => { resetPluginStateStoreForTests(); }); afterAll(async () => { await testState.cleanup(); }); describe("plugin state prepared queries", () => { it("uses the supplied connection and keeps registration eviction in its owner's transaction", () => { const scope = { pluginId: "discord", namespace: "owned-kernel" }; const defaultStore = createPluginStateSyncKeyedStore(scope.pluginId, { namespace: scope.namespace, maxEntries: 1, }); defaultStore.register("original", "default database"); const pathname = testState.statePath("kernel-owned.sqlite"); const database = openOpenClawStateDatabase({ path: pathname, env: testState.env }); const options = { database, env: testState.env }; const entry = { ...scope, maxEntries: 1, overflowPolicy: "evict-oldest" as const }; runOpenClawStateWriteTransaction(() => { registerPluginStateEntry(database, { ...entry, key: "original", valueJson: '"owned"' }, 10); }, options); const aborted = new Error("abort the caller's transaction"); expect(() => runOpenClawStateWriteTransaction(() => { registerPluginStateEntry( database, { ...entry, key: "pending", valueJson: '"pending"' }, 10, ); expect(lookupPluginStateEntry(database, { ...scope, key: "pending" })).toBe("pending"); expect(lookupPluginStateEntry(database, { ...scope, key: "original" })).toBeUndefined(); throw aborted; }, options), ).toThrow(aborted); expect(lookupPluginStateEntry(database, { ...scope, key: "pending" })).toBeUndefined(); expect(lookupPluginStateEntry(database, { ...scope, key: "original" })).toBe("owned"); expect(defaultStore.lookup("original")).toBe("default database"); closeOpenClawStateDatabaseByPath(pathname); const reopened = openOpenClawStateDatabase({ path: pathname, env: testState.env }); expect(lookupPluginStateEntry(reopened, { ...scope, key: "original" })).toBe("owned"); expect(lookupPluginStateEntry(reopened, { ...scope, key: "pending" })).toBeUndefined(); }); it("compiles exact reads once per connection with fresh scope and expiry bindings", () => { const now = Date.now(); seedPluginStateEntriesForTests([ { pluginId: "discord", namespace: "prepared", key: "first", value: 1, expiresAt: now + 100 }, { pluginId: "discord", namespace: "prepared", key: "second", value: 2 }, { pluginId: "telegram", namespace: "prepared", key: "first", value: 3 }, { pluginId: "discord", namespace: "sibling", key: "first", value: 4 }, ]); const store = createPluginStateSyncKeyedStore("discord", { namespace: "prepared", maxEntries: 10, }); const pluginSibling = createPluginStateSyncKeyedStore("telegram", { namespace: "prepared", maxEntries: 10, }); const namespaceSibling = createPluginStateSyncKeyedStore("discord", { namespace: "sibling", maxEntries: 10, }); const clock = vi.spyOn(Date, "now").mockReturnValue(now); try { for (let connection = 0; connection < 2; connection++) { closePluginStateDatabase(); const { db } = openOpenClawStateDatabase(); const compile = vi.spyOn(getNodeSqliteKysely(db).getExecutor(), "compileQuery"); try { clock.mockReturnValue(now); expect(store.lookup("first")).toBe(1); expect(store.lookup("second")).toBe(2); expect(pluginSibling.lookup("first")).toBe(3); expect(namespaceSibling.lookup("first")).toBe(4); expect(store.lookup("missing")).toBeUndefined(); clock.mockReturnValue(now + 100); expect(store.lookup("first")).toBeUndefined(); expect(store.lookup("second")).toBe(2); expect(compile).toHaveBeenCalledOnce(); } finally { compile.mockRestore(); } } } finally { clock.mockRestore(); } }); it.each([ ["register", "evict-oldest"], ["register", "reject-new"], ["registerIfAbsent", "evict-oldest"], ] as const)( "reuses %s %s write and quota compilation with fresh bindings after reopening", (operation, overflowPolicy) => { const options = { namespace: "prepared-writes", maxEntries: 20, overflowPolicy }; const stores = [ createPluginStateSyncKeyedStore("discord", options), createPluginStateSyncKeyedStore("telegram", options), createPluginStateSyncKeyedStore("discord", { ...options, namespace: "prepared-sibling", }), ]; const clock = vi.spyOn(Date, "now").mockReturnValue(10_000); try { for (let connection = 0; connection < 2; connection++) { closePluginStateDatabase(); const { db } = openOpenClawStateDatabase(); const compile = vi.spyOn(getNodeSqliteKysely(db).getExecutor(), "compileQuery"); try { const key = `round-${connection}`; for (const [index, store] of stores.entries()) { clock.mockReturnValue(10_000 + index); store[operation](key, `value-${index}`, { ttlMs: 100 }); clock.mockReturnValue(10_010 + index); store[operation](`${key}-durable`, `durable-${index}`); const result = store[operation](key, `replacement-${index}`); if (operation === "registerIfAbsent") { expect(result).toBe(false); } const expected = operation === "registerIfAbsent" ? { key, value: `value-${index}`, createdAt: 10_000 + index, expiresAt: 10_100 + index, } : { key, value: `replacement-${index}`, createdAt: 10_010 + index }; expect(store.entries().filter((entry) => entry.key.startsWith(key))).toEqual([ expected, { key: `${key}-durable`, value: `durable-${index}`, createdAt: 10_010 + index }, ]); } const writes = compile.mock.results.filter( (result) => result.type === "return" && result.value.sql.startsWith("insert"), ); expect(writes).toHaveLength(1); const counts = compile.mock.results.filter( (result) => result.type === "return" && result.value.sql.startsWith( 'select count(*) as "count" from "plugin_state_entries"', ), ); expect(counts).toHaveLength(2); } finally { compile.mockRestore(); } } } finally { clock.mockRestore(); } }, ); });