mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(openrig-upgrade): use node:sqlite for database backup when sqlite3 CLI is absent (#2394)
Step 4 of the openrig-upgrade skill now works on a host without the sqlite3 CLI when the running Node provides node:sqlite. backup-sqlite.mjs still uses the sqlite3 CLI's .backup first (OPENRIG_SQLITE_BIN, or sqlite3 on PATH); when the CLI isn't there, it uses Node's built-in node:sqlite online backup() for a page-for-page copy, and VACUUM INTO only when backup() isn't available, then checks the copy with PRAGMA integrity_check. With neither the CLI nor node:sqlite, it stops with a message naming both remedies. It needs no extra dependency. A focused test runs the helper with an empty PATH, confirms sqlite3 can't be found, and checks the copied database. Reported in #2391 by @ziadeh. Contributed by @mohitdixit02.
This commit is contained in:
1 parent
79e5b7952d
commit
94b01e5c88
6 files changed
+255
-45
No files matched your search
@@ -98,9 +98,10 @@ node "$SKILL_DIR/scripts/backup-sqlite.mjs" \
|
||||
--destination "/safe/path/openrig-before-upgrade.sqlite"
|
||||
```
|
||||
|
||||
The helper refuses to overwrite a destination, uses SQLite's backup operation,
|
||||
and verifies the backup with `PRAGMA integrity_check`. It does not stop the
|
||||
daemon or restore a database.
|
||||
The helper refuses to overwrite a destination, uses the `sqlite3` CLI `.backup`
|
||||
command when available (falling back to an in-process backup via `node:sqlite`
|
||||
when the CLI is absent), and verifies the backup with `PRAGMA integrity_check`.
|
||||
It does not stop the daemon or restore a database.
|
||||
|
||||
### Plan or apply safe managed-plugin refreshes
|
||||
|
||||
|
||||
+90
-14
@@ -4,6 +4,28 @@ import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Suppress experimental warning for node:sqlite to keep stderr clean for JSON errors
|
||||
const originalEmitWarning = process.emitWarning;
|
||||
process.emitWarning = (warning, ...args) => {
|
||||
if (typeof warning === "string" && warning.includes("SQLite is an experimental feature")) {
|
||||
return;
|
||||
}
|
||||
if (warning && typeof warning === "object" && warning.name === "ExperimentalWarning" && warning.message?.includes("SQLite")) {
|
||||
return;
|
||||
}
|
||||
return originalEmitWarning.call(process, warning, ...args);
|
||||
};
|
||||
|
||||
let sqliteModule;
|
||||
let DatabaseSync;
|
||||
try {
|
||||
sqliteModule = await import("node:sqlite");
|
||||
DatabaseSync = sqliteModule.DatabaseSync;
|
||||
} catch {
|
||||
sqliteModule = undefined;
|
||||
DatabaseSync = undefined;
|
||||
}
|
||||
|
||||
function argument(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
@@ -33,22 +55,76 @@ if (destinationPath.includes("'") || destinationPath.includes("\n")) {
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
const sqlite = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
|
||||
const backup = spawnSync(sqlite, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
|
||||
if (backup.status !== 0) {
|
||||
fs.rmSync(destinationPath, { force: true });
|
||||
fail(
|
||||
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
|
||||
`run ${sqlite} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
|
||||
);
|
||||
}
|
||||
|
||||
const check = spawnSync(sqlite, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
|
||||
const integrity = check.stdout?.trim();
|
||||
if (check.status !== 0 || integrity !== "ok") {
|
||||
const sqliteBin = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
|
||||
const probe = spawnSync(sqliteBin, ["-version"], { encoding: "utf8" });
|
||||
const hasCli = probe.status === 0;
|
||||
|
||||
let integrity;
|
||||
|
||||
if (hasCli) {
|
||||
const backup = spawnSync(sqliteBin, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
|
||||
if (backup.status !== 0) {
|
||||
fs.rmSync(destinationPath, { force: true });
|
||||
fail(
|
||||
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
|
||||
`run ${sqliteBin} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
|
||||
);
|
||||
}
|
||||
|
||||
const check = spawnSync(sqliteBin, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
|
||||
integrity = check.stdout?.trim();
|
||||
if (check.status !== 0 || integrity !== "ok") {
|
||||
fail(
|
||||
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
);
|
||||
}
|
||||
} else if (DatabaseSync) {
|
||||
try {
|
||||
const srcDb = new DatabaseSync(sourcePath, { readOnly: true });
|
||||
try {
|
||||
if (typeof sqliteModule?.backup === "function") {
|
||||
await sqliteModule.backup(srcDb, destinationPath);
|
||||
} else {
|
||||
srcDb.exec(`VACUUM INTO '${destinationPath}';`);
|
||||
}
|
||||
} finally {
|
||||
srcDb.close();
|
||||
}
|
||||
} catch (err) {
|
||||
fs.rmSync(destinationPath, { force: true });
|
||||
fail(
|
||||
err?.message || "sqlite backup failed via node:sqlite",
|
||||
"resolve locking/path/tooling errors, then choose a fresh destination",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const backupDb = new DatabaseSync(destinationPath, { readOnly: true });
|
||||
try {
|
||||
const rows = backupDb.prepare("PRAGMA integrity_check;").all();
|
||||
integrity = rows[0]?.integrity_check;
|
||||
} finally {
|
||||
backupDb.close();
|
||||
}
|
||||
} catch (err) {
|
||||
fail(
|
||||
err?.message || `backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
);
|
||||
}
|
||||
|
||||
if (integrity !== "ok") {
|
||||
fail(
|
||||
`backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fail(
|
||||
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
"neither sqlite3 CLI nor node:sqlite is available to create a backup",
|
||||
"install sqlite3 CLI or run on Node with node:sqlite support",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -379,13 +379,20 @@ describe("refresh-managed-plugin helper", () => {
|
||||
|
||||
describe("backup-sqlite helper", () => {
|
||||
it("creates a verified backup without changing the source database", () => {
|
||||
const sqlite = spawnSync("sqlite3", ["-version"], { encoding: "utf8" });
|
||||
if (sqlite.status !== 0) return;
|
||||
const hasSqlite = spawnSync("sqlite3", ["-version"], { encoding: "utf8" }).status === 0;
|
||||
const DatabaseSync = (process as any).getBuiltinModule?.("node:sqlite")?.DatabaseSync;
|
||||
if (!hasSqlite && !DatabaseSync) return;
|
||||
|
||||
const root = temporaryRoot();
|
||||
const source = path.join(root, "openrig.sqlite");
|
||||
const destination = path.join(root, "openrig.backup.sqlite");
|
||||
execFileSync("sqlite3", [source, "CREATE TABLE proof(value TEXT); INSERT INTO proof VALUES ('kept');"]);
|
||||
if (hasSqlite) {
|
||||
execFileSync("sqlite3", [source, "CREATE TABLE proof(value TEXT); INSERT INTO proof VALUES ('kept');"]);
|
||||
} else {
|
||||
const db = new DatabaseSync(source);
|
||||
db.exec("CREATE TABLE proof(value TEXT); INSERT INTO proof VALUES ('kept');");
|
||||
db.close();
|
||||
}
|
||||
const before = fs.readFileSync(source);
|
||||
|
||||
const receipt = runJson("backup-sqlite.mjs", [
|
||||
@@ -395,17 +402,66 @@ describe("backup-sqlite helper", () => {
|
||||
expect(receipt.integrity).toBe("ok");
|
||||
expect(receipt.destination).toBe(destination);
|
||||
expect(fs.readFileSync(source)).toEqual(before);
|
||||
expect(execFileSync("sqlite3", [destination, "SELECT value FROM proof;"], { encoding: "utf8" }).trim()).toBe("kept");
|
||||
if (hasSqlite) {
|
||||
expect(execFileSync("sqlite3", [destination, "SELECT value FROM proof;"], { encoding: "utf8" }).trim()).toBe("kept");
|
||||
} else {
|
||||
const verifyDb = new DatabaseSync(destination, { readOnly: true });
|
||||
const row = (verifyDb.prepare("SELECT value FROM proof;").get() as { value: string })?.value;
|
||||
verifyDb.close();
|
||||
expect(row).toBe("kept");
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a verified backup in-process when sqlite3 CLI is absent from PATH", () => {
|
||||
const DatabaseSync = (process as any).getBuiltinModule?.("node:sqlite")?.DatabaseSync;
|
||||
if (!DatabaseSync) return;
|
||||
|
||||
const root = temporaryRoot();
|
||||
const source = path.join(root, "openrig.sqlite");
|
||||
const destination = path.join(root, "openrig.inprocess.backup.sqlite");
|
||||
|
||||
const db = new DatabaseSync(source);
|
||||
db.exec("CREATE TABLE proof(value TEXT); INSERT INTO proof VALUES ('kept');");
|
||||
db.close();
|
||||
const before = fs.readFileSync(source);
|
||||
|
||||
const emptyPath = temporaryRoot();
|
||||
const probe = spawnSync("sqlite3", ["-version"], {
|
||||
env: { ...process.env, PATH: emptyPath, Path: emptyPath, OPENRIG_SQLITE_BIN: "" },
|
||||
});
|
||||
expect(probe.status).not.toBe(0);
|
||||
|
||||
const receipt = runJson(
|
||||
"backup-sqlite.mjs",
|
||||
["--source", source, "--destination", destination],
|
||||
{ PATH: emptyPath, Path: emptyPath, OPENRIG_SQLITE_BIN: "" },
|
||||
);
|
||||
expect(receipt.ok).toBe(true);
|
||||
expect(receipt.integrity).toBe("ok");
|
||||
expect(receipt.destination).toBe(destination);
|
||||
expect(fs.readFileSync(source)).toEqual(before);
|
||||
|
||||
const verifyDb = new DatabaseSync(destination, { readOnly: true });
|
||||
const row = (verifyDb.prepare("SELECT value FROM proof;").get() as { value: string })?.value;
|
||||
verifyDb.close();
|
||||
expect(row).toBe("kept");
|
||||
});
|
||||
|
||||
it("refuses to overwrite an existing destination", () => {
|
||||
const sqlite = spawnSync("sqlite3", ["-version"], { encoding: "utf8" });
|
||||
if (sqlite.status !== 0) return;
|
||||
const hasSqlite = spawnSync("sqlite3", ["-version"], { encoding: "utf8" }).status === 0;
|
||||
const DatabaseSync = (process as any).getBuiltinModule?.("node:sqlite")?.DatabaseSync;
|
||||
if (!hasSqlite && !DatabaseSync) return;
|
||||
|
||||
const root = temporaryRoot();
|
||||
const source = path.join(root, "openrig.sqlite");
|
||||
const destination = path.join(root, "existing.sqlite");
|
||||
execFileSync("sqlite3", [source, "CREATE TABLE proof(value TEXT);"]);
|
||||
if (hasSqlite) {
|
||||
execFileSync("sqlite3", [source, "CREATE TABLE proof(value TEXT);"]);
|
||||
} else {
|
||||
const db = new DatabaseSync(source);
|
||||
db.exec("CREATE TABLE proof(value TEXT);");
|
||||
db.close();
|
||||
}
|
||||
fs.writeFileSync(destination, "keep this");
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
|
||||
"core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77",
|
||||
"core/openrig-software-factory/references/worked-example.md": "64dec0e832692b655ffc1ca9f72e96f221f78b8bc97f73775198f188b502f147",
|
||||
"core/openrig-upgrade/SKILL.md": "3cdbff3ed7ce31a6386586018269cb09221ef6c97c67475963bbd3eeb556d5a3",
|
||||
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4",
|
||||
"core/openrig-upgrade/SKILL.md": "bd1765d94e4d462b9c8b08198238c1239c70ff68228c3d575438a7bc9a326ac6",
|
||||
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "42b0a3292ac2240e87c925020425f62e47869f8c4a6b8f17297239762f2064d3",
|
||||
"core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a",
|
||||
"core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f",
|
||||
"core/openrig-upgrade/scripts/refresh-managed-plugin.mjs": "cd117e3e891d6ad632f88a0ad10f5fc8a68ea724ce2715c89f45b6000307cf81",
|
||||
@@ -117,8 +117,8 @@
|
||||
"core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da",
|
||||
"core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77",
|
||||
"core/openrig-software-factory/references/worked-example.md": "64dec0e832692b655ffc1ca9f72e96f221f78b8bc97f73775198f188b502f147",
|
||||
"core/openrig-upgrade/SKILL.md": "3cdbff3ed7ce31a6386586018269cb09221ef6c97c67475963bbd3eeb556d5a3",
|
||||
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4",
|
||||
"core/openrig-upgrade/SKILL.md": "bd1765d94e4d462b9c8b08198238c1239c70ff68228c3d575438a7bc9a326ac6",
|
||||
"core/openrig-upgrade/scripts/backup-sqlite.mjs": "42b0a3292ac2240e87c925020425f62e47869f8c4a6b8f17297239762f2064d3",
|
||||
"core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a",
|
||||
"core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f",
|
||||
"core/openrig-upgrade/scripts/refresh-managed-plugin.mjs": "cd117e3e891d6ad632f88a0ad10f5fc8a68ea724ce2715c89f45b6000307cf81",
|
||||
|
||||
@@ -98,9 +98,10 @@ node "$SKILL_DIR/scripts/backup-sqlite.mjs" \
|
||||
--destination "/safe/path/openrig-before-upgrade.sqlite"
|
||||
```
|
||||
|
||||
The helper refuses to overwrite a destination, uses SQLite's backup operation,
|
||||
and verifies the backup with `PRAGMA integrity_check`. It does not stop the
|
||||
daemon or restore a database.
|
||||
The helper refuses to overwrite a destination, uses the `sqlite3` CLI `.backup`
|
||||
command when available (falling back to an in-process backup via `node:sqlite`
|
||||
when the CLI is absent), and verifies the backup with `PRAGMA integrity_check`.
|
||||
It does not stop the daemon or restore a database.
|
||||
|
||||
### Plan or apply safe managed-plugin refreshes
|
||||
|
||||
|
||||
@@ -4,6 +4,28 @@ import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Suppress experimental warning for node:sqlite to keep stderr clean for JSON errors
|
||||
const originalEmitWarning = process.emitWarning;
|
||||
process.emitWarning = (warning, ...args) => {
|
||||
if (typeof warning === "string" && warning.includes("SQLite is an experimental feature")) {
|
||||
return;
|
||||
}
|
||||
if (warning && typeof warning === "object" && warning.name === "ExperimentalWarning" && warning.message?.includes("SQLite")) {
|
||||
return;
|
||||
}
|
||||
return originalEmitWarning.call(process, warning, ...args);
|
||||
};
|
||||
|
||||
let sqliteModule;
|
||||
let DatabaseSync;
|
||||
try {
|
||||
sqliteModule = await import("node:sqlite");
|
||||
DatabaseSync = sqliteModule.DatabaseSync;
|
||||
} catch {
|
||||
sqliteModule = undefined;
|
||||
DatabaseSync = undefined;
|
||||
}
|
||||
|
||||
function argument(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
@@ -33,22 +55,76 @@ if (destinationPath.includes("'") || destinationPath.includes("\n")) {
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
const sqlite = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
|
||||
const backup = spawnSync(sqlite, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
|
||||
if (backup.status !== 0) {
|
||||
fs.rmSync(destinationPath, { force: true });
|
||||
fail(
|
||||
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
|
||||
`run ${sqlite} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
|
||||
);
|
||||
}
|
||||
|
||||
const check = spawnSync(sqlite, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
|
||||
const integrity = check.stdout?.trim();
|
||||
if (check.status !== 0 || integrity !== "ok") {
|
||||
const sqliteBin = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
|
||||
const probe = spawnSync(sqliteBin, ["-version"], { encoding: "utf8" });
|
||||
const hasCli = probe.status === 0;
|
||||
|
||||
let integrity;
|
||||
|
||||
if (hasCli) {
|
||||
const backup = spawnSync(sqliteBin, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
|
||||
if (backup.status !== 0) {
|
||||
fs.rmSync(destinationPath, { force: true });
|
||||
fail(
|
||||
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
|
||||
`run ${sqliteBin} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
|
||||
);
|
||||
}
|
||||
|
||||
const check = spawnSync(sqliteBin, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
|
||||
integrity = check.stdout?.trim();
|
||||
if (check.status !== 0 || integrity !== "ok") {
|
||||
fail(
|
||||
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
);
|
||||
}
|
||||
} else if (DatabaseSync) {
|
||||
try {
|
||||
const srcDb = new DatabaseSync(sourcePath, { readOnly: true });
|
||||
try {
|
||||
if (typeof sqliteModule?.backup === "function") {
|
||||
await sqliteModule.backup(srcDb, destinationPath);
|
||||
} else {
|
||||
srcDb.exec(`VACUUM INTO '${destinationPath}';`);
|
||||
}
|
||||
} finally {
|
||||
srcDb.close();
|
||||
}
|
||||
} catch (err) {
|
||||
fs.rmSync(destinationPath, { force: true });
|
||||
fail(
|
||||
err?.message || "sqlite backup failed via node:sqlite",
|
||||
"resolve locking/path/tooling errors, then choose a fresh destination",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const backupDb = new DatabaseSync(destinationPath, { readOnly: true });
|
||||
try {
|
||||
const rows = backupDb.prepare("PRAGMA integrity_check;").all();
|
||||
integrity = rows[0]?.integrity_check;
|
||||
} finally {
|
||||
backupDb.close();
|
||||
}
|
||||
} catch (err) {
|
||||
fail(
|
||||
err?.message || `backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
);
|
||||
}
|
||||
|
||||
if (integrity !== "ok") {
|
||||
fail(
|
||||
`backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fail(
|
||||
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
"neither sqlite3 CLI nor node:sqlite is available to create a backup",
|
||||
"install sqlite3 CLI or run on Node with node:sqlite support",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user