fix: migrate legacy plugin state on runtime open

This commit is contained in:
Peter Steinberger
2026-05-17 19:45:52 +01:00
parent d027469129
commit cc3eb60aba
2 changed files with 164 additions and 1 deletions

View File

@@ -1,3 +1,5 @@
import { existsSync, rmSync } from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import type { Insertable, Selectable } from "kysely";
import {
@@ -6,6 +8,8 @@ import {
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
import { resolveStateDir } from "../config/paths.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabase,
@@ -31,6 +35,7 @@ type PluginStateEntriesTable = OpenClawStateKyselyDatabase["plugin_state_entries
type PluginStateStoreDatabase = Pick<OpenClawStateKyselyDatabase, "plugin_state_entries">;
type PluginStateRow = Selectable<PluginStateEntriesTable>;
type LegacyPluginStateRow = Insertable<PluginStateEntriesTable>;
type CountRow = {
count: number | bigint;
@@ -51,6 +56,8 @@ type PluginStateSeedEntryForTests = {
};
let cachedDatabase: PluginStateDatabase | null = null;
const importedLegacyPluginStatePaths = new Set<string>();
const LEGACY_PLUGIN_STATE_SIDECAR_SUFFIXES = ["", "-shm", "-wal"] as const;
function normalizeNumber(value: number | bigint | null): number | undefined {
if (typeof value === "bigint") {
@@ -307,6 +314,7 @@ function openPluginStateDatabase(
const env = options.env ?? process.env;
const pathname = resolveOpenClawStateSqlitePath(env);
if (cachedDatabase && cachedDatabase.path === pathname && cachedDatabase.db.isOpen) {
importLegacyPluginStateIfPresent({ targetDb: cachedDatabase.db, env });
return cachedDatabase;
}
if (cachedDatabase && !cachedDatabase.db.isOpen) {
@@ -315,6 +323,7 @@ function openPluginStateDatabase(
try {
const database = openOpenClawStateDatabase(options);
importLegacyPluginStateIfPresent({ targetDb: database.db, env });
cachedDatabase = {
db: database.db,
path: database.path,
@@ -336,6 +345,69 @@ function countRow(row: CountRow | undefined): number {
return typeof raw === "bigint" ? Number(raw) : raw;
}
function resolveLegacyPluginStateSqlitePath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "plugin-state", "state.sqlite");
}
function removeLegacyPluginStateSqliteFiles(sourcePath: string): void {
for (const suffix of LEGACY_PLUGIN_STATE_SIDECAR_SUFFIXES) {
rmSync(`${sourcePath}${suffix}`, { force: true });
}
}
function readLegacyPluginStateRows(sourcePath: string): LegacyPluginStateRow[] {
let sourceDb: DatabaseSync | undefined;
try {
const sqlite = requireNodeSqlite();
sourceDb = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
return sourceDb
.prepare(
`
SELECT plugin_id, namespace, entry_key, value_json, created_at, expires_at
FROM plugin_state_entries
ORDER BY plugin_id ASC, namespace ASC, entry_key ASC
`,
)
.all() as LegacyPluginStateRow[];
} finally {
sourceDb?.close();
}
}
function importLegacyPluginStateIfPresent(params: {
targetDb: DatabaseSync;
env: NodeJS.ProcessEnv;
}): void {
const sourcePath = resolveLegacyPluginStateSqlitePath(params.env);
if (importedLegacyPluginStatePaths.has(sourcePath) || !existsSync(sourcePath)) {
return;
}
try {
const rows = readLegacyPluginStateRows(sourcePath);
if (rows.length > 0) {
runSqliteImmediateTransactionSync(params.targetDb, () => {
const db = getPluginStateKysely(params.targetDb);
for (const row of rows) {
executeSqliteQuerySync(
params.targetDb,
db
.insertInto("plugin_state_entries")
.values(row)
.onConflict((conflict) =>
conflict.columns(["plugin_id", "namespace", "entry_key"]).doNothing(),
),
);
}
});
}
removeLegacyPluginStateSqliteFiles(sourcePath);
importedLegacyPluginStatePaths.add(sourcePath);
} catch {
// Runtime import is best-effort; doctor --fix reports detailed legacy migration failures.
}
}
function envOptions(env?: NodeJS.ProcessEnv): OpenClawStateDatabaseOptions {
return env ? { env } : {};
}
@@ -768,5 +840,6 @@ export function probePluginStateStore(): PluginStateStoreProbeResult {
export function closePluginStateDatabase(): void {
cachedDatabase = null;
importedLegacyPluginStatePaths.clear();
closeOpenClawStateDatabase();
}

View File

@@ -1,6 +1,7 @@
import { rmSync, statSync } from "node:fs";
import { existsSync, mkdirSync, rmSync, statSync } from "node:fs";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import {
@@ -46,6 +47,54 @@ async function withPluginStateTestState<T>(fn: () => Promise<T>): Promise<T> {
return await fn();
}
function createLegacyPluginStateSqlite(
stateDir: string,
rows: Array<{
pluginId: string;
namespace: string;
key: string;
valueJson: string;
createdAt: number;
expiresAt?: number | null;
}>,
): string {
const sqlitePath = path.join(stateDir, "plugin-state", "state.sqlite");
mkdirSync(path.dirname(sqlitePath), { recursive: true });
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(sqlitePath);
try {
db.exec(`
CREATE TABLE plugin_state_entries (
plugin_id TEXT NOT NULL,
namespace TEXT NOT NULL,
entry_key TEXT NOT NULL,
value_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
);
`);
const insert = db.prepare(`
INSERT INTO plugin_state_entries (
plugin_id, namespace, entry_key, value_json, created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?)
`);
for (const row of rows) {
insert.run(
row.pluginId,
row.namespace,
row.key,
row.valueJson,
row.createdAt,
row.expiresAt ?? null,
);
}
} finally {
db.close();
}
return sqlitePath;
}
async function expectPluginStateStoreError(
promise: Promise<unknown>,
expected: { code: string; operation?: string },
@@ -80,6 +129,47 @@ describe("plugin state keyed store", () => {
});
});
it("imports legacy sidecar SQLite rows before reading shared plugin state", async () => {
await withOpenClawTestState(
{ label: "plugin-state-legacy-import", applyEnv: false },
async (state) => {
const legacyPath = createLegacyPluginStateSqlite(state.stateDir, [
{
pluginId: "discord",
namespace: "components",
key: "interaction:1",
valueJson: '{"ok":true}',
createdAt: 1000,
},
{
pluginId: "github-copilot",
namespace: "token-cache",
key: "default",
valueJson: '{"token":"redacted"}',
createdAt: 2000,
expiresAt: 4_102_444_800_000,
},
]);
const store = createPluginStateKeyedStore<{ ok: boolean }>("discord", {
namespace: "components",
maxEntries: 10,
env: state.env,
});
await expect(store.lookup("interaction:1")).resolves.toEqual({ ok: true });
expect(existsSync(legacyPath)).toBe(false);
const tokenStore = createPluginStateKeyedStore<{ token: string }>("github-copilot", {
namespace: "token-cache",
maxEntries: 10,
env: state.env,
});
await expect(tokenStore.lookup("default")).resolves.toEqual({ token: "redacted" });
},
);
});
it("honors explicit store env without mutating process state", async () => {
await withOpenClawTestState(
{ label: "plugin-state-explicit-env-a", applyEnv: false },