import fs from "node:fs"; import os from "node:os"; import path from "node:path"; // Prefix for dev-prod-db automatic snapshots. Distinct from the backend's // "kandev-*" auto-snapshots and "manual-*" user snapshots so the families // don't interfere with each other's retention policies. const BACKUP_PREFIX = "dev-prod-db-"; const BACKUP_SUFFIX = ".db"; const MAX_BACKUPS = 5; /** * Returns true if the given dbPath points to a non-dev database that should * be backed up before running dev mode. Dev-isolated databases live under * /.kandev-dev/ and are considered disposable. */ export function isProductionDb(dbPath: string): boolean { const normalized = path.normalize(dbPath); const segments = normalized.split(path.sep).filter(Boolean); return !segments.includes(".kandev-dev"); } /** * Backs up the database at dbPath to /backups/ before dev mode * runs against it. Keeps only the newest MAX_BACKUPS snapshots. * * Returns the path of the created backup, or null if the DB didn't exist * or no backup was needed. * * The optional `homeDir` parameter is exposed for tests so they can redirect * the backup location without mocking os.homedir(). The optional `now` * parameter lets tests pass an explicit timestamp so back-to-back calls in * the same millisecond produce distinct filenames + mtimes without sleeping. */ export function backupProductionDb(dbPath: string, homeDir?: string, now?: Date): string | null { if (!fs.existsSync(dbPath)) { return null; } const root = homeDir ?? os.homedir(); // Backups are always placed under ~/.kandev/data/backups/ (or the // caller-supplied homeDir in tests), even when dbPath points elsewhere. // This matches the dev-prod-db default flow; custom KANDEV_DATABASE_PATH // values are advanced usage where the user is responsible for backup location. const dataDir = path.join(root, ".kandev", "data"); const backupDir = path.join(dataDir, "backups"); fs.mkdirSync(backupDir, { recursive: true }); const stamp = now ?? new Date(); const ts = stamp.toISOString().replace(/[:.]/g, ""); const name = `${BACKUP_PREFIX}${ts}${BACKUP_SUFFIX}`; const dest = path.join(backupDir, name); fs.copyFileSync(dbPath, dest); // Stamp both atime + mtime so pruning (which sorts by mtime) is deterministic. fs.utimesSync(dest, stamp, stamp); pruneBackups(backupDir, MAX_BACKUPS); return dest; } /** * Keeps only the `keep` newest dev-prod-db backup files in `dir`, deleting * older ones. Non-matching files are left untouched. */ function pruneBackups(dir: string, keep: number): void { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } const files = entries .filter((e) => e.isFile() && e.name.startsWith(BACKUP_PREFIX) && e.name.endsWith(BACKUP_SUFFIX)) .map((e) => { const fullPath = path.join(dir, e.name); try { const stat = fs.statSync(fullPath); return { path: fullPath, mtime: stat.mtime }; } catch { return null; } }) .filter((f): f is { path: string; mtime: Date } => f !== null); if (files.length <= keep) { return; } files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); for (const f of files.slice(keep)) { try { fs.unlinkSync(f.path); } catch { // Non-critical: don't fail the launch if one old backup can't be removed. } } }