import { constants } from "node:fs"; import { access, stat } from "node:fs/promises"; import { resolve } from "node:path"; import { eq, siteConfig, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js"; // Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen // and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI // takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) — // a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can // show last-success / last-error, and serializes concurrent runs (manual + timer). See // wiki/concepts/backup-recovery.md. // // Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in // memory — an earlier version tracked these as plain in-process fields only, so every server // restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently // reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating // backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md. /** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */ export function backupKeyFromEnv(): string { return process.env.BACKUP_KEY ?? ""; } export interface TargetCheck { readonly ok: boolean; /** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */ readonly reason?: string; } export interface BackupStatus { /** True once a target dir is set AND a usable key is present (else backups are a no-op). */ readonly configured: boolean; /** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */ readonly targetDir: string | null; /** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */ readonly keepLast: number; readonly keepDailyDays: number; /** Whether the env key is present + long enough (the UI flags a missing key distinctly). */ readonly keyPresent: boolean; readonly running: boolean; readonly lastSuccessAt: string | null; readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null; readonly lastErrorAt: string | null; readonly lastError: string | null; } /** Probe a candidate target path server-side: exists, is a directory, is writable. */ export async function checkTargetDir(dir: string): Promise { const trimmed = dir.trim(); if (!trimmed) return { ok: false, reason: "empty" }; const path = resolve(trimmed); let st: Awaited>; try { st = await stat(path); } catch { return { ok: false, reason: "missing" }; } if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" }; try { await access(path, constants.W_OK); } catch { return { ok: false, reason: "not_writable" }; } return { ok: true }; } export class BackupService { readonly #db: Db; readonly #logger?: FastifyBaseLogger; #running = false; constructor(db: Db, logger?: FastifyBaseLogger) { this.#db = db; this.#logger = logger; } /** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale * or reset on restart). */ #row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined { return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); } #persist(patch: { backupLastSuccessAt?: string | null; backupLastResultJson?: string | null; backupLastErrorAt?: string | null; backupLastError?: string | null; }): void { const updatedAt = new Date().toISOString(); const existing = this.#row(); if (existing) { this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); } else { this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run(); } } /** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */ targetDir(): string | null { const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const dir = row?.backupTargetDir?.trim(); return dir ? dir : null; } /** Resolved retention from site_config, falling back to the code default per field. Read fresh. */ retention(): BackupRetention { const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const keepLast = row?.backupKeepLast; const keepDailyDays = row?.backupKeepDailyDays; return { keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast, keepDailyDays: keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays, }; } get keyPresent(): boolean { return backupKeyFromEnv().length >= 16; } get configured(): boolean { return this.targetDir() !== null && this.keyPresent; } status(): BackupStatus { const r = this.retention(); const row = this.#row(); let lastResult: BackupStatus["lastResult"] = null; if (row?.backupLastResultJson) { try { lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"]; } catch { lastResult = null; // corrupt/foreign value in the column — don't let it crash status() } } return { configured: this.configured, targetDir: this.targetDir(), keepLast: r.keepLast, keepDailyDays: r.keepDailyDays, keyPresent: this.keyPresent, running: this.#running, lastSuccessAt: row?.backupLastSuccessAt ?? null, lastResult, lastErrorAt: row?.backupLastErrorAt ?? null, lastError: row?.backupLastError ?? null, }; } /** * Run one backup. `trigger` is just for the log line. Serialized: if one is already in * flight, resolves to that same promise. Reads the target dir + key at run time. Records * last-success/last-error. Re-throws on failure so a manual caller (the route) can surface * it; the scheduled timer wraps + swallows. */ #inflight: Promise | null = null; async run(trigger: "manual" | "scheduled"): Promise { if (this.#inflight) return this.#inflight; const targetDir = this.targetDir(); const key = backupKeyFromEnv(); if (!targetDir) throw new Error("backup: no target directory configured"); if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)"); this.#running = true; this.#inflight = (async () => { try { this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`); const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger); this.#persist({ backupLastSuccessAt: new Date().toISOString(), backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }), backupLastErrorAt: null, backupLastError: null, }); return res; } catch (err) { const message = (err as Error).message; this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message }); this.#logger?.error(`backup: failed (${trigger}): ${message}`); throw err; } finally { this.#running = false; this.#inflight = null; } })(); return this.#inflight; } /** * Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on * a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval * has actually elapsed since the last recorded success, so frequent polling doesn't cause * frequent backups. */ async runScheduled(): Promise { if (!this.configured) return; // silent no-op when backups aren't set up if (!this.isDue()) return; try { await this.run("scheduled"); } catch { /* recorded in last-error; already logged */ } } /** * Wall-clock check: has enough time elapsed since the last successful backup for a new one * to be due? Deliberately based on the PERSISTED last-success instant, not "time since this * process started" — a `setInterval(..., 24h)` measured from process start silently drifts * (or skips a whole day) across every restart, since the countdown restarts from zero each * time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md. */ isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean { const lastSuccessAt = this.#row()?.backupLastSuccessAt; if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured const last = new Date(lastSuccessAt).getTime(); if (Number.isNaN(last)) return true; return now.getTime() - last >= intervalMs; } }