2026-07-10 02:08:58 -04:00
|
|
|
// DB safety backups — taken ONLY before a schema change (see migrate.js).
|
|
|
|
|
//
|
|
|
|
|
// ⚠️ AGENT/DEV NOTES:
|
|
|
|
|
// - Backups are a best-effort safety net before schema migrations. There is NO
|
|
|
|
|
// automated restore path; recovery is manual (copy a backup file back).
|
|
|
|
|
// - Backups intentionally EXCLUDE the `requestDetails` table (observability log,
|
|
|
|
|
// auto-pruned, non-critical) so a multi-hundred-MB DB backs up as a few MB.
|
|
|
|
|
// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically.
|
2026-05-09 06:48:20 -04:00
|
|
|
import fs from "node:fs";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import { BACKUPS_DIR, ensureDirs } from "./paths.js";
|
|
|
|
|
import { timestampSlug, getAppVersion } from "./version.js";
|
|
|
|
|
|
2026-07-10 02:08:58 -04:00
|
|
|
const KEEP_BACKUPS = 3;
|
|
|
|
|
|
|
|
|
|
// Tables excluded from safety backups (large, non-critical, reproducible).
|
|
|
|
|
const BACKUP_EXCLUDE_TABLES = ["requestDetails"];
|
2026-05-09 06:48:20 -04:00
|
|
|
|
|
|
|
|
export function makeBackupDir(label) {
|
|
|
|
|
ensureDirs();
|
|
|
|
|
const ver = getAppVersion();
|
|
|
|
|
const slug = `${label}-${ver}-${timestampSlug()}`;
|
|
|
|
|
const dir = path.join(BACKUPS_DIR, slug);
|
|
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function backupFile(srcPath, destDir, destName = null) {
|
|
|
|
|
if (!fs.existsSync(srcPath)) return null;
|
|
|
|
|
const name = destName || path.basename(srcPath);
|
|
|
|
|
const dest = path.join(destDir, name);
|
|
|
|
|
fs.copyFileSync(srcPath, dest);
|
|
|
|
|
return dest;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 02:08:58 -04:00
|
|
|
// Lightweight DB backup via ATTACH: create an empty sqlite file, copy every
|
|
|
|
|
// table EXCEPT the excluded ones into it. Avoids duplicating the huge
|
|
|
|
|
// observability log, so the backup stays small regardless of DB size.
|
|
|
|
|
export function backupDbLite(adapter, destDir, destName = "data.sqlite") {
|
|
|
|
|
const dest = path.join(destDir, destName);
|
|
|
|
|
try { fs.rmSync(dest, { force: true }); } catch {}
|
|
|
|
|
const escaped = dest.replace(/'/g, "''");
|
|
|
|
|
|
|
|
|
|
adapter.exec(`ATTACH DATABASE '${escaped}' AS bak`);
|
|
|
|
|
try {
|
|
|
|
|
const excluded = new Set(BACKUP_EXCLUDE_TABLES);
|
|
|
|
|
const tables = adapter
|
|
|
|
|
.all(`SELECT name, sql FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
|
|
|
|
.filter((t) => !excluded.has(t.name));
|
|
|
|
|
|
|
|
|
|
adapter.transaction(() => {
|
|
|
|
|
for (const t of tables) {
|
|
|
|
|
// Recreate table structure in backup DB, then copy rows.
|
|
|
|
|
const createSql = t.sql.replace(/CREATE TABLE\s+/i, "CREATE TABLE bak.");
|
|
|
|
|
adapter.exec(createSql);
|
|
|
|
|
adapter.exec(`INSERT INTO bak.${t.name} SELECT * FROM main.${t.name}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
try { adapter.exec("DETACH DATABASE bak"); } catch {}
|
|
|
|
|
}
|
|
|
|
|
return dest;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 06:48:20 -04:00
|
|
|
export function pruneOldBackups() {
|
|
|
|
|
if (!fs.existsSync(BACKUPS_DIR)) return;
|
|
|
|
|
const entries = fs.readdirSync(BACKUPS_DIR, { withFileTypes: true })
|
|
|
|
|
.filter((e) => e.isDirectory())
|
|
|
|
|
.map((e) => ({ name: e.name, full: path.join(BACKUPS_DIR, e.name), mtime: fs.statSync(path.join(BACKUPS_DIR, e.name)).mtimeMs }))
|
|
|
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
|
|
|
|
|
|
|
|
for (const old of entries.slice(KEEP_BACKUPS)) {
|
|
|
|
|
try { fs.rmSync(old.full, { recursive: true, force: true }); } catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|