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 type { BackupMirrorStatus, BackupRemoteStatus } from '@dorfteich/shared'; 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; /** * Off-host upload of the completed set (issue #103, remote.ts). Called * only after a successful local run; returns the new remote status, or * undefined when no upload happened (not configured / not due) — the * previous remote status is then carried unchanged. Upload failures are * reported inside the returned status, never thrown: the local set exists * and the run must count as succeeded. */ upload?(input: { backupId: string; previous: BackupRemoteStatus | undefined; }): Promise; /** * rsync mirror to a private host (issue #84, mirror.ts). Runs after the * prune so the remote retention aligns with the local one. Failures are * reported inside the returned status, never thrown. */ mirror?(input: { previous: BackupMirrorStatus | undefined; }): 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; let remote = previous?.remote; let mirror = previous?.mirror; 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'); if (deps.upload) { try { remote = (await deps.upload({ backupId, previous: remote })) ?? remote; } catch (uploadError) { // Defensive: remote.ts reports failures in its return value; a throw // here is a bug, but it must never turn a good local run into a // failed one. deps.log.error({ backupId, error: String(uploadError) }, 'upload hook threw unexpectedly'); } } } 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); // After the prune, so `--delete` aligns the remote retention with the // local one — including the newest-complete-set guarantee. if (run.outcome === 'succeeded' && deps.mirror) { try { mirror = (await deps.mirror({ previous: mirror })) ?? mirror; } catch (mirrorError) { // Defensive like the upload hook: mirror.ts reports failures in its // return value; a throw here must never fail the local run. deps.log.error({ error: String(mirrorError) }, 'mirror hook threw unexpectedly'); } } const status: BackupStatus = { schemaVersion: 1, updatedAt: deps.now().toISOString(), retentionDays: deps.retentionDays, lastRun: run, lastSuccess: success, ...(remote ? { remote } : {}), ...(mirror ? { mirror } : {}), }; 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'); } }