// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups. // A snapshot is a complete, consistent copy made while the app keeps running, and is restored with // scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off. import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs'; import { join } from 'path'; import { logEvent } from './health.js'; import { backupDatabase, dataDir, replaceDatabase } from './db.js'; const DAY = 24 * 60 * 60 * 1000; export const backupDir = () => join(dataDir(), 'backups'); const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16); export const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name); export function listSnapshots(dir = backupDir()) { if (!existsSync(dir)) return []; return readdirSync(dir).filter(isSnapshot).sort().reverse() .map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs })); } // Deletes all but the newest `keep` snapshots; returns the names removed. export function pruneSnapshots(keep = 7, dir = backupDir()) { const old = listSnapshots(dir).slice(Math.max(1, keep)); old.forEach((s) => unlinkSync(join(dir, s.name))); return old.map((s) => s.name); } export async function runBackup(now = new Date()) { const dir = backupDir(); mkdirSync(dir, { recursive: true }); const file = join(dir, `projects-${stamp(now)}.db`); await backupDatabase(file); pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir); return file; } export const snapshotPath = (name) => (isSnapshot(name) && existsSync(join(backupDir(), name)) ? join(backupDir(), name) : null); // Puts a snapshot back as the live database. A fresh snapshot of what is there now is taken first (from a // copy of the chosen file, so the two can't collide), so a restore can itself be undone. export async function restoreSnapshot(name, now = new Date()) { const src = snapshotPath(name); if (!src) throw new Error('No such snapshot.'); const hold = join(backupDir(), `.restoring-${Date.now()}.tmp`); copyFileSync(src, hold); try { const before = await runBackup(now); replaceDatabase(hold); return { restored: name, safetyCopy: before }; } finally { unlinkSync(hold); } } // Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up). export function startBackupScheduler() { if (process.env.BACKUP_DISABLED === '1') return; const tick = () => { const latest = listSnapshots()[0]; if (latest && Date.now() - latest.at < DAY) return; runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => { console.error('Database snapshot failed:', err.message); logEvent('backup', err.message); }); }; setTimeout(tick, 30_000).unref(); setInterval(tick, 60 * 60 * 1000).unref(); }