| |
| import { chmodSync, existsSync, rmSync, statSync } from "node:fs"; |
| import path from "node:path"; |
| import { runInNewContext } from "node:vm"; |
| import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; |
| import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { closeOpenClawStateDatabaseByPath } from "../state/openclaw-state-db-cache.js"; |
| import { |
| isOpenClawStateDatabaseOpen, |
| openOpenClawStateDatabase, |
| } from "../state/openclaw-state-db.js"; |
| import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; |
| import { |
| createOpenClawTestState, |
| withOpenClawTestState, |
| type OpenClawTestState, |
| } from "../test-utils/openclaw-test-state.js"; |
| import { |
| countPluginStateLiveEntries, |
| createCorePluginStateKeyedStore, |
| createCorePluginStateSyncKeyedStore, |
| createPluginStateKeyedStore, |
| createPluginStateSyncKeyedStore, |
| pluginStateEntriesInKeyRange, |
| resetPluginStateStoreForTests, |
| sweepExpiredPluginStateEntries, |
| } from "./plugin-state-store.js"; |
| import { closePluginStateDatabase } from "./plugin-state-store.sqlite.js"; |
| import { |
| clearPluginStateStoreForTests, |
| probePluginStateStore, |
| seedPluginStateEntriesForTests, |
| setMaxPluginStateEntriesPerPluginForTests, |
| } from "./plugin-state-store.test-helpers.js"; |
| import { PluginStateStoreError } from "./plugin-state-store.types.js"; |
|
|
| let testState: OpenClawTestState | undefined; |
|
|
| beforeAll(async () => { |
| testState = await createOpenClawTestState({ label: "plugin-state-store" }); |
| rmSync(path.dirname(resolveOpenClawStateSqlitePath()), { recursive: true, force: true }); |
| }); |
|
|
| beforeEach(() => { |
| testState?.applyEnv(); |
| clearPluginStateStoreForTests(); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| setMaxPluginStateEntriesPerPluginForTests(undefined); |
| resetPluginStateStoreForTests({ closeDatabase: false }); |
| }); |
|
|
| afterAll(async () => { |
| resetPluginStateStoreForTests(); |
| await testState?.cleanup(); |
| }); |
|
|
| async function withPluginStateTestState<T>(fn: () => Promise<T>): Promise<T> { |
| return await fn(); |
| } |
|
|
| async function expectPluginStateStoreError( |
| promise: Promise<unknown>, |
| expected: { code: string; operation?: string }, |
| ): Promise<void> { |
| let storeError: unknown; |
| try { |
| await promise; |
| } catch (error) { |
| storeError = error; |
| } |
| expect(storeError).toBeInstanceOf(PluginStateStoreError); |
| expect((storeError as PluginStateStoreError | undefined)?.code).toBe(expected.code); |
| if (expected.operation) { |
| expect((storeError as PluginStateStoreError | undefined)?.operation).toBe(expected.operation); |
| } |
| } |
|
|
| describe("plugin state keyed store", () => { |
| it("round-trips nested VM realm values across store instances", async () => { |
| await withPluginStateTestState(async () => { |
| const options = { namespace: "components", maxEntries: 10 }; |
| const store = createPluginStateKeyedStore("discord", options); |
| const value: unknown = runInNewContext( |
| '({ nested: [{ count: 1, labels: ["retained", null] }] })', |
| ); |
| await store.register("interaction:1", value); |
| closePluginStateDatabase(); |
|
|
| const reopened = createPluginStateSyncKeyedStore("discord", options); |
| expect(reopened.lookup("interaction:1")).toEqual({ |
| nested: [{ count: 1, labels: ["retained", null] }], |
| }); |
| }); |
| }); |
|
|
| it("supports synchronous keyed store callers", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateSyncKeyedStore<{ count: number }>("discord", { |
| namespace: "sync-components", |
| maxEntries: 10, |
| }); |
|
|
| expect(store.registerIfAbsent("interaction:1", { count: 1 })).toBe(true); |
| expect(store.registerIfAbsent("interaction:1", { count: 2 })).toBe(false); |
| expect(store.lookup("interaction:1")).toEqual({ count: 1 }); |
| expect(store.entries()).toMatchObject([{ key: "interaction:1", value: { count: 1 } }]); |
| expect(store.consume("interaction:1")).toEqual({ count: 1 }); |
| expect(store.lookup("interaction:1")).toBeUndefined(); |
| }); |
| }); |
|
|
| it("shares sync and async state while preserving their error contracts", async () => { |
| await withPluginStateTestState(async () => { |
| const options = { namespace: "shared-sync-async", maxEntries: 10 }; |
| const asyncStore = createPluginStateKeyedStore<{ count: number }>("discord", options); |
| const syncStore = createPluginStateSyncKeyedStore<{ count: number }>("discord", options); |
|
|
| syncStore.register("counter", { count: 1 }); |
| await expect(asyncStore.lookup("counter")).resolves.toEqual({ count: 1 }); |
|
|
| await expect( |
| asyncStore.update("counter", (current) => ({ count: (current?.count ?? 0) + 1 })), |
| ).resolves.toBe(true); |
| expect(syncStore.lookup("counter")).toEqual({ count: 2 }); |
|
|
| expect(() => syncStore.lookup(" ")).toThrow(PluginStateStoreError); |
| await expect(asyncStore.lookup(" ")).rejects.toThrow(PluginStateStoreError); |
| }); |
| }); |
|
|
| it("reads a bounded sortable key range without scanning sibling keys", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateSyncKeyedStore<{ count: number }>("memory-core", { |
| namespace: "events", |
| maxEntries: 10, |
| }); |
| store.register("workspace:event:0001", { count: 1 }); |
| store.register("workspace:event:0002", { count: 2 }); |
| store.register("workspace:other:0003", { count: 3 }); |
|
|
| expect( |
| await pluginStateEntriesInKeyRange({ |
| pluginId: "memory-core", |
| namespace: "events", |
| keyStartInclusive: "workspace:event:", |
| keyEndExclusive: "workspace:event;", |
| limit: 1, |
| order: "desc", |
| }), |
| ).toMatchObject([{ key: "workspace:event:0002", value: { count: 2 } }]); |
| }); |
| }); |
|
|
| it("updates a key from the current stored value", async () => { |
| await withPluginStateTestState(async () => { |
| setMaxPluginStateEntriesPerPluginForTests(10); |
| const store = createPluginStateSyncKeyedStore<{ count: number }>("discord", { |
| namespace: "sync-update", |
| maxEntries: 10, |
| }); |
| const update = store.update; |
|
|
| expect(update("counter", (current) => ({ count: (current?.count ?? 0) + 1 }))).toBe(true); |
| expect(update("counter", (current) => ({ count: (current?.count ?? 0) + 1 }))).toBe(true); |
| expect(update("counter", () => undefined)).toBe(false); |
| expect(store.lookup("counter")).toEqual({ count: 2 }); |
| }); |
| }); |
|
|
| it("honors explicit store env without mutating process state", async () => { |
| await withOpenClawTestState( |
| { label: "plugin-state-explicit-env-a", applyEnv: false }, |
| async (stateA) => { |
| await withOpenClawTestState( |
| { label: "plugin-state-explicit-env-b", applyEnv: false }, |
| async (stateB) => { |
| const storeA = createPluginStateKeyedStore<{ owner: string }>("discord", { |
| namespace: "explicit-env", |
| maxEntries: 10, |
| env: stateA.env, |
| }); |
| const storeB = createPluginStateKeyedStore<{ owner: string }>("discord", { |
| namespace: "explicit-env", |
| maxEntries: 10, |
| env: stateB.env, |
| }); |
|
|
| await storeA.register("shared", { owner: "a" }); |
| await storeB.register("shared", { owner: "b" }); |
|
|
| await expect(storeA.lookup("shared")).resolves.toEqual({ owner: "a" }); |
| await expect(storeB.lookup("shared")).resolves.toEqual({ owner: "b" }); |
| expect(resolveOpenClawStateSqlitePath(stateA.env)).not.toBe( |
| resolveOpenClawStateSqlitePath(stateB.env), |
| ); |
| }, |
| ); |
| }, |
| ); |
| }); |
|
|
| it("upserts values and refreshes deterministic entry ordering", async () => { |
| await withPluginStateTestState(async () => { |
| vi.useFakeTimers(); |
| const store = createPluginStateSyncKeyedStore<{ version: number }>("discord", { |
| namespace: "components", |
| maxEntries: 10, |
| }); |
| vi.setSystemTime(1000); |
| store.register("b", { version: 1 }); |
| vi.setSystemTime(2000); |
| store.register("a", { version: 1 }); |
| vi.setSystemTime(3000); |
| store.register("b", { version: 2 }); |
|
|
| expect(store.lookup("b")).toEqual({ version: 2 }); |
| expect(store.entries()).toEqual([ |
| { key: "a", value: { version: 1 }, createdAt: 2000 }, |
| { key: "b", value: { version: 2 }, createdAt: 3000 }, |
| ]); |
| }); |
| }); |
|
|
| it("refreshes the default TTL when register upserts an existing key", async () => { |
| await withPluginStateTestState(async () => { |
| vi.useFakeTimers(); |
| const store = createPluginStateSyncKeyedStore<{ version: number }>("beam", { |
| namespace: "sessions", |
| maxEntries: 10, |
| defaultTtlMs: 1_000, |
| }); |
| vi.setSystemTime(1_000); |
| store.register("session", { version: 1 }); |
| vi.setSystemTime(1_500); |
| store.register("session", { version: 2 }); |
|
|
| expect(store.entries()).toEqual([ |
| { key: "session", value: { version: 2 }, createdAt: 1_500, expiresAt: 2_500 }, |
| ]); |
| vi.setSystemTime(2_100); |
| expect(store.lookup("session")).toEqual({ version: 2 }); |
| vi.setSystemTime(2_501); |
| expect(store.lookup("session")).toBeUndefined(); |
| }); |
| }); |
|
|
| it("registerIfAbsent inserts the first value and preserves live duplicates", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore<{ version: number }>("discord", { |
| namespace: "claims", |
| maxEntries: 10, |
| }); |
|
|
| const before = Date.now(); |
| await expect(store.registerIfAbsent("claim", { version: 1 }, { ttlMs: 1000 })).resolves.toBe( |
| true, |
| ); |
| const after = Date.now(); |
| const [created] = await store.entries(); |
| expect(created?.createdAt).toBeGreaterThanOrEqual(before); |
| expect(created?.createdAt).toBeLessThanOrEqual(after); |
| expect(created?.expiresAt).toBe(created!.createdAt + 1000); |
| await expect(store.registerIfAbsent("claim", { version: 2 }, { ttlMs: 5000 })).resolves.toBe( |
| false, |
| ); |
|
|
| await expect(store.lookup("claim")).resolves.toEqual({ version: 1 }); |
| await expect(store.entries()).resolves.toEqual([ |
| { |
| key: "claim", |
| value: { version: 1 }, |
| createdAt: created!.createdAt, |
| expiresAt: created!.expiresAt, |
| }, |
| ]); |
| }); |
| }); |
|
|
| it("registerIfAbsent replaces expired keys", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore<{ version: number }>("discord", { |
| namespace: "claims-expired", |
| maxEntries: 10, |
| }); |
|
|
| seedPluginStateEntriesForTests([ |
| { |
| pluginId: "discord", |
| namespace: "claims-expired", |
| key: "claim", |
| value: { version: 1 }, |
| createdAt: 1000, |
| expiresAt: 1100, |
| }, |
| ]); |
| const before = Date.now(); |
| await expect(store.registerIfAbsent("claim", { version: 2 })).resolves.toBe(true); |
| const after = Date.now(); |
| const [created] = await store.entries(); |
| expect(created?.createdAt).toBeGreaterThanOrEqual(before); |
| expect(created?.createdAt).toBeLessThanOrEqual(after); |
|
|
| await expect(store.lookup("claim")).resolves.toEqual({ version: 2 }); |
| await expect(store.entries()).resolves.toEqual([ |
| { key: "claim", value: { version: 2 }, createdAt: created!.createdAt }, |
| ]); |
| }); |
| }); |
|
|
| it("rejects new durable rows at capacity without evicting or blocking updates", async () => { |
| await withPluginStateTestState(async () => { |
| vi.useFakeTimers(); |
| setMaxPluginStateEntriesPerPluginForTests(2); |
| const store = createPluginStateSyncKeyedStore<number>("codex", { |
| namespace: "durable-bindings", |
| maxEntries: 2, |
| overflowPolicy: "reject-new", |
| }); |
| vi.setSystemTime(1000); |
| store.register("first", 1); |
| vi.setSystemTime(2000); |
| store.register("second", 2); |
|
|
| expect(() => store.register("third", 3)).toThrowError( |
| expect.objectContaining({ |
| code: "PLUGIN_STATE_LIMIT_EXCEEDED", |
| operation: "register", |
| message: "Plugin state namespace durable-bindings for codex reached its 2-row limit.", |
| }), |
| ); |
| expect(store.registerIfAbsent("first", 99)).toBe(false); |
| vi.setSystemTime(3000); |
| expect(store.update("first", () => 10)).toBe(true); |
| expect(() => store.update("third", () => 3)).toThrowError( |
| expect.objectContaining({ |
| code: "PLUGIN_STATE_LIMIT_EXCEEDED", |
| operation: "register", |
| message: "Plugin state namespace durable-bindings for codex reached its 2-row limit.", |
| }), |
| ); |
| expect(store.entries()).toEqual([ |
| expect.objectContaining({ key: "second", value: 2 }), |
| expect.objectContaining({ key: "first", value: 10 }), |
| ]); |
| }); |
| }); |
|
|
| it("deletes an entry only when the current value matches", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore<{ version: number }>("device-pair", { |
| namespace: "notify-subscribers", |
| maxEntries: 10, |
| }); |
| await store.register("chat", { version: 1 }); |
|
|
| await expect(store.deleteIf("chat", (current) => current.version === 2)).resolves.toBe(false); |
| await expect(store.lookup("chat")).resolves.toEqual({ version: 1 }); |
| await expect(store.deleteIf("chat", (current) => current.version === 1)).resolves.toBe(true); |
| await expect(store.lookup("chat")).resolves.toBeUndefined(); |
| }); |
| }); |
|
|
| it("registerIfAbsent keeps plugin and namespace claims isolated", async () => { |
| await withPluginStateTestState(async () => { |
| const discordA = createPluginStateKeyedStore<{ owner: string }>("discord", { |
| namespace: "claims-a", |
| maxEntries: 10, |
| }); |
| const discordB = createPluginStateKeyedStore<{ owner: string }>("discord", { |
| namespace: "claims-b", |
| maxEntries: 10, |
| }); |
| const telegramA = createPluginStateKeyedStore<{ owner: string }>("telegram", { |
| namespace: "claims-a", |
| maxEntries: 10, |
| }); |
|
|
| await expect(discordA.registerIfAbsent("same", { owner: "discord-a" })).resolves.toBe(true); |
| await expect(discordB.registerIfAbsent("same", { owner: "discord-b" })).resolves.toBe(true); |
| await expect(telegramA.registerIfAbsent("same", { owner: "telegram-a" })).resolves.toBe(true); |
| await expect(discordA.registerIfAbsent("same", { owner: "overwrite" })).resolves.toBe(false); |
|
|
| await expect(discordA.lookup("same")).resolves.toEqual({ owner: "discord-a" }); |
| await expect(discordB.lookup("same")).resolves.toEqual({ owner: "discord-b" }); |
| await expect(telegramA.lookup("same")).resolves.toEqual({ owner: "telegram-a" }); |
| }); |
| }); |
|
|
| it("registerIfAbsent only lets one parallel claimant win", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore<{ claimant: number }>("discord", { |
| namespace: "claims-race", |
| maxEntries: 10, |
| }); |
|
|
| const attempts = await Promise.all( |
| Array.from({ length: 25 }, async (_, claimant) => |
| store.registerIfAbsent("claim", { claimant }), |
| ), |
| ); |
|
|
| expect(attempts.reduce((count, attempt) => count + (attempt ? 1 : 0), 0)).toBe(1); |
| const stored = await store.lookup("claim"); |
| if (stored === undefined) { |
| throw new Error("expected winning plugin-state claim"); |
| } |
| expect(attempts[stored.claimant]).toBe(true); |
| }); |
| }); |
|
|
| it("registerIfAbsent preserves eviction and plugin row cap behavior", async () => { |
| await withPluginStateTestState(async () => { |
| const maxPluginEntries = 40; |
| setMaxPluginStateEntriesPerPluginForTests(maxPluginEntries); |
| vi.useFakeTimers(); |
| const evicting = createPluginStateSyncKeyedStore<number>("discord", { |
| namespace: "claims-evict", |
| maxEntries: 2, |
| }); |
| vi.setSystemTime(1000); |
| evicting.registerIfAbsent("a", 1); |
| vi.setSystemTime(2000); |
| evicting.registerIfAbsent("b", 2); |
| vi.setSystemTime(3000); |
| evicting.registerIfAbsent("c", 3); |
| expect(evicting.entries().map((entry) => entry.key)).toEqual(["b", "c"]); |
|
|
| vi.useRealTimers(); |
| seedPluginStateEntriesForTests([ |
| ...Array.from({ length: maxPluginEntries - 1 }, (_, entryIndex) => ({ |
| pluginId: "limited-plugin", |
| namespace: "limit", |
| key: `k-${entryIndex}`, |
| value: { entryIndex }, |
| })), |
| { |
| pluginId: "limited-plugin", |
| namespace: "sibling", |
| key: "k-0", |
| value: { sibling: true }, |
| }, |
| ]); |
| const limited = createPluginStateKeyedStore("limited-plugin", { |
| namespace: "limit", |
| maxEntries: maxPluginEntries + 1, |
| }); |
| const sibling = createPluginStateKeyedStore("limited-plugin", { |
| namespace: "sibling", |
| maxEntries: 10, |
| }); |
| await expect(limited.registerIfAbsent("overflow", { overflow: true })).resolves.toBe(true); |
| await expect(limited.lookup("k-0")).resolves.toBeUndefined(); |
| await expect(limited.lookup("overflow")).resolves.toEqual({ overflow: true }); |
| await expect(sibling.lookup("k-0")).resolves.toEqual({ sibling: true }); |
| }); |
| }); |
|
|
| it("returns undefined for missing lookups and consumes by deleting atomically", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore<{ ok: boolean }>("discord", { |
| namespace: "components", |
| maxEntries: 10, |
| }); |
|
|
| await expect(store.lookup("missing")).resolves.toBeUndefined(); |
| await expect(store.consume("missing")).resolves.toBeUndefined(); |
| await store.register("k", { ok: true }); |
| await expect(store.consume("k")).resolves.toEqual({ ok: true }); |
| await expect(store.lookup("k")).resolves.toBeUndefined(); |
| }); |
| }); |
|
|
| it("deletes and clears only the targeted namespace", async () => { |
| await withPluginStateTestState(async () => { |
| const first = createPluginStateKeyedStore("discord", { namespace: "a", maxEntries: 10 }); |
| const second = createPluginStateKeyedStore("discord", { namespace: "b", maxEntries: 10 }); |
| await first.register("k1", { value: 1 }); |
| await second.register("k2", { value: 2 }); |
|
|
| await expect(first.delete("k1")).resolves.toBe(true); |
| await expect(first.delete("k1")).resolves.toBe(false); |
| await first.register("k1", { value: 1 }); |
| await first.clear(); |
|
|
| await expect(first.entries()).resolves.toStrictEqual([]); |
| await expect(second.lookup("k2")).resolves.toEqual({ value: 2 }); |
| }); |
| }); |
|
|
| it("excludes expired entries and sweeps them", async () => { |
| await withPluginStateTestState(async () => { |
| vi.useFakeTimers(); |
| vi.setSystemTime(1000); |
| const store = createPluginStateSyncKeyedStore("discord", { |
| namespace: "ttl", |
| maxEntries: 10, |
| defaultTtlMs: 100, |
| }); |
| store.register("default", { value: "default" }); |
| store.register("override", { value: "override" }, { ttlMs: 500 }); |
|
|
| vi.setSystemTime(1200); |
| expect(store.lookup("default")).toBeUndefined(); |
| expect(store.lookup("override")).toEqual({ value: "override" }); |
| expect(sweepExpiredPluginStateEntries()).toBe(1); |
| expect(store.entries().map((entry) => entry.key)).toEqual(["override"]); |
| }); |
| }); |
|
|
| it("rejects plugin state ttl when expiry cannot fit in a Date timestamp", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore("discord", { |
| namespace: "ttl-bounds", |
| maxEntries: 10, |
| }); |
|
|
| await expectPluginStateStoreError(store.register("huge", true, { ttlMs: Number.MAX_VALUE }), { |
| code: "PLUGIN_STATE_INVALID_INPUT", |
| operation: "register", |
| }); |
|
|
| const nowSpy = vi.spyOn(Date, "now"); |
| try { |
| nowSpy.mockReturnValue(MAX_DATE_TIMESTAMP_MS); |
| const sync = createPluginStateSyncKeyedStore("discord", { |
| namespace: "ttl-bounds", |
| maxEntries: 10, |
| }); |
| expect(() => sync.register("overflow", true, { ttlMs: 60_000 })).toThrowError( |
| expect.objectContaining({ code: "PLUGIN_STATE_INVALID_INPUT", operation: "register" }), |
| ); |
| } finally { |
| nowSpy.mockRestore(); |
| } |
| }); |
| }); |
|
|
| it("segregates plugins sharing a namespace and key", async () => { |
| await withPluginStateTestState(async () => { |
| const discord = createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 10 }); |
| const telegram = createPluginStateKeyedStore("telegram", { |
| namespace: "same", |
| maxEntries: 10, |
| }); |
| await discord.register("k", { plugin: "discord" }); |
| await telegram.register("k", { plugin: "telegram" }); |
| await discord.clear(); |
|
|
| await expect(discord.lookup("k")).resolves.toBeUndefined(); |
| await expect(telegram.lookup("k")).resolves.toEqual({ plugin: "telegram" }); |
| }); |
| }); |
|
|
| it("validates namespaces, keys, options, and JSON values before writes", async () => { |
| await withPluginStateTestState(async () => { |
| expect(() => |
| createPluginStateKeyedStore("discord", { namespace: "../bad", maxEntries: 10 }), |
| ).toThrow(PluginStateStoreError); |
| expect(() => |
| createPluginStateKeyedStore("discord", { namespace: "bad-max", maxEntries: 0 }), |
| ).toThrow(PluginStateStoreError); |
|
|
| const store = createPluginStateKeyedStore("discord", { namespace: "valid", maxEntries: 10 }); |
| await expect(store.register(" ", { ok: true })).rejects.toThrow(PluginStateStoreError); |
| await expect(store.register("undefined", undefined)).rejects.toThrow(PluginStateStoreError); |
| await expect(store.register("infinity", Number.POSITIVE_INFINITY)).rejects.toThrow( |
| PluginStateStoreError, |
| ); |
| const circular: Record<string, unknown> = {}; |
| circular.self = circular; |
| await expect(store.register("circular", circular)).rejects.toThrow(PluginStateStoreError); |
| const sparse = [] as unknown[]; |
| sparse[1] = "hole"; |
| await expect(store.register("sparse", sparse)).rejects.toThrow(PluginStateStoreError); |
| await expect(store.register("date", new Date())).rejects.toThrow(PluginStateStoreError); |
| await expect(store.register("map", new Map([["k", "v"]]))).rejects.toThrow( |
| PluginStateStoreError, |
| ); |
| const nonEnumerable = { visible: true }; |
| Object.defineProperty(nonEnumerable, "hidden", { value: true, enumerable: false }); |
| await expect(store.register("non-enumerable", nonEnumerable)).rejects.toThrow( |
| PluginStateStoreError, |
| ); |
| |
| const boundary = "é".repeat(524_287); |
| await expect(store.register("large", boundary)).resolves.toBeUndefined(); |
| await expectPluginStateStoreError(store.register("big", `${boundary}x`), { |
| code: "PLUGIN_STATE_LIMIT_EXCEEDED", |
| }); |
|
|
| |
| await expect(store.register("k".repeat(513), { ok: true })).rejects.toThrow( |
| PluginStateStoreError, |
| ); |
|
|
| |
| expect(() => |
| createPluginStateKeyedStore("discord", { namespace: "a".repeat(129), maxEntries: 10 }), |
| ).toThrow(PluginStateStoreError); |
|
|
| |
| let deep: unknown = { leaf: true }; |
| for (let i = 0; i < 65; i += 1) { |
| deep = { nested: deep }; |
| } |
| await expectPluginStateStoreError(store.register("deep", deep), { |
| code: "PLUGIN_STATE_LIMIT_EXCEEDED", |
| }); |
|
|
| |
| await expectPluginStateStoreError(store.lookup(" "), { |
| code: "PLUGIN_STATE_INVALID_INPUT", |
| operation: "lookup", |
| }); |
| await expectPluginStateStoreError(store.delete(" "), { |
| code: "PLUGIN_STATE_INVALID_INPUT", |
| operation: "delete", |
| }); |
| }); |
| }); |
|
|
| it("rejects reopening the same namespace with incompatible options", async () => { |
| await withPluginStateTestState(async () => { |
| createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 10 }); |
| expect(() => |
| createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 11 }), |
| ).toThrow(PluginStateStoreError); |
| }); |
| }); |
|
|
| it("allows core owners and reserves core-prefixed plugin ids", async () => { |
| await withPluginStateTestState(async () => { |
| const options = { |
| ownerId: "core:channel-intent" as const, |
| namespace: "stopped", |
| maxEntries: 10, |
| }; |
| const store = createCorePluginStateSyncKeyedStore<{ stopped: boolean }>(options); |
| const asyncStore = createCorePluginStateKeyedStore<{ stopped: boolean }>(options); |
| expect(store.update("telegram:personal", () => ({ stopped: true }))).toBe(true); |
| closePluginStateDatabase(); |
| await expect(asyncStore.lookup("telegram:personal")).resolves.toEqual({ stopped: true }); |
| await expect( |
| asyncStore.update("telegram:personal", () => ({ stopped: false })), |
| ).resolves.toBe(true); |
| expect(store.lookup("telegram:personal")).toEqual({ stopped: false }); |
| await expect( |
| asyncStore.deleteIf("telegram:personal", (current) => !current.stopped), |
| ).resolves.toBe(true); |
| await expect(asyncStore.lookup(" ")).rejects.toThrow(PluginStateStoreError); |
| expect(() => createCorePluginStateKeyedStore({ ...options, maxEntries: 11 })).toThrow( |
| PluginStateStoreError, |
| ); |
| expect(() => |
| createPluginStateKeyedStore("core:not-a-plugin", { namespace: "bad", maxEntries: 10 }), |
| ).toThrow(PluginStateStoreError); |
| }); |
| }); |
|
|
| it("closes the cached DB handle and reopens cleanly", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore("discord", { namespace: "close", maxEntries: 10 }); |
| await store.register("k", { ok: true }); |
| const database = openOpenClawStateDatabase(); |
| closePluginStateDatabase(); |
| expect(() => database.db.exec("SELECT 1")).toThrow(); |
| await expect(store.lookup("k")).resolves.toEqual({ ok: true }); |
| }); |
| }); |
|
|
| it("keeps plugin-state reads outside the writable database lifecycle", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore("discord", { |
| namespace: "read-only", |
| maxEntries: 10, |
| }); |
| await store.register("k", { ok: true }); |
| resetPluginStateStoreForTests(); |
|
|
| expect(isOpenClawStateDatabaseOpen()).toBe(false); |
| await expect(store.lookup("k")).resolves.toEqual({ ok: true }); |
| await expect(store.entries()).resolves.toMatchObject([{ key: "k", value: { ok: true } }]); |
| expect( |
| await pluginStateEntriesInKeyRange({ |
| pluginId: "discord", |
| namespace: "read-only", |
| keyStartInclusive: "k", |
| keyEndExclusive: "l", |
| limit: 1, |
| }), |
| ).toMatchObject([{ key: "k", value: { ok: true } }]); |
| expect(countPluginStateLiveEntries("discord")).toBe(1); |
| await expect(store.count()).resolves.toBe(1); |
| expect(isOpenClawStateDatabaseOpen()).toBe(false); |
| }); |
| }); |
|
|
| it("treats a missing plugin-state database as empty without creating it", async () => { |
| await withOpenClawTestState( |
| { label: "plugin-state-read-only-missing", applyEnv: false }, |
| async (state) => { |
| const store = createPluginStateKeyedStore("discord", { |
| namespace: "read-only-missing", |
| maxEntries: 10, |
| env: state.env, |
| }); |
| const databasePath = resolveOpenClawStateSqlitePath(state.env); |
|
|
| expect(existsSync(databasePath)).toBe(false); |
| await expect(store.lookup("k")).resolves.toBeUndefined(); |
| await expect(store.lookupMany(["k", "missing"])).resolves.toEqual([ |
| { ok: true, value: undefined }, |
| { ok: true, value: undefined }, |
| ]); |
| await expect(store.lookupMany([])).resolves.toEqual([]); |
| await expect(store.entries()).resolves.toEqual([]); |
| await expect(store.count()).resolves.toBe(0); |
| expect(countPluginStateLiveEntries("discord", state.env)).toBe(0); |
| expect(existsSync(databasePath)).toBe(false); |
| }, |
| ); |
| }); |
|
|
| it.runIf(process.platform !== "win32")( |
| "reports inaccessible explicit state directories instead of treating them as empty", |
| async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore("discord", { |
| namespace: "inaccessible", |
| maxEntries: 10, |
| }); |
| await store.register("k", { ok: true }); |
| const databasePath = resolveOpenClawStateSqlitePath(testState?.env); |
| closePluginStateDatabase(); |
| chmodSync(testState?.stateDir ?? "", 0o000); |
| try { |
| await expect(store.lookup("k")).rejects.toMatchObject({ |
| code: "PLUGIN_STATE_OPEN_FAILED", |
| path: databasePath, |
| }); |
| } finally { |
| chmodSync(testState?.stateDir ?? "", 0o700); |
| } |
| }); |
| }, |
| ); |
|
|
| it.runIf(process.platform !== "win32")( |
| "reuses a process-held state database when its directory becomes inaccessible", |
| async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore("discord", { |
| namespace: "inaccessible-open-handle", |
| maxEntries: 10, |
| }); |
| await store.register("k", { ok: true }); |
| const database = openOpenClawStateDatabase(); |
| chmodSync(testState?.stateDir ?? "", 0o000); |
| try { |
| await expect(store.lookup("k")).resolves.toEqual({ ok: true }); |
| expect(database.db.isOpen).toBe(true); |
| } finally { |
| chmodSync(testState?.stateDir ?? "", 0o700); |
| } |
| }); |
| }, |
| ); |
|
|
| it("does not close a shared state database opened before the plugin-state probe", async () => { |
| await withPluginStateTestState(async () => { |
| const database = openOpenClawStateDatabase(); |
| const result = probePluginStateStore(); |
|
|
| expect(result.ok).toBe(true); |
| expect(database.db.isOpen).toBe(true); |
| }); |
| }); |
|
|
| it("keeps retained stores writable after the shared database owner closes its handle", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore("discord", { |
| namespace: "cache-switch", |
| maxEntries: 10, |
| }); |
| await store.register("k", { ok: true }); |
|
|
| const syncStore = createPluginStateSyncKeyedStore("discord", { |
| namespace: "cache-switch", |
| maxEntries: 10, |
| }); |
| const databasePath = resolveOpenClawStateSqlitePath(); |
| const firstHandle = openOpenClawStateDatabase(); |
| expect(closeOpenClawStateDatabaseByPath(databasePath)).toBe(true); |
| expect(firstHandle.db.isOpen).toBe(false); |
| await store.register("k", { version: 2 }); |
| expect(syncStore.lookup("k")).toEqual({ version: 2 }); |
|
|
| const secondHandle = openOpenClawStateDatabase(); |
| expect(closeOpenClawStateDatabaseByPath(databasePath)).toBe(true); |
| expect(secondHandle.db.isOpen).toBe(false); |
| syncStore.register("k", { version: 3 }); |
| await expect(store.lookup("k")).resolves.toEqual({ version: 3 }); |
| }); |
| }); |
|
|
| it.runIf(process.platform !== "win32")("hardens DB directory and file permissions", async () => { |
| await withPluginStateTestState(async () => { |
| const store = createPluginStateKeyedStore("discord", { namespace: "perms", maxEntries: 10 }); |
| await store.register("k", { ok: true }); |
|
|
| const databasePath = resolveOpenClawStateSqlitePath(); |
| expect(statSync(path.dirname(databasePath)).mode & 0o777).toBe(0o700); |
| expect(statSync(databasePath).mode & 0o777).toBe(0o600); |
| }); |
| }); |
|
|
| it("reports healthy diagnostics without stored values", async () => { |
| await withPluginStateTestState(async () => { |
| const result = probePluginStateStore(); |
| expect(result.ok).toBe(true); |
| const failedSteps = result.steps.filter((step) => !step.ok); |
| expect(failedSteps).toStrictEqual([]); |
| expect(JSON.stringify(result)).not.toContain("probe-value"); |
| }); |
| }); |
|
|
| it("reports an unhealthy probe when the clock cannot produce a valid ttl expiry", async () => { |
| await withPluginStateTestState(async () => { |
| const nowSpy = vi.spyOn(Date, "now"); |
| nowSpy.mockReturnValue(MAX_DATE_TIMESTAMP_MS); |
|
|
| try { |
| const result = probePluginStateStore(); |
|
|
| expect(result.ok).toBe(false); |
| expect(result.steps).toContainEqual( |
| expect.objectContaining({ |
| name: "probe", |
| ok: false, |
| code: "PLUGIN_STATE_INVALID_INPUT", |
| }), |
| ); |
| } finally { |
| nowSpy.mockRestore(); |
| } |
| }); |
| }); |
| }); |
|
|