import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { archiveFileName, dumpFileName, expiredSets, listSets, newBackupId } from './backup-set.js'; import { readStatus, writeStatus, type BackupRun, type BackupSizes, type BackupStatus, } from './status.js'; /** * One nightly run (ADR 0015): dump first, then the volume archive (files may * be minutes newer than the dump — documented consistency model), then prune, * then `status.json`. Artifacts are written under a `.partial` suffix and * renamed on completion, so a crash never leaves a file that looks like a * restorable artifact, and prune (which ignores `.partial`) never counts a * torn set as the "newest complete" one. */ export interface RunnerDeps { backupsDir: string; retentionDays: number; now(): Date; /** Real implementation: pg_dump -Fc (pg.ts). */ dump(outFile: string): Promise; /** Real implementation: tar of the uploads/plugins mounts (archive.ts). */ archive(outFile: string): Promise; /** Failure alert (mail.ts); errors here are logged, never rethrown. */ onFailure(run: { backupId: string; error: string; lastSuccessAt: string | null }): Promise; log: { info(details: object, message: string): void; error(details: object, message: string): void; }; } export async function runBackup(deps: RunnerDeps): Promise { const startedAt = deps.now(); const backupId = newBackupId(startedAt); const previous = readStatus(deps.backupsDir); const lastSuccess = previous?.lastSuccess ?? null; await mkdir(deps.backupsDir, { recursive: true }); const dumpFile = join(deps.backupsDir, dumpFileName(backupId)); const archiveFile = join(deps.backupsDir, archiveFileName(backupId)); let run: BackupRun; let success: BackupStatus['lastSuccess'] = lastSuccess; try { const sizes = await produceArtifacts(deps, dumpFile, archiveFile); const finishedAt = deps.now(); run = { backupId, startedAt: startedAt.toISOString(), finishedAt: finishedAt.toISOString(), durationMs: finishedAt.getTime() - startedAt.getTime(), outcome: 'succeeded', sizes, }; success = { backupId, finishedAt: run.finishedAt, sizes }; deps.log.info({ backupId, sizes }, 'backup run succeeded'); } catch (error) { await removePartials(deps.backupsDir); const message = error instanceof Error ? error.message : String(error); const finishedAt = deps.now(); run = { backupId, startedAt: startedAt.toISOString(), finishedAt: finishedAt.toISOString(), durationMs: finishedAt.getTime() - startedAt.getTime(), outcome: 'failed', error: message, }; deps.log.error({ backupId, error: message }, 'backup run failed'); try { await deps.onFailure({ backupId, error: message, lastSuccessAt: lastSuccess?.finishedAt ?? null, }); } catch (mailError) { deps.log.error({ backupId, error: String(mailError) }, 'failure alert could not be sent'); } } await prune(deps); const status: BackupStatus = { schemaVersion: 1, updatedAt: deps.now().toISOString(), retentionDays: deps.retentionDays, lastRun: run, lastSuccess: success, }; await writeStatus(deps.backupsDir, status); return status; } /** Dump, then archive — each staged as `.partial` and renamed only when done. */ async function produceArtifacts( deps: RunnerDeps, dumpFile: string, archiveFile: string, ): Promise { await deps.dump(`${dumpFile}.partial`); await rename(`${dumpFile}.partial`, dumpFile); await deps.archive(`${archiveFile}.partial`); await rename(`${archiveFile}.partial`, archiveFile); return { dumpBytes: (await stat(dumpFile)).size, archiveBytes: (await stat(archiveFile)).size, }; } /** Leftover staging files: from this failed run or an earlier crash — no * resumption path exists, so they are dead weight either way. */ async function removePartials(backupsDir: string): Promise { for (const name of await readdir(backupsDir)) { if (name.endsWith('.partial')) await rm(join(backupsDir, name), { force: true }); } } async function prune(deps: RunnerDeps): Promise { const names = await readdir(deps.backupsDir); for (const set of expiredSets(listSets(names), deps.now(), deps.retentionDays)) { for (const file of set.files) { await rm(join(deps.backupsDir, file), { force: true }); } deps.log.info({ backupId: set.id }, 'pruned expired backup set'); } }