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:
Mohit Sharma authored and GitHub committed 2026-11-03 09:05:20 -08:00
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
@@ -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, [
+4 -4
View File
@@ -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",
);
}