mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 08:52:52 +00:00
Adds the shared SQLite state database base, moves plugin keyed state into it with doctor migration coverage, and keeps generated Kysely guardrails aligned. Proof: focused SQLite/plugin-state tests, db:kysely:check, lint:kysely, architecture/dependency guards, autoreview, and PR CI all clean.
244 lines
9.0 KiB
TypeScript
244 lines
9.0 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||
import {
|
||
closePluginStateDatabase,
|
||
createPluginStateKeyedStore,
|
||
PluginStateStoreError,
|
||
probePluginStateStore,
|
||
resetPluginStateStoreForTests,
|
||
sweepExpiredPluginStateEntries,
|
||
} from "./plugin-state-store.js";
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
resetPluginStateStoreForTests();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Runtime smoke
|
||
// ---------------------------------------------------------------------------
|
||
describe("runtime smoke", () => {
|
||
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("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("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.databasePath).toContain("openclaw.sqlite");
|
||
expect(result.steps.length).toBeGreaterThanOrEqual(4);
|
||
const failedSteps = result.steps.filter((step) => !step.ok);
|
||
expect(failedSteps).toEqual([]);
|
||
|
||
// 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.
|
||
closePluginStateDatabase();
|
||
await expect(store.lookup("k")).resolves.toEqual({ v: 1 });
|
||
|
||
// Second close (idempotent).
|
||
closePluginStateDatabase();
|
||
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 });
|
||
});
|
||
});
|
||
});
|