feat(plugins): add SQLite plugin state store (#74190)

* feat(plugins): add experimental sqlite plugin state store
This commit is contained in:
Alex Knight
2026-04-29 23:02:14 +10:00
committed by GitHub
parent abaa4326d8
commit bbf985d50a
16 changed files with 1822 additions and 6 deletions

View File

@@ -0,0 +1,304 @@
import { mkdirSync } from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
closePluginStateSqliteStore,
createPluginStateKeyedStore,
PluginStateStoreError,
probePluginStateStore,
resetPluginStateStoreForTests,
sweepExpiredPluginStateEntries,
} from "./plugin-state-store.js";
import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
import { MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN } from "./plugin-state-store.sqlite.js";
afterEach(() => {
vi.useRealTimers();
resetPluginStateStoreForTests();
});
// ---------------------------------------------------------------------------
// Runtime smoke
// ---------------------------------------------------------------------------
describe("runtime smoke", () => {
it("creates and exercises a keyed store directly", async () => {
await withOpenClawTestState({ label: "e2e-smoke-load" }, async () => {
const store = createPluginStateKeyedStore<{ ready: boolean }>("fixture-plugin", {
namespace: "boot",
maxEntries: 10,
});
expect(store).toBeDefined();
expect(typeof store.register).toBe("function");
expect(typeof store.lookup).toBe("function");
expect(typeof store.consume).toBe("function");
});
});
it("writes and reads a value", async () => {
await withOpenClawTestState({ label: "e2e-smoke-rw" }, async () => {
const store = createPluginStateKeyedStore<{ msg: string }>("fixture-plugin", {
namespace: "data",
maxEntries: 10,
});
await store.register("greeting", { msg: "hello" });
await expect(store.lookup("greeting")).resolves.toEqual({ msg: "hello" });
});
});
it("consumes a value exactly once", async () => {
await withOpenClawTestState({ label: "e2e-smoke-consume" }, async () => {
const store = createPluginStateKeyedStore<{ token: string }>("fixture-plugin", {
namespace: "tokens",
maxEntries: 10,
});
await store.register("one-shot", { token: "abc123" });
const first = await store.consume("one-shot");
expect(first).toEqual({ token: "abc123" });
const second = await store.consume("one-shot");
expect(second).toBeUndefined();
await expect(store.lookup("one-shot")).resolves.toBeUndefined();
});
});
});
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
describe("persistence", () => {
it("survives close and reopen of the store", async () => {
await withOpenClawTestState({ label: "e2e-persist" }, async () => {
const storeA = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", {
namespace: "durable",
maxEntries: 10,
});
await storeA.register("key1", { persisted: true });
await storeA.register("key2", { persisted: true });
// Tear down the cached DB handle and option signatures simulates
// a full gateway restart while the on-disk DB survives.
resetPluginStateStoreForTests();
const storeB = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", {
namespace: "durable",
maxEntries: 10,
});
await expect(storeB.lookup("key1")).resolves.toEqual({ persisted: true });
await expect(storeB.lookup("key2")).resolves.toEqual({ persisted: true });
});
});
});
// ---------------------------------------------------------------------------
// TTL
// ---------------------------------------------------------------------------
describe("TTL", () => {
it("hides expired values and sweep removes the row", async () => {
await withOpenClawTestState({ label: "e2e-ttl" }, async () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", {
namespace: "ttl-test",
maxEntries: 10,
});
await store.register("short", { v: 1 }, { ttlMs: 500 });
await store.register("long", { v: 2 }, { ttlMs: 60_000 });
// Before expiry both visible.
await expect(store.lookup("short")).resolves.toEqual({ v: 1 });
await expect(store.lookup("long")).resolves.toEqual({ v: 2 });
// Advance past the short TTL.
vi.setSystemTime(10_600);
// Expired value is invisible to reads.
await expect(store.lookup("short")).resolves.toBeUndefined();
await expect(store.lookup("long")).resolves.toEqual({ v: 2 });
// Sweep physically removes the expired row.
const swept = sweepExpiredPluginStateEntries();
expect(swept).toBe(1);
// After sweep the entry list contains only the long-lived record.
const remaining = await store.entries();
expect(remaining).toHaveLength(1);
expect(remaining[0].key).toBe("long");
});
});
});
// ---------------------------------------------------------------------------
// Isolation
// ---------------------------------------------------------------------------
describe("isolation", () => {
it("segregates plugins sharing namespace and key", async () => {
await withOpenClawTestState({ label: "e2e-isolation" }, async () => {
const pluginA = createPluginStateKeyedStore<{ owner: string }>("plugin-a", {
namespace: "x",
maxEntries: 10,
});
const pluginB = createPluginStateKeyedStore<{ owner: string }>("plugin-b", {
namespace: "x",
maxEntries: 10,
});
await pluginA.register("same", { owner: "a" });
await pluginB.register("same", { owner: "b" });
await expect(pluginA.lookup("same")).resolves.toEqual({ owner: "a" });
await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" });
// Clearing one plugin's namespace does not affect the other.
await pluginA.clear();
await expect(pluginA.lookup("same")).resolves.toBeUndefined();
await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" });
});
});
});
// ---------------------------------------------------------------------------
// Limits
// ---------------------------------------------------------------------------
describe("limits", () => {
it("accepts a value at the 64 KB boundary", async () => {
await withOpenClawTestState({ label: "e2e-limit-accept" }, async () => {
const store = createPluginStateKeyedStore<string>("fixture-plugin", {
namespace: "size",
maxEntries: 10,
});
// JSON.stringify wraps a string in quotes (+2 bytes).
// 65 534 chars → 65 536 bytes of JSON → exactly at limit.
const boundary = "x".repeat(65_534);
await expect(store.register("big", boundary)).resolves.toBeUndefined();
await expect(store.lookup("big")).resolves.toBe(boundary);
});
});
it("rejects a value one byte over 64 KB", async () => {
await withOpenClawTestState({ label: "e2e-limit-reject" }, async () => {
const store = createPluginStateKeyedStore<string>("fixture-plugin", {
namespace: "size",
maxEntries: 10,
});
// 65 535 chars → 65 537 bytes of JSON → over limit.
const oversize = "x".repeat(65_535);
await expect(store.register("big", oversize)).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
});
});
it("enforces the per-plugin live-row cap", async () => {
await withOpenClawTestState({ label: "e2e-limit-plugin" }, async () => {
// Spread MAX_ENTRIES_PER_PLUGIN rows across several namespaces so
// namespace eviction never fires (each namespace has generous room).
const nsCount = 10;
const perNs = MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN / nsCount; // 100
const stores = Array.from({ length: nsCount }, (_, i) =>
createPluginStateKeyedStore("fixture-plugin", {
namespace: `ns-${i}`,
maxEntries: perNs + 1,
}),
);
for (let ns = 0; ns < nsCount; ns += 1) {
for (let k = 0; k < perNs; k += 1) {
await stores[ns].register(`k-${k}`, { ns, k });
}
}
// One more row tips over the plugin-wide limit.
await expect(stores[0].register("overflow", { boom: true })).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
});
});
it("evicts oldest entries when namespace maxEntries is exceeded", async () => {
await withOpenClawTestState({ label: "e2e-limit-eviction" }, async () => {
vi.useFakeTimers();
const store = createPluginStateKeyedStore<number>("fixture-plugin", {
namespace: "capped",
maxEntries: 3,
});
vi.setSystemTime(1000);
await store.register("a", 1);
vi.setSystemTime(2000);
await store.register("b", 2);
vi.setSystemTime(3000);
await store.register("c", 3);
vi.setSystemTime(4000);
await store.register("d", 4); // should evict "a"
const entries = await store.entries();
expect(entries).toHaveLength(3);
expect(entries.map((e) => e.key)).toEqual(["b", "c", "d"]);
await expect(store.lookup("a")).resolves.toBeUndefined();
});
});
});
// ---------------------------------------------------------------------------
// Failure safety
// ---------------------------------------------------------------------------
describe("failure safety", () => {
it("gives a typed error for unsupported schema versions", async () => {
await withOpenClawTestState({ label: "e2e-fail-schema" }, async () => {
// Pre-seed the DB with a future schema version.
mkdirSync(resolvePluginStateDir(), { recursive: true });
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(resolvePluginStateSqlitePath());
db.exec("PRAGMA user_version = 99;");
db.close();
const store = createPluginStateKeyedStore("fixture-plugin", {
namespace: "schema",
maxEntries: 10,
});
const error = await store.register("k", { ok: true }).catch((e: unknown) => e);
expect(error).toBeInstanceOf(PluginStateStoreError);
expect(error).toMatchObject({ code: "PLUGIN_STATE_SCHEMA_UNSUPPORTED" });
});
});
it("probe returns redacted diagnostics without leaking stored values", async () => {
await withOpenClawTestState({ label: "e2e-fail-probe" }, async () => {
const result = probePluginStateStore();
expect(result.ok).toBe(true);
expect(result.dbPath).toContain("state.sqlite");
expect(result.steps.length).toBeGreaterThanOrEqual(4);
expect(result.steps.every((s) => s.ok)).toBe(true);
// The probe's temporary stored value must not leak into the result.
const serialised = JSON.stringify(result);
expect(serialised).not.toContain("probe-value");
});
});
it("close and reopen cycle is clean", async () => {
await withOpenClawTestState({ label: "e2e-fail-reopen" }, async () => {
const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", {
namespace: "reopen",
maxEntries: 10,
});
await store.register("k", { v: 1 });
// First close.
closePluginStateSqliteStore();
await expect(store.lookup("k")).resolves.toEqual({ v: 1 });
// Second close (idempotent).
closePluginStateSqliteStore();
await expect(store.lookup("k")).resolves.toEqual({ v: 1 });
// Write after reopen.
await store.register("k", { v: 2 });
await expect(store.lookup("k")).resolves.toEqual({ v: 2 });
});
});
});