fiemwl / src /lib /db /backup.ts
automindy's picture
Upload 1980 files
6111b2b verified
Raw
History Blame Contribute Delete
14.3 kB
/**
* 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";
type CountRow = { cnt?: number };
// ──────────────── Backup Config ────────────────
let _lastBackupAt = 0;
const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes
const MAX_DB_BACKUPS = 20;
const DEFAULT_DB_BACKUP_RETENTION_DAYS = 0;
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
function parsePositiveInt(value: string | undefined, fallback: number) {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function parseNonNegativeInt(value: string | undefined, fallback: number) {
if (value === undefined) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
}
export function getDbBackupMaxFiles() {
return parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS);
}
export function getDbBackupRetentionDays() {
return parseNonNegativeInt(
process.env.DB_BACKUP_RETENTION_DAYS,
DEFAULT_DB_BACKUP_RETENTION_DAYS
);
}
function getBackupDir() {
return DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
}
function getBackupFamilyBase(filename: string) {
if (filename.endsWith("-wal") || filename.endsWith("-shm")) return filename.slice(0, -4);
if (filename.endsWith("-journal")) return filename.slice(0, -8);
return filename;
}
function collectBackupFamilies(backupDir: string) {
if (!fs.existsSync(backupDir)) return [];
const families = new Map<
string,
{
base: string;
hasPrimary: boolean;
primaryMtimeMs: number;
latestMtimeMs: number;
files: string[];
}
>();
for (const name of fs.readdirSync(backupDir)) {
if (!name.startsWith("db_")) continue;
const base = getBackupFamilyBase(name);
const filePath = path.join(backupDir, name);
let stat;
try {
stat = fs.statSync(filePath);
} catch {
continue;
}
const family = families.get(base) || {
base,
hasPrimary: false,
primaryMtimeMs: 0,
latestMtimeMs: 0,
files: [],
};
family.files.push(name);
family.latestMtimeMs = Math.max(family.latestMtimeMs, stat.mtimeMs);
if (name === base && name.endsWith(".sqlite")) {
family.hasPrimary = true;
family.primaryMtimeMs = stat.mtimeMs;
}
families.set(base, family);
}
return [...families.values()];
}
export function cleanupDbBackups(options?: { maxFiles?: number; retentionDays?: number }) {
const backupDir = getBackupDir();
if (!fs.existsSync(backupDir)) {
return {
deletedBackupFamilies: 0,
deletedFiles: 0,
keptBackupFamilies: 0,
maxFiles: options?.maxFiles ?? getDbBackupMaxFiles(),
retentionDays: options?.retentionDays ?? getDbBackupRetentionDays(),
};
}
const maxFiles = Math.max(1, options?.maxFiles ?? getDbBackupMaxFiles());
const retentionDays = Math.max(0, options?.retentionDays ?? getDbBackupRetentionDays());
const cutoffMs = retentionDays > 0 ? Date.now() - retentionDays * 24 * 60 * 60 * 1000 : 0;
const families = collectBackupFamilies(backupDir);
const primaryFamilies = families
.filter((family) => family.hasPrimary)
.sort((a, b) => b.primaryMtimeMs - a.primaryMtimeMs);
const keepPrimaryBases = new Set(primaryFamilies.slice(0, maxFiles).map((family) => family.base));
let deletedBackupFamilies = 0;
let deletedFiles = 0;
for (const family of families) {
const isOverflowPrimary = family.hasPrimary && !keepPrimaryBases.has(family.base);
const isExpired = retentionDays > 0 && family.latestMtimeMs < cutoffMs;
const isOrphan = !family.hasPrimary;
if (!isOverflowPrimary && !isExpired && !isOrphan) continue;
deletedBackupFamilies += 1;
for (const name of family.files) {
try {
fs.unlinkSync(path.join(backupDir, name));
deletedFiles += 1;
} catch {
/* ignore */
}
}
}
return {
deletedBackupFamilies,
deletedFiles,
keptBackupFamilies: collectBackupFamilies(backupDir).filter((family) => family.hasPrimary)
.length,
maxFiles,
retentionDays,
};
}
function isSqliteAutoBackupDisabled() {
const isTest =
typeof process !== "undefined" &&
(process.env.NODE_ENV === "test" ||
process.env.VITEST !== undefined ||
process.argv.some((a) => a.includes("test")));
if (isTest) 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;
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,
};
}