import { fileURLToPath } from "node:url"; import { Database } from "bun:sqlite"; import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; export type BackupConfig = { directory: string; replica?: string; retain: number; intervalMs: number; enabled: boolean }; const filenamePattern = /^minabot-\d{4}-\d{2}-\d{2}T[\d-]+Z-[a-f0-9-]+\.sqlite$/; export function backupConfig(database: string, env = process.env): BackupConfig { const retain = Number(env.BACKUP_RETAIN ?? 7); const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24); if (!Number.isInteger(retain) || retain < 2 || retain > 365 || !Number.isFinite(hours) || hours < 1 || hours > 168) throw new Error("BACKUP_RETAIN must be 2–365 and BACKUP_INTERVAL_HOURS must be 1–168"); const base = dirname(database); const directory = resolve(base, env.BACKUP_DIR ?? "backups"); const replica = env.BACKUP_REPLICA_DIR ? resolve(base, env.BACKUP_REPLICA_DIR) : undefined; if (replica === directory) throw new Error("The backup replica must use a separate directory"); return { directory, replica, retain, intervalMs: hours * 3600000, enabled: env.BACKUP_ENABLED !== "false" }; } function durable(path: string) { chmodSync(path, 0o600); const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); } } export function verifyBackup(path: string) { const db = new Database(path, { readonly: true, strict: true }); try { const integrity = db.query("PRAGMA integrity_check").all() as { integrity_check: string }[]; if (integrity.length !== 1 || integrity[0]?.integrity_check !== "ok" || db.query("PRAGMA foreign_key_check").all().length) throw new Error("Backup integrity check failed"); db.query("SELECT id FROM users LIMIT 1").all(); db.query("SELECT id FROM habits LIMIT 1").all(); db.query("SELECT hash FROM __drizzle_migrations LIMIT 1").all(); } finally { db.close(); } } function snapshots(directory: string) { if (!existsSync(directory)) return []; return readdirSync(directory).filter(name => filenamePattern.test(name)).sort().reverse(); } export function latestBackupTime(config: BackupConfig) { const latest = snapshots(config.directory)[0]; if (!latest || (config.replica && !existsSync(join(config.replica, latest)))) return 0; return statSync(join(config.directory, latest)).mtimeMs; } export function createBackup(database: Database, config: BackupConfig) { const name = `minabot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID()}.sqlite`; mkdirSync(config.directory, { recursive: true, mode: 0o700 }); const path = join(config.directory, name); const staging = `${path}.partial`; mkdirSync(staging, { mode: 0o700 }); const temporary = join(staging, "snapshot.sqlite"); try { // VACUUM INTO includes committed WAL transactions in one consistent snapshot. // SQLite creates the output inside a private staging directory. database.query("VACUUM INTO ?").run(temporary); verifyBackup(temporary); durable(temporary); renameSync(temporary, path); if (config.replica) { // Require the configured mount to exist; never silently create an absent mount. if (!statSync(config.replica).isDirectory()) throw new Error("Backup replica directory unavailable"); const replica = join(config.replica, name); copyFileSync(path, `${replica}.partial`, constants.COPYFILE_EXCL); durable(`${replica}.partial`); verifyBackup(`${replica}.partial`); renameSync(`${replica}.partial`, replica); } for (const directory of [config.directory, ...(config.replica ? [config.replica] : [])]) for (const old of snapshots(directory).slice(config.retain)) unlinkSync(join(directory, old)); return path; } finally { rmSync(staging, { recursive: true, force: true }); } } export function restoreBackup(source: string, destination: string) { if (existsSync(destination) || existsSync(`${destination}-wal`) || existsSync(`${destination}-shm`)) throw new Error("Restore destination must be a new path with no SQLite sidecars"); verifyBackup(source); mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }); const temporary = `${destination}.${crypto.randomUUID()}.partial`; try { copyFileSync(source, temporary, constants.COPYFILE_EXCL); chmodSync(temporary, 0o600); const restored = new Database(temporary); try { restored.exec("PRAGMA journal_mode=DELETE; DELETE FROM sessions;"); if (restored.query("SELECT name FROM sqlite_master WHERE name = 'reminder_settings'").get()) restored.exec("UPDATE reminder_settings SET enabled=0"); } finally { restored.close(); } verifyBackup(temporary); durable(temporary); // Link is exclusive: a concurrently-created destination is never overwritten. linkSync(temporary, destination); } finally { if (existsSync(temporary)) unlinkSync(temporary); } return destination; } /** Runs all filesystem and SQLite backup work in a separate Bun process. */ export async function runScheduledBackup(database: string, config: BackupConfig): Promise { const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url); const built = new URL("../scripts/scheduled-backup.ts", import.meta.url); const script = fileURLToPath(existsSync(source) ? source : built); const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], { stdout: "pipe", stderr: "ignore", }); const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]); if (status !== 0) throw new Error("Scheduled backup process failed"); const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean }; if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp"); if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() })); return timestamp; } export function startBackups(database: Database | string, config: BackupConfig, run = runScheduledBackup) { const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; let running: Promise | undefined; let stopped = false; const tick = (): Promise => { if (stopped || !config.enabled) return Promise.resolve(); if (running) return running; running = (async () => { try { const path = typeof database === 'string' ? database : database.filename; if (!path || path === ':memory:') throw new Error('Scheduled backups require a file database'); state.lastSuccess = await run(path, config); state.failed = false; } catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); } })().finally(() => { running = undefined; }); return running; }; // Callers await readiness before accepting traffic, matching the original startup check. const ready = tick(); const timer = config.enabled ? setInterval(() => { void tick(); }, 60000) : undefined; timer?.unref(); return { state, ready, tick, stop: () => { stopped = true; clearInterval(timer); } }; }