/** * The contract of the backup sidecar's `status.json` (ADR 0015, issue #83): * the sidecar writes it after every run, the api reads it for the readyz * backup-freshness check (issue #85) and the admin panel's backup card * (issue #86). Keep the shape additive — bump `schemaVersion` on breaking * changes. */ export const BACKUP_STATUS_FILE = 'status.json'; /** * Freshness bound for readiness (operations.md §Health): the nightly cadence * plus a two-hour grace window. An older (or missing) last success degrades * readyz — it never hard-fails it. */ export const BACKUP_FRESH_MAX_AGE_HOURS = 26; /** * Freshness bound of the off-host copy (issue #103) when the upload * schedule is `weekly`: seven nightly cadences plus the same grace window. * `daily` uploads share {@link BACKUP_FRESH_MAX_AGE_HOURS}. */ export const BACKUP_REMOTE_WEEKLY_MAX_AGE_HOURS = 7 * 24 + 2; export interface BackupSizes { dumpBytes: number; archiveBytes: number; } export interface BackupRun { backupId: string; startedAt: string; finishedAt: string; durationMs: number; outcome: 'succeeded' | 'failed'; /** Present on failure: the first error the run hit, as a plain string. */ error?: string; /** Present on success. */ sizes?: BackupSizes; } export interface BackupStatus { schemaVersion: 1; updatedAt: string; retentionDays: number; lastRun: BackupRun; /** Carried across failed runs so freshness checks see the real gap. */ lastSuccess: { backupId: string; finishedAt: string; sizes: BackupSizes } | null; /** * Off-host upload state (issue #103). Absent until a Nextcloud target was * configured and the sidecar attempted its first upload; carried across * runs like `lastSuccess` so a broken target keeps its last good copy * visible. */ remote?: BackupRemoteStatus; /** rsync mirror state (issue #84); absent until a target is configured. */ mirror?: BackupMirrorStatus; } /** One WebDAV upload attempt of a complete restore set (issue #103). */ export interface BackupRemoteUpload { backupId: string; finishedAt: string; outcome: 'succeeded' | 'failed'; /** Present on failure: the first error the upload hit, as a plain string. */ error?: string; /** Present on success: size of the uploaded bundle. */ sizeBytes?: number; } export interface BackupRemoteStatus { lastUpload: BackupRemoteUpload; /** Carried across failed uploads — the newest copy known to exist remotely. */ lastSuccessfulUpload: { backupId: string; finishedAt: string; sizeBytes: number } | null; } /** * The rsync mirror to a private host (issue #84, ADR 0015) — an * operator-level extra beside the admin-configured Nextcloud target * (#103). Present once a mirror target is configured; carried across * failed runs like the other blocks. */ export interface BackupMirrorStatus { lastRun: { finishedAt: string; outcome: 'succeeded' | 'failed'; /** Present on failure: the first error the mirror hit. */ error?: string; /** Present on success: files rsync actually transferred (0 = idempotent re-run). */ transferredFiles?: number; }; /** Carried across failed runs — when the mirror last matched the local sets. */ lastSuccessAt: string | null; } /** * The remote bundle naming scheme (issue #103): one self-contained archive * per restore set, everything needed to rebuild an instance after total * loss (`db-.dump`, `files-.tar.gz`, `manifest.json`). */ export function remoteBundleName(backupId: string): string { return `dorfteich-backup-${backupId}.tar.gz`; } const REMOTE_BUNDLE_PATTERN = /^dorfteich-backup-(\d{8}-\d{6})\.tar\.gz$/; /** The backup id encoded in a remote bundle name, or null for foreign files. */ export function remoteBundleId(name: string): string | null { return REMOTE_BUNDLE_PATTERN.exec(name)?.[1] ?? null; } /** * The in-app restore contract (issue #103): the sidecar orchestrates a * restore and mirrors its progress into `restore-status.json` next to * `status.json`. The api's maintenance gate answers 503 while `state` is * `running`, and restarts itself once it flips to `succeeded`. */ export const RESTORE_STATUS_FILE = 'restore-status.json'; /** * Safety valve: a `running` restore older than this is treated as crashed * (sidecar died before writing a final state), so the api leaves maintenance * mode instead of serving 503 forever. */ export const RESTORE_STALE_MAX_AGE_MINUTES = 30; export interface RestoreStatus { schemaVersion: 1; state: 'running' | 'succeeded' | 'failed'; backupId: string; source: 'local' | 'remote'; /** Username of the requesting Site Admin (display only). */ requestedBy: string | null; startedAt: string; finishedAt: string | null; /** Present when `state` is `failed`. */ error?: string; } /** * PostgreSQL `NOTIFY` channel over which the api sends commands to the * backup sidecar (issue #103) — the same primitive as the api↔collab bus * (collab-token.ts). Payload is a JSON {@link BackupCommand}. */ export const BACKUP_COMMAND_CHANNEL = 'backup_command'; export type BackupCommand = | { kind: 'run'; requestedBy: string | null } | { kind: 'restore'; source: 'local' | 'remote'; backupId: string; requestedBy: string | null }; /** * PostgreSQL `NOTIFY` channel over which the backup sidecar announces * maintenance-mode transitions during an in-app restore (issue #103). The * collab server listens and closes/refuses live sessions so no in-memory * document persists pre-restore content back over the restored database. * Payload is a JSON {@link MaintenanceEvent}. */ export const BACKUP_MAINTENANCE_CHANNEL = 'backup_maintenance'; export interface MaintenanceEvent { phase: 'enter' | 'exit'; }