/** * A restore set (ADR 0015) is one nightly `pg_dump` plus the matching * uploads/plugins archive, tied together by a shared backup id derived from * the run's UTC start time. This module owns the naming scheme and the pure * prune decision; the sidecar's runner applies it to * the filesystem, and the api reads it to list local sets for the in-app * restore (issue #103). */ export interface BackupSet { id: string; /** File names (not paths) present in the backups directory. */ files: string[]; /** Complete = both the dump and the volume archive exist. */ complete: boolean; } const ID_PATTERN = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/; const SET_FILE_PATTERN = /^(?:db-|files-)(\d{8}-\d{6})\.(?:dump|tar\.gz)$/; /** Backup id for a run starting now: UTC timestamp, filesystem-safe. */ export function newBackupId(now: Date): string { const pad = (value: number): string => String(value).padStart(2, '0'); return ( `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}` + `-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}` ); } /** The UTC time encoded in a backup id, or null for a malformed id. */ export function backupIdTime(id: string): Date | null { const match = ID_PATTERN.exec(id); if (!match) return null; const [, year, month, day, hour, minute, second] = match; return new Date( Date.UTC( Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), ), ); } export function dumpFileName(id: string): string { return `db-${id}.dump`; } export function archiveFileName(id: string): string { return `files-${id}.tar.gz`; } /** * Groups the backup directory's file names into sets, oldest first. Files * that do not belong to the naming scheme (status.json, `.partial` staging * files of a running or crashed run) are ignored — prune never touches them. */ export function listSets(fileNames: string[]): BackupSet[] { const byId = new Map(); for (const name of fileNames) { const match = SET_FILE_PATTERN.exec(name); if (!match || !backupIdTime(match[1]!)) continue; const files = byId.get(match[1]!) ?? []; files.push(name); byId.set(match[1]!, files); } return [...byId.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([id, files]) => ({ id, files: files.sort(), complete: files.includes(dumpFileName(id)) && files.includes(archiveFileName(id)), })); } /** * The sets prune may delete: older than the retention cutoff — but never * the newest complete set, even when it is expired. A stalled instance must * always keep one restorable set (issue #83 acceptance criteria). */ export function expiredSets(sets: BackupSet[], now: Date, retentionDays: number): BackupSet[] { const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000; const newestComplete = [...sets].reverse().find((set) => set.complete); return sets.filter((set) => { if (set === newestComplete) return false; const time = backupIdTime(set.id); return time !== null && time.getTime() < cutoff; }); }