mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 06:00:45 +00:00
feat(plugins): add SQLite plugin state store (#74190)
* feat(plugins): add experimental sqlite plugin state store
This commit is contained in:
379
src/plugin-state/plugin-state-store.test.ts
Normal file
379
src/plugin-state/plugin-state-store.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { mkdirSync, statSync } from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { requireNodeSqlite } from "../infra/node-sqlite.js";
|
||||
import type { PluginRecord } from "../plugins/registry-types.js";
|
||||
import { createPluginRegistry } from "../plugins/registry.js";
|
||||
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
closePluginStateSqliteStore,
|
||||
createCorePluginStateKeyedStore,
|
||||
createPluginStateKeyedStore,
|
||||
PluginStateStoreError,
|
||||
probePluginStateStore,
|
||||
resetPluginStateStoreForTests,
|
||||
sweepExpiredPluginStateEntries,
|
||||
} from "./plugin-state-store.js";
|
||||
import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
|
||||
|
||||
function createPluginRecord(id: string, origin: PluginRecord["origin"] = "bundled"): PluginRecord {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
source: `/plugins/${id}/index.ts`,
|
||||
origin,
|
||||
enabled: true,
|
||||
status: "loaded",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
realtimeTranscriptionProviderIds: [],
|
||||
realtimeVoiceProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
videoGenerationProviderIds: [],
|
||||
musicGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
migrationProviderIds: [],
|
||||
memoryEmbeddingProviderIds: [],
|
||||
agentHarnessIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
gatewayDiscoveryServiceIds: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: false,
|
||||
} as PluginRecord;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
describe("plugin state keyed store", () => {
|
||||
it("registers and looks up values across store instances", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-roundtrip" }, async () => {
|
||||
const store = createPluginStateKeyedStore<{ count: number }>("discord", {
|
||||
namespace: "components",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("interaction:1", { count: 1 });
|
||||
|
||||
const reopened = createPluginStateKeyedStore<{ count: number }>("discord", {
|
||||
namespace: "components",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await expect(reopened.lookup("interaction:1")).resolves.toEqual({ count: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
it("upserts values and refreshes deterministic entry ordering", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-upsert" }, async () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createPluginStateKeyedStore<{ version: number }>("discord", {
|
||||
namespace: "components",
|
||||
maxEntries: 10,
|
||||
});
|
||||
vi.setSystemTime(1000);
|
||||
await store.register("b", { version: 1 });
|
||||
vi.setSystemTime(2000);
|
||||
await store.register("a", { version: 1 });
|
||||
vi.setSystemTime(3000);
|
||||
await store.register("b", { version: 2 });
|
||||
|
||||
await expect(store.lookup("b")).resolves.toEqual({ version: 2 });
|
||||
await expect(store.entries()).resolves.toMatchObject([
|
||||
{ key: "a", value: { version: 1 }, createdAt: 2000 },
|
||||
{ key: "b", value: { version: 2 }, createdAt: 3000 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for missing lookups and consumes by deleting atomically", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-consume" }, 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 withOpenClawTestState({ label: "plugin-state-clear" }, 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.toEqual([]);
|
||||
await expect(second.lookup("k2")).resolves.toEqual({ value: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes expired entries and sweeps them", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-expiry" }, async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const store = createPluginStateKeyedStore("discord", {
|
||||
namespace: "ttl",
|
||||
maxEntries: 10,
|
||||
defaultTtlMs: 100,
|
||||
});
|
||||
await store.register("default", { value: "default" });
|
||||
await store.register("override", { value: "override" }, { ttlMs: 500 });
|
||||
|
||||
vi.setSystemTime(1200);
|
||||
await expect(store.lookup("default")).resolves.toBeUndefined();
|
||||
await expect(store.lookup("override")).resolves.toEqual({ value: "override" });
|
||||
expect(sweepExpiredPluginStateEntries()).toBe(1);
|
||||
await expect(store.entries()).resolves.toMatchObject([{ key: "override" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts oldest live entries over maxEntries", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-eviction" }, async () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "evict", maxEntries: 2 });
|
||||
vi.setSystemTime(1000);
|
||||
await store.register("a", 1);
|
||||
vi.setSystemTime(2000);
|
||||
await store.register("b", 2);
|
||||
vi.setSystemTime(3000);
|
||||
await store.register("c", 3);
|
||||
|
||||
await expect(store.entries()).resolves.toMatchObject([{ key: "b" }, { key: "c" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the per-plugin live row ceiling would be exceeded without evicting siblings", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-plugin-limit" }, async () => {
|
||||
const stores = Array.from({ length: 10 }, (_, index) =>
|
||||
createPluginStateKeyedStore("discord", {
|
||||
namespace: `ns-${index}`,
|
||||
maxEntries: 101,
|
||||
}),
|
||||
);
|
||||
for (let namespaceIndex = 0; namespaceIndex < stores.length; namespaceIndex += 1) {
|
||||
for (let entryIndex = 0; entryIndex < 100; entryIndex += 1) {
|
||||
await stores[namespaceIndex].register(`k-${entryIndex}`, { namespaceIndex, entryIndex });
|
||||
}
|
||||
}
|
||||
|
||||
await expect(stores[0].register("overflow", { overflow: true })).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
|
||||
});
|
||||
await expect(stores[1].lookup("k-0")).resolves.toEqual({ namespaceIndex: 1, entryIndex: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it("segregates plugins sharing a namespace and key", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-segregation" }, 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 withOpenClawTestState({ label: "plugin-state-validation" }, 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,
|
||||
);
|
||||
await expect(store.register("big", "x".repeat(65_537))).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
|
||||
});
|
||||
|
||||
// Key byte-length limit (512 bytes)
|
||||
await expect(store.register("k".repeat(513), { ok: true })).rejects.toThrow(
|
||||
PluginStateStoreError,
|
||||
);
|
||||
|
||||
// Namespace byte-length limit (128 bytes)
|
||||
expect(() =>
|
||||
createPluginStateKeyedStore("discord", { namespace: "a".repeat(129), maxEntries: 10 }),
|
||||
).toThrow(PluginStateStoreError);
|
||||
|
||||
// JSON depth limit (64 levels)
|
||||
let deep: unknown = { leaf: true };
|
||||
for (let i = 0; i < 65; i += 1) {
|
||||
deep = { nested: deep };
|
||||
}
|
||||
await expect(store.register("deep", deep)).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
|
||||
});
|
||||
|
||||
// Validation errors surface the correct operation
|
||||
await expect(store.lookup(" ")).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_INVALID_INPUT",
|
||||
operation: "lookup",
|
||||
});
|
||||
await expect(store.delete(" ")).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_INVALID_INPUT",
|
||||
operation: "delete",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects reopening the same namespace with incompatible options", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-option-consistency" }, 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 withOpenClawTestState({ label: "plugin-state-core" }, async () => {
|
||||
const store = createCorePluginStateKeyedStore<{ stopped: boolean }>({
|
||||
ownerId: "core:channel-intent",
|
||||
namespace: "stopped",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("telegram:personal", { stopped: true });
|
||||
await expect(store.lookup("telegram:personal")).resolves.toEqual({ stopped: true });
|
||||
expect(() =>
|
||||
createPluginStateKeyedStore("core:not-a-plugin", { namespace: "bad", maxEntries: 10 }),
|
||||
).toThrow(PluginStateStoreError);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the cached DB handle and reopens cleanly", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-close" }, async () => {
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "close", maxEntries: 10 });
|
||||
await store.register("k", { ok: true });
|
||||
closePluginStateSqliteStore();
|
||||
await expect(store.lookup("k")).resolves.toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("hardens DB directory and file permissions", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-permissions" }, async () => {
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "perms", maxEntries: 10 });
|
||||
await store.register("k", { ok: true });
|
||||
|
||||
expect(statSync(resolvePluginStateDir()).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(resolvePluginStateSqlitePath()).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports healthy diagnostics without stored values", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-probe" }, async () => {
|
||||
const result = probePluginStateStore();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.steps.every((step) => step.ok)).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain("probe-value");
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on unsupported future schema versions", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-schema" }, async () => {
|
||||
mkdirSync(resolvePluginStateDir(), { recursive: true });
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const db = new DatabaseSync(resolvePluginStateSqlitePath());
|
||||
db.exec("PRAGMA user_version = 2;");
|
||||
db.close();
|
||||
|
||||
const store = createPluginStateKeyedStore("discord", { namespace: "schema", maxEntries: 10 });
|
||||
await expect(store.register("k", { ok: true })).rejects.toMatchObject({
|
||||
code: "PLUGIN_STATE_SCHEMA_UNSUPPORTED",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin runtime state proxy", () => {
|
||||
it("binds openKeyedStore to the bundled plugin id and keeps resolveStateDir", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-state-runtime" }, async (state) => {
|
||||
const registry = createPluginRegistry({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
runtime: createPluginRuntime(),
|
||||
});
|
||||
const record = createPluginRecord("discord", "bundled");
|
||||
registry.registry.plugins.push(record);
|
||||
const api = registry.createApi(record, { config: {} });
|
||||
|
||||
expect(api.runtime.state.resolveStateDir()).toBe(state.stateDir);
|
||||
const store = api.runtime.state.openKeyedStore<{ plugin: string }>({
|
||||
namespace: "runtime",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("k", { plugin: "discord" });
|
||||
|
||||
const telegram = createPluginRecord("telegram", "bundled");
|
||||
registry.registry.plugins.push(telegram);
|
||||
const telegramApi = registry.createApi(telegram, { config: {} });
|
||||
const telegramStore = telegramApi.runtime.state.openKeyedStore<{ plugin: string }>({
|
||||
namespace: "runtime",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await expect(telegramStore.lookup("k")).resolves.toBeUndefined();
|
||||
await expect(store.lookup("k")).resolves.toEqual({ plugin: "discord" });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects external plugins in this release", () => {
|
||||
const registry = createPluginRegistry({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
runtime: createPluginRuntime(),
|
||||
});
|
||||
const record = createPluginRecord("external-plugin", "workspace");
|
||||
registry.registry.plugins.push(record);
|
||||
const api = registry.createApi(record, { config: {} });
|
||||
|
||||
expect(() =>
|
||||
api.runtime.state.openKeyedStore({ namespace: "runtime", maxEntries: 10 }),
|
||||
).toThrow("openKeyedStore is only available for bundled plugins");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user