mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 17:55:58 +00:00
fix: migrate legacy plugin state on runtime open
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user