Skip to content

Commit 24d94a5

Browse files
authored
fix: migrate QQBot credential backups to SQLite KV (#89597)
* fix: migrate qqbot credential backups to sqlite kv * fix: complete qqbot sqlite migration cleanup * fix: harden qqbot doctor state migration
1 parent 5db7c37 commit 24d94a5

16 files changed

Lines changed: 586 additions & 536 deletions

docs/refactor/database-first.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -524,8 +524,8 @@ The branch already has a real shared SQLite base:
524524
shape into SQLite before normal runtime use.
525525
- QQBot credential recovery snapshots now live in SQLite plugin state under
526526
`qqbot/credential-backups`. Runtime no longer writes
527-
`qqbot/data/credential-backup*.json`; doctor imports and removes those
528-
legacy backup files with the other QQBot state inputs.
527+
`qqbot/data/credential-backup*.json`; the QQBot doctor contract imports and
528+
archives those legacy backup files from the active state directory.
529529
- Gateway reload planning compares SQLite installed-plugin index snapshots under
530530
an internal `installedPluginIndex.installRecords.*` diff namespace. Runtime
531531
reload decisions no longer wrap those rows in fake `plugins.installs` config
@@ -1576,10 +1576,9 @@ Move these into the global database:
15761576
`voice-call` / `calls` namespace instead of `calls.jsonl`; the plugin CLI
15771577
tails and summarizes SQLite-backed call history.
15781578
- QQBot gateway sessions, known-user records, and ref-index quote cache now use
1579-
SQLite plugin state under `qqbot` namespaces (`sessions`, `known-users`,
1580-
`ref-index`) instead of `session-*.json`, `known-users.json`, and
1581-
`ref-index.jsonl`; the QQBot doctor/setup migration imports and removes the
1582-
legacy files.
1579+
SQLite plugin state under `qqbot` namespaces (`gateway-sessions`,
1580+
`known-users`, `ref-index`) instead of `session-*.json`, `known-users.json`,
1581+
and `ref-index.jsonl`. Those legacy files are caches and are not migrated.
15831582
- Discord model-picker preferences, command-deploy hashes, and thread bindings
15841583
now use SQLite plugin state under `discord` namespaces
15851584
(`model-picker-preferences`, `command-deploy-hashes`, `thread-bindings`)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
2+
export { stateMigrations } from "./src/state-migrations.js";

extensions/qqbot/src/engine/config/credential-backup.test.ts

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,24 @@ function useStateDir(stateDir: string): void {
4343
installQQBotRuntimeForStateTests(stateDir);
4444
}
4545

46-
function legacyOsHomeBackupPath(homeDir: string, accountId = "default"): string {
47-
return path.join(homeDir, ".openclaw", "qqbot", "data", `credential-backup-${accountId}.json`);
48-
}
49-
5046
function writeJson(filePath: string, value: unknown): void {
5147
fs.mkdirSync(path.dirname(filePath), { recursive: true });
5248
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
5349
}
5450

51+
function legacyCredentialBackupFile(accountId: string): string {
52+
return path.join(
53+
process.env.OPENCLAW_STATE_DIR!,
54+
"qqbot",
55+
"data",
56+
`credential-backup-${accountId}.json`,
57+
);
58+
}
59+
60+
function legacySingleCredentialBackupFile(): string {
61+
return path.join(process.env.OPENCLAW_STATE_DIR!, "qqbot", "data", "credential-backup.json");
62+
}
63+
5564
function readCredentialRows(stateDir: string): CredentialBackup[] {
5665
const store = createPluginStateSyncKeyedStoreForTests<CredentialBackup>("qqbot", {
5766
namespace: "credential-backups",
@@ -83,7 +92,6 @@ describe("engine/config/credential-backup", () => {
8392
});
8493

8594
it("round-trips a credential snapshot through SQLite without writing JSON", async () => {
86-
const { getCredentialBackupFile } = await import("../utils/data-paths.js");
8795
const { loadCredentialBackup, saveCredentialBackup } = await import("./credential-backup.js");
8896
const stateDir = process.env.OPENCLAW_STATE_DIR!;
8997

@@ -95,7 +103,7 @@ describe("engine/config/credential-backup", () => {
95103
appId: "app-1",
96104
clientSecret: "secret-1",
97105
});
98-
expect(fs.existsSync(getCredentialBackupFile("default"))).toBe(false);
106+
expect(fs.existsSync(legacyCredentialBackupFile("default"))).toBe(false);
99107
expect(readCredentialRows(stateDir)).toHaveLength(1);
100108
});
101109

@@ -116,52 +124,32 @@ describe("engine/config/credential-backup", () => {
116124
expect(loadCredentialBackup("default")?.appId).toBe("app-b");
117125
});
118126

119-
it("imports the state-dir legacy per-account JSON backup once", async () => {
120-
const { getCredentialBackupFile } = await import("../utils/data-paths.js");
127+
it("does not import state-dir legacy JSON backups during runtime reads", async () => {
121128
const { loadCredentialBackup } = await import("./credential-backup.js");
122-
writeJson(getCredentialBackupFile("default"), {
129+
const legacyFile = legacyCredentialBackupFile("default");
130+
writeJson(legacyFile, {
123131
accountId: "default",
124132
appId: "app-old",
125133
clientSecret: "secret-old",
126134
savedAt: new Date().toISOString(),
127135
});
128136

129-
const loaded = loadCredentialBackup("default");
130-
131-
expect(loaded?.appId).toBe("app-old");
132-
expect(fs.existsSync(getCredentialBackupFile("default"))).toBe(false);
133-
expect(loadCredentialBackup("default")?.clientSecret).toBe("secret-old");
134-
});
135-
136-
it("imports the old OS-home JSON backup once", async () => {
137-
const { loadCredentialBackup } = await import("./credential-backup.js");
138-
const legacyPath = legacyOsHomeBackupPath(process.env.HOME!);
139-
writeJson(legacyPath, {
140-
accountId: "default",
141-
appId: "app-home",
142-
clientSecret: "secret-home",
143-
savedAt: new Date().toISOString(),
144-
});
145-
146-
const loaded = loadCredentialBackup("default");
147-
148-
expect(loaded?.appId).toBe("app-home");
149-
expect(fs.existsSync(legacyPath)).toBe(false);
150-
expect(loadCredentialBackup("default")?.clientSecret).toBe("secret-home");
137+
expect(loadCredentialBackup("default")).toBeNull();
138+
expect(fs.existsSync(legacyFile)).toBe(true);
151139
});
152140

153-
it("returns null when the legacy single-file backup belongs to a different accountId", async () => {
154-
const { getLegacyCredentialBackupFile } = await import("../utils/data-paths.js");
141+
it("does not import legacy single-file backups during runtime reads", async () => {
155142
const { loadCredentialBackup } = await import("./credential-backup.js");
156-
writeJson(getLegacyCredentialBackupFile(), {
143+
const legacyFile = legacySingleCredentialBackupFile();
144+
writeJson(legacyFile, {
157145
accountId: "other-acct",
158146
appId: "app-old",
159147
clientSecret: "secret-old",
160148
savedAt: new Date().toISOString(),
161149
});
162150

163151
expect(loadCredentialBackup("default")).toBeNull();
164-
expect(fs.existsSync(getLegacyCredentialBackupFile())).toBe(true);
152+
expect(fs.existsSync(legacyFile)).toBe(true);
165153
});
166154

167155
it("ignores empty appId/clientSecret on save", async () => {

extensions/qqbot/src/engine/config/credential-backup.ts

Lines changed: 7 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
* - During plugin startup, if the live config has an empty appId or
1212
* secret, the gateway consults the backup and restores the values
1313
* via the config mutation API.
14-
* - Legacy JSON backups are imported on first read, then removed after
15-
* SQLite has the canonical copy.
14+
* - Legacy JSON backups are imported by `openclaw doctor --fix`, not by
15+
* runtime startup.
1616
*
1717
* Safety notes:
1818
* - Only restore when credentials are **actually empty** — never
@@ -21,11 +21,6 @@
2121
* precisely when appId is unknown.
2222
*/
2323

24-
import fs from "node:fs";
25-
import path from "node:path";
26-
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
27-
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js";
28-
import { getQQBotDataPath } from "../utils/platform.js";
2924
import { buildQQBotStateKey, openQQBotSyncKeyedStore } from "../utils/sqlite-state.js";
3025

3126
interface CredentialBackup {
@@ -35,8 +30,8 @@ interface CredentialBackup {
3530
savedAt: string;
3631
}
3732

38-
const CREDENTIAL_BACKUPS_NAMESPACE = "credential-backups";
39-
const MAX_CREDENTIAL_BACKUPS = 1000;
33+
export const CREDENTIAL_BACKUPS_NAMESPACE = "credential-backups";
34+
export const MAX_CREDENTIAL_BACKUPS = 1000;
4035

4136
function createCredentialBackupStore() {
4237
return openQQBotSyncKeyedStore<CredentialBackup>({
@@ -45,77 +40,27 @@ function createCredentialBackupStore() {
4540
});
4641
}
4742

48-
function safeName(id: string): string {
49-
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
50-
}
51-
52-
function getLegacyOsHomeCredentialBackupFile(accountId: string): string {
53-
return path.join(getQQBotDataPath("data"), `credential-backup-${safeName(accountId)}.json`);
54-
}
55-
56-
function getLegacyOsHomeCredentialBackupFileWithoutAccount(): string {
57-
return path.join(getQQBotDataPath("data"), "credential-backup.json");
58-
}
59-
60-
function credentialBackupKey(accountId: string): string {
43+
export function credentialBackupKey(accountId: string): string {
6144
return buildQQBotStateKey("credential-backup", accountId);
6245
}
6346

6447
function isUsableBackup(data: CredentialBackup | null | undefined): data is CredentialBackup {
6548
return Boolean(data?.accountId && data.appId && data.clientSecret);
6649
}
6750

68-
function loadUsableBackupFromFile(filePath: string): CredentialBackup | null {
69-
const data = loadJsonFile<CredentialBackup>(filePath);
70-
return isUsableBackup(data) ? data : null;
71-
}
72-
73-
function removeFileQuietly(filePath: string): void {
74-
try {
75-
fs.unlinkSync(filePath);
76-
} catch {
77-
/* ignore cleanup errors */
78-
}
79-
}
80-
81-
function findLegacyBackup(accountId?: string): { data: CredentialBackup; filePath: string } | null {
82-
const candidates = accountId
83-
? [
84-
getCredentialBackupFile(accountId),
85-
getLegacyCredentialBackupFile(),
86-
getLegacyOsHomeCredentialBackupFile(accountId),
87-
getLegacyOsHomeCredentialBackupFileWithoutAccount(),
88-
]
89-
: [getLegacyCredentialBackupFile(), getLegacyOsHomeCredentialBackupFileWithoutAccount()];
90-
91-
for (const filePath of candidates) {
92-
const data = loadUsableBackupFromFile(filePath);
93-
if (!data) {
94-
continue;
95-
}
96-
if (accountId && data.accountId !== accountId) {
97-
continue;
98-
}
99-
return { data, filePath };
100-
}
101-
return null;
102-
}
103-
10451
/** Persist a credential snapshot (called once gateway reaches READY). */
10552
export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void {
10653
if (!appId || !clientSecret) {
10754
return;
10855
}
10956
try {
110-
const backupPath = getCredentialBackupFile(accountId);
11157
const data: CredentialBackup = {
11258
accountId,
11359
appId,
11460
clientSecret,
11561
savedAt: new Date().toISOString(),
11662
};
11763
createCredentialBackupStore().register(credentialBackupKey(accountId), data);
118-
removeFileQuietly(backupPath);
11964
} catch {
12065
/* best-effort — ignore */
12166
}
@@ -124,8 +69,8 @@ export function saveCredentialBackup(accountId: string, appId: string, clientSec
12469
/**
12570
* Load a credential snapshot for `accountId`.
12671
*
127-
* Consults SQLite first; falls back to shipped JSON backups and imports
128-
* them when the embedded `accountId` matches the request.
72+
* Reads SQLite only. Legacy JSON backup import is owned by doctor/setup
73+
* migration so runtime startup stays canonical-state-only.
12974
*/
13075
export function loadCredentialBackup(accountId?: string): CredentialBackup | null {
13176
try {
@@ -136,16 +81,6 @@ export function loadCredentialBackup(accountId?: string): CredentialBackup | nul
13681
return data;
13782
}
13883
}
139-
140-
const legacy = findLegacyBackup(accountId);
141-
if (legacy) {
142-
createCredentialBackupStore().register(
143-
credentialBackupKey(legacy.data.accountId),
144-
legacy.data,
145-
);
146-
removeFileQuietly(legacy.filePath);
147-
return legacy.data;
148-
}
14984
} catch {
15085
/* corrupt file — ignore */
15186
}

extensions/qqbot/src/engine/ref/store.test.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -108,25 +108,4 @@ describe("engine/ref/store", () => {
108108
expect(() => setRefIndex("ref-unavailable", entry("ignored"))).not.toThrow();
109109
expect(getRefIndex("ref-unavailable")).toBeNull();
110110
});
111-
112-
it("imports legacy ref-index JSONL and drops expired rows", async () => {
113-
const { getRefIndex } = await import("./store.js");
114-
const legacyPath = refIndexFile(process.env.HOME!);
115-
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
116-
fs.writeFileSync(
117-
legacyPath,
118-
[
119-
JSON.stringify({ k: "valid", v: entry("valid-content"), t: Date.now() }),
120-
JSON.stringify({
121-
k: "expired",
122-
v: entry("expired-content"),
123-
t: Date.now() - 8 * 24 * 60 * 60 * 1000,
124-
}),
125-
].join("\n"),
126-
);
127-
128-
expect(getRefIndex("valid")?.content).toBe("valid-content");
129-
expect(getRefIndex("expired")).toBeNull();
130-
expect(fs.existsSync(legacyPath)).toBe(false);
131-
});
132111
});

0 commit comments

Comments
 (0)