/** * Backup retention: how many copies to keep, and for how long. * * Policies are count-based (keep last N) and age-based (delete older than X * days), and the two are INDEPENDENT — whichever limit is hit first triggers * deletion. The manifest suggests; the operator's `backup_retention_count` and * `backup_retention_max_age_days` overrides decide, each dimension resolved on * its own. * * ⚠️ An unset dimension is UNBOUNDED, never a default bound. Today an absent * `backup.retention` block means prune nothing at all — the block is optional, * so its inner `count: 7` / `max_age_days: 30` defaults never apply. If setting * one dimension made the other fall back to those defaults, an operator asking * to keep 3 copies would silently also arm a 30-day deletion they never asked * for, on a module that had been keeping everything. Deleting backups nobody * asked to delete is the one failure here that cannot be undone. */ import type { Backup } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { deleteBackupRecord, listCompletedBackupsForModule } from './backup-metadata'; import { createStorageProvider } from './backup-storage'; import { configOverride } from './module-config'; /** The `module_configs` keys an operator's retention policy is stored under. */ export const BACKUP_RETENTION_COUNT_CONFIG_KEY = 'backup_retention_count'; export const BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY = 'backup_retention_max_age_days'; export interface RetentionPolicy { /** Copies to keep. `Infinity` means unbounded — keep every copy. */ count: number; /** Days to keep. `Infinity` means unbounded — never delete on age. */ maxAgeDays: number; } /** Neither dimension bounded: nothing is ever pruned, so the pass can be skipped. */ export function prunesNothing(policy: RetentionPolicy): boolean { return ( policy.count === Number.POSITIVE_INFINITY && policy.maxAgeDays === Number.POSITIVE_INFINITY ); } /** * Takes the module's operator config as loaded from `module_configs`. An * unparseable or non-positive override leaves that dimension to the manifest: * values are validated at SET time, so a bad one here means hand-edited state, * and keeping too much is the only safe direction to fail in. */ export function effectiveBackupRetention( manifest: ModuleManifest, configs: Record | undefined, ): RetentionPolicy { const declared = manifest.backup?.retention; return { count: positiveIntegerOr( configOverride(configs, BACKUP_RETENTION_COUNT_CONFIG_KEY), declared?.count, ), maxAgeDays: positiveIntegerOr( configOverride(configs, BACKUP_RETENTION_MAX_AGE_DAYS_CONFIG_KEY), declared?.max_age_days, ), }; } /** Override, else the manifest's suggestion, else unbounded. Never a default bound. */ function positiveIntegerOr(override: string | undefined, suggested: number | undefined): number { if (override !== undefined) { const parsed = Number(override); if (Number.isInteger(parsed) && parsed > 0) return parsed; } return suggested ?? Number.POSITIVE_INFINITY; } export interface PruneResult { moduleId: string; deleted: number; deletedPaths: string[]; } /** * Identify backups that should be pruned per the retention policy. * * An unbounded dimension is `Infinity`, which needs no special case: no index * reaches it and no age exceeds it. */ export function identifyExpiredBackups(backupsList: Backup[], policy: RetentionPolicy): Backup[] { const now = Date.now(); const maxAgeMs = policy.maxAgeDays * 24 * 60 * 60 * 1000; const expired: Backup[] = []; // Backups are already sorted newest-first from the query for (let i = 0; i < backupsList.length; i++) { const backup = backupsList[i]; const age = now - new Date(backup.startedAt).getTime(); const exceedsCount = i >= policy.count; const exceedsAge = age > maxAgeMs; if (exceedsCount || exceedsAge) { expired.push(backup); } } return expired; } /** * Prune expired backups for a module. * Deletes from storage and removes database records. */ export async function pruneBackupsForModule( moduleId: string, policy: RetentionPolicy, dryRun = false, ): Promise { const backupsList = listCompletedBackupsForModule(moduleId); const expired = identifyExpiredBackups(backupsList, policy); if (dryRun || expired.length === 0) { return { moduleId, deleted: expired.length, deletedPaths: expired.map((b) => b.storagePath), }; } const deletedPaths: string[] = []; for (const backup of expired) { try { const provider = await createStorageProvider(backup.storageId); await provider.delete(backup.storagePath); } catch { // Storage deletion may fail if file already removed — continue } deleteBackupRecord(backup.id); deletedPaths.push(backup.storagePath); } return { moduleId, deleted: deletedPaths.length, deletedPaths, }; }