Files
OmniRoute/src/lib/db/backup.ts
Diego Rodrigues de Sa e Souza 4a53a53277 fix(db): prune pre-migration backups so db_backups stops growing unbounded (#10423)
* fix(db): prune pre-migration backups so db_backups stops growing unbounded

createPreMigrationBackup() wrote a VACUUM INTO snapshot on every migration run
and never pruned. On a long-lived instance db_backups/ reached 48.999 files /
204 GB against a 5,3 MB live database; a second devbox showed the same shape
(5.711 files / 24 GB).

The retention policy already existed in cleanupDbBackups() but nothing on the
migration path reached it — its only callers are backup.ts and the
/api/db-backups route, neither of which runs during a migration.

migrationRunner.ts cannot import backup.ts: core.ts imports migrationRunner.ts
and backup.ts imports core.ts, so that edge would close a cycle. The policy
therefore moves to a new core-free module, backupRetention.ts, which both call
sites share — cleanupDbBackups() now delegates to it rather than duplicating it.

At the migration call site the operator's maxFiles/retentionDays are read
through the adapter already open for the run; going through getDbInstance()
would re-enter database initialization. Pruning never throws, so housekeeping
cannot fail a migration.

Closes #10421

* chore(db): declare backupRetention as an intentionally-internal db module

check:db-rules requires every src/lib/db/ module to be either re-exported by
localDb.ts or listed in INTENTIONALLY_INTERNAL. backupRetention.ts is a shared
primitive consumed only by db/backup.ts and db/migrationRunner.ts — the same
category as the migrationRunner entry — so it belongs in the allowlist rather
than in the public re-export surface.

* test(db): include backupRetention in the audited INTENTIONALLY_INTERNAL list

check-db-rules-classification.test.ts freezes the exact membership of
INTENTIONALLY_INTERNAL, so adding the 40th entry has to be reflected there too
— the gate script and this test pin the same contract from opposite sides.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 21:27:16 -03:00

609 lines
21 KiB
TypeScript

/**
* db/backup.js — Database backup/restore operations.
*/
import path from "path";
import fs from "fs";
import {
getDbInstance,
resetDbInstance,
isBuildPhase,
isCloud,
SQLITE_FILE,
DB_BACKUPS_DIR,
DATA_DIR,
} from "./core";
import { resetAllDbModuleState } from "./stateReset";
import {
MAX_DB_BACKUPS,
DEFAULT_DB_BACKUP_RETENTION_DAYS,
parsePositiveInt,
parseNonNegativeInt,
pruneBackupDirectory,
} from "./backupRetention";
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
type CountRow = { cnt?: number };
// ──────────────── Backup Config ────────────────
let _lastBackupAt = 0;
const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
// #3834: the "Keep latest backups" UI value is persisted here so it survives a page
// refresh / the loadStorageHealth() refetch. A dedicated namespace avoids any
// cross-talk with the databaseSettings key_value store (which rewrites all of its own
// keys on every update). It is intentionally separate from the orphan
// `databaseSettings.backup.keepLastNBackups` (default 5) so existing installs keep the
// historical default of 20 until an operator explicitly changes it here.
const DB_BACKUP_SETTINGS_NAMESPACE = "dbBackup";
const DB_BACKUP_MAX_FILES_KEY = "maxFiles";
const DB_BACKUP_RETENTION_DAYS_KEY = "retentionDays";
function getStoredDbBackupInteger(key: string, options: { min: number }): number | undefined {
try {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(DB_BACKUP_SETTINGS_NAMESPACE, key) as { value?: string } | undefined;
if (!row?.value) return undefined;
const parsed = JSON.parse(row.value);
return Number.isInteger(parsed) && parsed >= options.min ? parsed : undefined;
} catch {
return undefined;
}
}
function setStoredDbBackupInteger(key: string, value: number, options: { min: number }): void {
if (!Number.isInteger(value) || value < options.min) return;
const db = getDbInstance();
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
DB_BACKUP_SETTINGS_NAMESPACE,
key,
JSON.stringify(value)
);
}
/** Persist the operator-chosen "keep latest backups" retention count (#3834). */
export function setDbBackupMaxFiles(value: number): void {
setStoredDbBackupInteger(DB_BACKUP_MAX_FILES_KEY, value, { min: 1 });
}
export function getDbBackupMaxFiles() {
// Precedence: DB_BACKUP_MAX_FILES env override (ops) → persisted UI value → default.
if (process.env.DB_BACKUP_MAX_FILES) {
return parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS);
}
return getStoredDbBackupInteger(DB_BACKUP_MAX_FILES_KEY, { min: 1 }) ?? MAX_DB_BACKUPS;
}
/** Persist the operator-chosen age-based backup retention window. */
export function setDbBackupRetentionDays(value: number): void {
setStoredDbBackupInteger(DB_BACKUP_RETENTION_DAYS_KEY, value, { min: 0 });
}
export function getDbBackupRetentionDays() {
// Precedence: DB_BACKUP_RETENTION_DAYS env override (ops) → persisted UI value → default.
if (process.env.DB_BACKUP_RETENTION_DAYS) {
return parseNonNegativeInt(
process.env.DB_BACKUP_RETENTION_DAYS,
DEFAULT_DB_BACKUP_RETENTION_DAYS
);
}
return (
getStoredDbBackupInteger(DB_BACKUP_RETENTION_DAYS_KEY, { min: 0 }) ??
DEFAULT_DB_BACKUP_RETENTION_DAYS
);
}
function getBackupDir() {
return DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
}
export function cleanupDbBackups(options?: {
maxFiles?: number;
retentionDays?: number;
backupDir?: string;
}) {
const backupDir = options?.backupDir ?? getBackupDir();
const maxFiles = options?.maxFiles ?? getDbBackupMaxFiles();
const retentionDays = options?.retentionDays ?? getDbBackupRetentionDays();
return pruneBackupDirectory({ backupDir, maxFiles, retentionDays });
}
function coerceBoolean(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (v === "true" || v === "1") return true;
if (v === "false" || v === "0") return false;
}
return null;
}
function parseStoredJson(value: string | undefined): unknown {
if (value === undefined) return undefined;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/**
* #5871: resolve the persisted `backup.autoBackupEnabled` dashboard setting.
*
* This mirrors the precedence used by `databaseSettings.getUserDatabaseSettings()`
* (default `false` → `settings.databaseSettings.backup` → `settings.backup` →
* `databaseSettings` namespace, last wins) but reads `key_value` rows directly so
* `backup.ts` does not create a static import cycle with `databaseSettings.ts`
* (which imports `backupDbFile` at module load). Returns `true` when auto backups
* are explicitly disabled by the operator, so callers can skip non-manual backups.
*/
export function isAutoBackupDisabledBySetting(): boolean {
try {
const db = getDbInstance();
const rows = db
.prepare("SELECT namespace, key, value FROM key_value WHERE namespace IN (?, ?)")
.all("settings", "databaseSettings") as Array<{
namespace: string;
key: string;
value: string;
}>;
// Candidate values by source, resolved regardless of DB row iteration order.
// Precedence (lowest → highest, last wins), mirroring getUserDatabaseSettings():
// settings.databaseSettings.backup → settings.backup → databaseSettings namespace.
let fromSettingsNested: boolean | null = null; // settings.databaseSettings.backup.autoBackupEnabled
let fromSettingsBackup: boolean | null = null; // settings.backup.autoBackupEnabled
let fromDbFlat: boolean | null = null; // databaseSettings namespace flat alias "autoBackupEnabled"
let fromDbNested: boolean | null = null; // databaseSettings namespace nested "backup.autoBackupEnabled"
for (const row of rows) {
const parsed = parseStoredJson(row.value);
if (row.namespace === "settings") {
if (row.key === "databaseSettings" && isPlainObject(parsed)) {
const backup = (parsed as Record<string, unknown>).backup;
if (isPlainObject(backup)) {
const b = coerceBoolean((backup as Record<string, unknown>).autoBackupEnabled);
if (b !== null) fromSettingsNested = b;
}
} else if (row.key === "backup" && isPlainObject(parsed)) {
const b = coerceBoolean((parsed as Record<string, unknown>).autoBackupEnabled);
if (b !== null) fromSettingsBackup = b;
}
} else if (row.namespace === "databaseSettings") {
if (row.key === "autoBackupEnabled") {
const b = coerceBoolean(parsed);
if (b !== null) fromDbFlat = b;
} else if (row.key === "backup.autoBackupEnabled") {
const b = coerceBoolean(parsed);
if (b !== null) fromDbNested = b;
}
}
}
// Apply precedence: last non-null wins (mirrors getUserDatabaseSettings — flat alias
// first, then nested key). Default (no persisted value) → not disabled.
let enabled: boolean | null = null;
for (const candidate of [fromSettingsNested, fromSettingsBackup, fromDbFlat, fromDbNested]) {
if (candidate !== null) enabled = candidate;
}
return enabled === false;
} catch {
// If the setting cannot be read (e.g. DB not ready), do not block backups.
return false;
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSqliteAutoBackupDisabled() {
if (isAutomatedTestProcess()) return true;
const value = process.env.DISABLE_SQLITE_AUTO_BACKUP;
if (!value) return false;
return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function unlinkFileWithRetry(
filePath: string,
options?: { maxAttempts?: number; retryableCodes?: string[]; baseDelayMs?: number }
) {
const maxAttempts = Math.max(1, options?.maxAttempts ?? 10);
const retryableCodes = new Set(options?.retryableCodes ?? ["EBUSY", "EPERM"]);
const baseDelayMs = Math.max(0, options?.baseDelayMs ?? 100);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
return;
} catch (err: unknown) {
const code =
err && typeof err === "object" && "code" in err ? (err as NodeJS.ErrnoException).code : "";
if (code === "ENOENT") return;
if (retryableCodes.has(String(code)) && attempt < maxAttempts - 1) {
await sleep(baseDelayMs * (attempt + 1));
} else {
throw err;
}
}
}
}
// ──────────────── Backup ────────────────
export function backupDbFile(reason = "auto") {
try {
if (isBuildPhase || isCloud) return null;
if (!SQLITE_FILE || !fs.existsSync(SQLITE_FILE)) return null;
if (reason !== "manual" && isSqliteAutoBackupDisabled()) return null;
// #5871: honor the persisted `backup.autoBackupEnabled` dashboard toggle. Only
// manual and pre-restore backups bypass this gate; automatic + pre-write safety
// snapshots must stop firing once the operator disables auto-backup in the UI.
if (reason !== "manual" && reason !== "pre-restore" && isAutoBackupDisabledBySetting())
return null;
const stat = fs.statSync(SQLITE_FILE);
if (stat.size < 4096) {
console.warn(`[DB] Backup SKIPPED — DB too small (${stat.size}B)`);
return null;
}
// Throttle
const now = Date.now();
if (reason !== "manual" && reason !== "pre-restore" && now - _lastBackupAt < BACKUP_THROTTLE_MS)
return null;
_lastBackupAt = now;
const backupDir = getBackupDir();
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
if (reason !== "manual" && reason !== "pre-restore") {
// Shrink detection is useful for automatic safety backups, but it should
// never block an explicit operator action like manual backup or pre-restore.
const existingBackups = fs
.readdirSync(backupDir)
.filter((f) => f.startsWith("db_") && f.endsWith(".sqlite"))
.sort();
if (existingBackups.length > 0) {
const latestBackup = existingBackups[existingBackups.length - 1];
const latestStat = fs.statSync(path.join(backupDir, latestBackup));
if (latestStat.size > 4096 && stat.size < latestStat.size * 0.5) {
console.warn(`[DB] Backup SKIPPED — DB shrank from ${latestStat.size}B to ${stat.size}B`);
return null;
}
}
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFile = path.join(backupDir, `db_${timestamp}_${reason}.sqlite`);
// Use native SQLite backup API for consistency
const db = getDbInstance();
db.backup(backupFile)
.then(() => {
console.log(`[DB] Backup created: ${backupFile} (${stat.size} bytes)`);
cleanupDbBackups();
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error("[DB] Backup failed:", message);
});
return { filename: path.basename(backupFile), size: stat.size };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("[DB] Backup failed:", message);
return null;
}
}
// ──────────────── List Backups ────────────────
export async function listDbBackups() {
const backupDir = getBackupDir();
try {
if (!fs.existsSync(backupDir)) return [];
const entries = fs
.readdirSync(backupDir)
.filter((f) => f.startsWith("db_") && f.endsWith(".sqlite"))
.sort()
.reverse();
const { tryOpenSync } = await import("@/lib/db/adapters/driverFactory");
return entries.map((filename) => {
const filePath = path.join(backupDir, filename);
const stat = fs.statSync(filePath);
const match = filename.match(/^db_(.+?)_([^.]+)\.sqlite$/);
const reason = match ? match[2] : "unknown";
let connectionCount = 0;
try {
const backupDb = tryOpenSync(filePath, { readonly: true });
if (backupDb) {
try {
const row = backupDb
.prepare("SELECT COUNT(*) as cnt FROM provider_connections")
.get() as CountRow | undefined;
connectionCount = Number(row?.cnt ?? 0);
} finally {
backupDb.close();
}
}
} catch {
/* ignore */
}
return {
id: filename,
filename,
createdAt: stat.mtime.toISOString(),
size: stat.size,
reason,
connectionCount,
};
});
} catch {
return [];
}
}
// ──────────────── Restore Backup ────────────────
export async function restoreDbBackup(backupId: string) {
const backupDir = getBackupDir();
// Validate format: must be db_<timestamp>_<reason>.sqlite, no path separators
if (
!backupId.startsWith("db_") ||
!backupId.endsWith(".sqlite") ||
backupId.includes(path.sep) ||
backupId.includes("/")
) {
throw new Error("Invalid backup ID");
}
const backupPath = path.resolve(backupDir, backupId);
// Prevent path traversal: resolved path must stay within backupDir
if (
!backupPath.startsWith(path.resolve(backupDir) + path.sep) &&
backupPath !== path.resolve(backupDir)
) {
throw new Error("Invalid backup ID: path traversal detected");
}
if (!fs.existsSync(backupPath)) {
throw new Error(`Backup not found: ${backupId}`);
}
// Validate backup integrity
try {
const { tryOpenSync } = await import("@/lib/db/adapters/driverFactory");
const testDb = tryOpenSync(backupPath, { readonly: true });
if (!testDb) {
throw new Error("Backup file is corrupt: could not open");
}
let result: Array<{ integrity_check?: string }>;
try {
result = testDb.pragma("integrity_check") as Array<{ integrity_check?: string }>;
} finally {
testDb.close();
}
if (result[0]?.integrity_check !== "ok") {
throw new Error("Backup integrity check failed");
}
} catch (e: unknown) {
if (e instanceof Error && e.message === "Backup integrity check failed") throw e;
const message = e instanceof Error ? e.message : String(e);
throw new Error(`Backup file is corrupt: ${message}`);
}
// Force pre-restore backup (bypass throttle) and await so the DB is not closed while backup runs
if (!isSqliteAutoBackupDisabled()) {
_lastBackupAt = 0;
const backupDirForPre = getBackupDir();
if (SQLITE_FILE && fs.existsSync(SQLITE_FILE)) {
const stat = fs.statSync(SQLITE_FILE);
if (stat.size >= 4096) {
if (!fs.existsSync(backupDirForPre)) fs.mkdirSync(backupDirForPre, { recursive: true });
const preBackupPath = path.join(
backupDirForPre,
`db_${new Date().toISOString().replace(/[:.]/g, "-")}_pre-restore.sqlite`
);
const dbForBackup = getDbInstance();
await dbForBackup.backup(preBackupPath);
_lastBackupAt = Date.now();
}
}
}
// Close and reset current connection
resetDbInstance();
// Clear all cached prepared statements and other state bound to the old connection
resetAllDbModuleState();
const sqliteFile = SQLITE_FILE;
if (!sqliteFile) {
throw new Error("SQLITE_FILE is unavailable in local backup restore");
}
// On Windows, the file handle may be released asynchronously after close; give it a moment.
await sleep(500);
// Remove main file and WAL sidecars to avoid stale frame replay after restore.
// Retry unlink on EBUSY/EPERM (Windows may hold the handle briefly).
const sqliteFilesToReplace = [
sqliteFile,
`${sqliteFile}-wal`,
`${sqliteFile}-shm`,
`${sqliteFile}-journal`,
];
for (const filePath of sqliteFilesToReplace) {
if (!filePath) continue;
await unlinkFileWithRetry(filePath);
}
// Copy backup over current DB
fs.copyFileSync(backupPath, sqliteFile);
// Reopen
const db = getDbInstance();
const connCount =
(db.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get() as CountRow | undefined)
?.cnt || 0;
const nodeCount =
(db.prepare("SELECT COUNT(*) as cnt FROM provider_nodes").get() as CountRow | undefined)?.cnt ||
0;
const comboCount =
(db.prepare("SELECT COUNT(*) as cnt FROM combos").get() as CountRow | undefined)?.cnt || 0;
const keyCount =
(db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get() as CountRow | undefined)?.cnt || 0;
console.log(`[DB] Restored backup: ${backupId} (${connCount} connections)`);
return {
restored: true,
backupId,
connectionCount: connCount,
nodeCount,
comboCount,
apiKeyCount: keyCount,
};
}
// ──────────────── Export-All helpers (for /api/db-backups/exportAll) ────────────────
export interface ExportAllRows {
settings: Record<string, string>;
combos: unknown[];
providers: unknown[];
apiKeys: unknown[];
reasoningRoutingRules: unknown[];
}
/**
* Reads summary rows used by the exportAll backup route.
*
* - settings: full key_value table (key → value map)
* - combos: full combos table
* - providers: provider_connections rows, **excluding sensitive credentials**
* (id, provider, name, auth_type, is_active, email, created_at only)
* - apiKeys: api_keys rows with masked prefix
* (id, name, first 8 chars of key, machine_id, created_at)
*
* Each category is wrapped in a try/catch so a missing table never aborts the
* entire export — consistent with the original inline behaviour.
*/
export function exportAllSummaryRows(): ExportAllRows {
const db = getDbInstance();
const settings: Record<string, string> = {};
try {
const rows = db.prepare("SELECT key, value FROM key_value").all() as {
key: string;
value: string;
}[];
for (const row of rows) {
settings[row.key] = row.value;
}
} catch {
// key_value table might not exist
}
const combos: unknown[] = [];
try {
combos.push(...db.prepare("SELECT * FROM combos").all());
} catch {
// combos table might not exist
}
const providers: unknown[] = [];
try {
providers.push(
...db
.prepare(
"SELECT id, provider, name, auth_type, is_active, email, created_at FROM provider_connections"
)
.all()
);
} catch {
// provider_connections table might not exist
}
const apiKeys: unknown[] = [];
try {
apiKeys.push(
...db
.prepare(
"SELECT id, name, substr(key, 1, 8) as prefix, machine_id, created_at FROM api_keys"
)
.all()
);
} catch {
// api_keys table might not exist
}
const reasoningRoutingRules: unknown[] = [];
try {
reasoningRoutingRules.push(...db.prepare("SELECT * FROM reasoning_routing_rules").all());
} catch {
// reasoning_routing_rules table might not exist in an older backup
}
return { settings, combos, providers, apiKeys, reasoningRoutingRules };
}
// ──────────────── Import validation helpers (for /api/db-backups/import) ────────────────
/**
* Queries an **already-opened** SQLite adapter for the list of table names.
*
* Used by the import route to validate that a candidate database contains the
* required OmniRoute tables before replacing the live database.
*
* Accepting an adapter as a parameter (rather than calling getDbInstance()) is
* intentional: the import route opens a *temporary* database for validation,
* not the live one.
*/
export function getTableNamesFromAdapter(adapter: {
prepare: (sql: string) => { all: () => unknown[] };
}): string[] {
const rows = adapter.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{
name: string;
}>;
return rows.map((r) => r.name);
}
/**
* Counts rows in a set of tables from the **live** database (post-import).
* Returns an object keyed by table name with the row count as value.
*/
export function countImportedRows(): {
connCount: number;
nodeCount: number;
comboCount: number;
keyCount: number;
} {
const db = getDbInstance();
const connCount =
(db.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get() as any)?.cnt || 0;
const nodeCount =
(db.prepare("SELECT COUNT(*) as cnt FROM provider_nodes").get() as any)?.cnt || 0;
const comboCount = (db.prepare("SELECT COUNT(*) as cnt FROM combos").get() as any)?.cnt || 0;
const keyCount = (db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get() as any)?.cnt || 0;
return { connCount, nodeCount, comboCount, keyCount };
}