Backup Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for backup.
Backup Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/backup/backup.module.ts, admin/backup-admin-aggregate.module.ts, shared/backup-shared.module.ts, workers/backup-worker.module.ts | Imports, providers, exports, boot-time reconcile. |
| Controllers | admin/catalog/backup-admin.controller.ts, admin/download/backup-download-admin.controller.ts, admin/restore/backup-restore-admin.controller.ts, admin/maintenance/maintenance-admin.controller.ts, admin/upload/backup-upload-admin.controller.ts, customer/maintenance-customer.controller.ts | Route ownership, guards, permissions — including the one @Public() route. |
| Services | All files under admin/*/, shared/*.service.ts | Business logic, validation, writes, side effects. |
| DTOs | admin/catalog/dto/*.ts, admin/restore/dto/restore.dto.ts, admin/maintenance/dto/maintenance.dto.ts, admin/download/dto/backup-download-params.dto.ts, admin/upload/dto/backup-upload.dto.ts | Request/response contracts. |
| Schema | packages/db/src/schema/backup/backup-run.ts, backup-restore.ts, enums.ts | Tables, enums, constraints, indexes. |
| Jobs | packages/jobs/src/index.ts (BackupJob, BackupRestoreJob, BackupReplicateJob) | Queue contracts and payloads. |
| Workers | apps/api/src/modules/backup/workers/*.ts, including backup-restore.processor.ts and restore-runner.ts | Processors, dispatch, schedulers, and the detached restore executor. |
| Env | apps/api/src/config/env.validation.ts (BACKUP_*, PG_*, TAR_PATH, BACKUP_SSH_PATH, BACKUP_RSYNC_PATH) | Every environment-only knob. |
| Structure baseline | apps/api/test/structure/structure.baseline.json | Authoritative route list — grepped for system/backups, system/restores, system/maintenance; 15 distinct route templates confirmed: 14 under admin/system/* plus the one public GET /api/system/maintenance. |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Backup, BackupDownload, BackupConfigure, BackupUpload modules, superadmin-only. |
| Rate limits | apps/api/src/common/guards/ip-throttler.config.ts | ADMIN_RESTORE_SUBMIT, ADMIN_BACKUP_DOWNLOAD, ADMIN_BACKUP_UPLOAD, ADMIN_ASYNC_JOB_SUBMIT. |
2. Backend Scope and Boundaries
Owns
- Manual and scheduled PostgreSQL logical dumps (
pg_dump -Fc) of the whole application database, viaBackupRunCreateService/BackupRunHandler. - An optional archive of the local upload root (
tar -czf), only when the effective storage driver is local. - A filesystem catalog (
manifest.jsonper run) that is self-describing and independently rebuildable, plus a Postgres index of that catalog (backup_run). - Count-based grandfather-father-son (GFS) retention and pruning (
BackupRetentionService), both scheduled and admin-triggered. - Optional off-host replication of completed artifacts over rsync-over-ssh, with local-copy reclamation once a remote copy is verified (
BackupReplicationService). - A site-wide maintenance kill switch (
MaintenanceService,MaintenanceGuard) that blocks customer traffic while engaged, enforced globally viaAPP_GUARD. - Admin-editable backup/retention policy stored in
settings.jsonunder the backup root (BackupSettingsService), with optimistic concurrency. - The request/accept surface for restoring the live database from a backup: preflight validation (
BackupRestorePreflightService), abackup_restoreaudit row, engaging maintenance, writing a live-state file, and enqueueing a restore job (BackupRestoreAdminService). - Executing the restore, with a safety net on both sides.
BackupRestoreProcessortakes an inline pre-restore safety dump and links it to the restore (backup_restore.safety_backup_run_id) before spawningworkers/restore-runner.tsas a detached process — deliberately not an in-process handler, becausepg_restore --cleantakesACCESS EXCLUSIVEon every table, includingadmin_sessions. The runner drains existing connections before terminating them, restores the database, then verifies restored row counts against the source manifest.BackupRestoreProcessorsupervises the whole run by pollingrestore-state.jsonuntil a terminal stage or an abandoned heartbeat. See 16.8 Backend Risk Register for the narrower gaps that remain. - Cataloguing an operator-supplied archive (
BackupUploadAdminController/BackupUploadAdminService, SE-4): verifies a keyed HMAC signature over the dump and manifest, screens the archive's schema SQL for constructs a restore must not execute, and inserts akind:'uploaded'backup_runrow the existing restore path can act on unchanged. Does not restore anything itself. - Streaming a completed dump to an authenticated, permissioned admin, with integrity checks that run before the first byte and an audit write that fails closed (
BackupDownloadAdminService).
Does Not Own
- MongoDB backup/restore. The restore acknowledgement text (
RestoreBackupDto.acknowledgeMongoNotRestored) states this explicitly: only PostgreSQL is in scope. - Storage driver implementation (local vs. bucket) — that is
@skoolsewa/storage'sStorageManager, which this module only readshasRemoteDriver()from. - BullMQ queue registration and
defaultJobOptions—QueueName.BACKUP,QueueName.BACKUP_RESTOREandQueueName.BACKUP_REPLICATEare registered inapps/api/src/services/bullmq/bull.module.ts'sREGISTERED_QUEUES, not in this module. - Outbox delivery —
OutboxService/OutboxModule(apps/api/src/modules/outbox/) own atomic enqueue and the dispatcher that relays outbox rows to BullMQ. - Activity/audit log storage —
ActivityRecordService(Postgres-backedactivitylog) andAuditLog(MongoDB) are separate modules this one calls into. - Permission grants —
Backup_*,BackupDownload_*,BackupConfigure_*are declared inpackages/db/src/authorization/permission-catalog.tsand synced bypnpm --filter @skoolsewa/api permissions:sync; this module only checks them via@Permissions(...).
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Backup catalog (queryable) | backup_run table | Rebuildable from disk; never the only copy of the truth. |
| Backup catalog (durable) | manifest.json beside each run's artifacts | Written by the worker before the row is marked completed; a catalog living only in the database it backs up is worthless in the disaster it exists for, and pg_restore --clean replaces backup_run mid-restore. |
| Live restore stage | restore-state.json under the backup root | backup_restore.status only ever holds queued or a terminal value — pg_restore --clean replaces the table holding its own row, so intermediate stages exist only in the file. |
| Admin-editable backup policy | settings.json under the backup root | Not a database table, for the same reason as the restore state — a restore would silently revert every policy field, including the replication destination mode. |
| Maintenance flag | maintenance.json under the backup root, mirrored to Redis, cached in-process 1s | The file is the only layer that survives both an API restart and a restore replacing the database. |
| Environment-only configuration | ConfigService reading apps/api/src/config/env.validation.ts | Binary paths, BACKUP_DIR, replication target/credentials, BACKUP_RESTORE_ENABLED — never admin-editable. See Section 6.6 in the API doc for the full split. |
| Identity | AuthUser from JwtAuthGuard/RoleGuard; request.user.actorType for MaintenanceGuard | Admin-only surface; MaintenanceGuard distinguishes admin actors from customer actors by actorType, not by decorator presence. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
BackupModule | Aggregate + OnModuleInit | apps/api/src/modules/backup/backup.module.ts | None | None (injects BackupManifestService) | BackupSharedModule | Wires BackupSharedModule, BackupAdminAggregateModule, MaintenanceCustomerModule and BackupWorkerModule; runs reconcileFromDisk() once at boot. |
BackupSharedModule | @Global() leaf | shared/backup-shared.module.ts | None | Path, state-file, settings, maintenance, manifest, artifact, signature, key-store, signature-resolver, replication, retention, run-create, restore-preflight services | All of the above | Everything the admin, worker and guard layers need; global because APP_GUARD-registered MaintenanceGuard cannot otherwise inject from it. |
BackupAdminAggregateModule | Aggregate | admin/backup-admin-aggregate.module.ts | None | None | 6 leaf modules | Composes the admin surface; declares no controllers or providers itself. Swagger's include does not recurse into an aggregate, so every leaf is also listed in config/swagger/default.swagger.ts — listing the aggregate alone leaves its routes live and invisible. |
MaintenanceAdminModule | Leaf | admin/maintenance/maintenance-admin.module.ts | MaintenanceAdminController | MaintenanceAdminService | Service | Site-wide kill switch (P0). |
BackupAdminModule | Leaf | admin/catalog/backup-admin.module.ts | BackupAdminController | BackupAdminService | Service | List/detail/create/pin/prune, settings read+write (P1). |
BackupDownloadAdminModule | Leaf | admin/download/backup-download-admin.module.ts | BackupDownloadAdminController | BackupDownloadAdminService | Service | Streaming the dump (P2), separately permissioned. |
BackupRestoreAdminModule | Leaf | admin/restore/backup-restore-admin.module.ts | BackupRestoreAdminController | BackupRestoreAdminService | Service | Restore request/accept surface, live state, force-clear. |
BackupUploadAdminModule | Leaf | admin/upload/backup-upload-admin.module.ts | BackupUploadAdminController | BackupUploadAdminService | Service | SE-4: catalogues an operator-supplied archive so restore/ can act on it, unchanged. |
BackupKeyAdminModule | Leaf | admin/key/backup-key-admin.module.ts | BackupKeyAdminController | BackupKeyAdminService | Service | Signing-key custody: generate, reveal-once, rotate, import, prune, resolve. Its own BackupKey_* permission module, superadmin-only, on the admin/system/backup-key prefix. |
MaintenanceCustomerModule | Leaf | customer/maintenance-customer.module.ts | MaintenanceCustomerController | MaintenanceCustomerService | Service | The one public read: whether the store is refusing traffic. Imported directly by BackupModule, not through the admin aggregate — it is not an admin surface. |
BackupWorkerModule | Leaf | workers/backup-worker.module.ts | None | BackupQueueProcessor, BackupRestoreProcessor, BackupRunHandler, BackupPruneHandler, three schedulers, BackupReplicateQueueProcessor, BackupReplicationHandler | BackupScheduleScheduler | Async surface: @Processor on each of QueueName.BACKUP, QueueName.BACKUP_RESTORE and QueueName.BACKUP_REPLICATE, plus schedulers. BackupRestoreProcessor now also depends on BackupRunCreateService and BackupRunHandler directly, to take an inline pre-restore safety dump before spawning the runner. |
4. File and Directory Map
apps/api/src/modules/backup/
backup.module.ts
backup.constants.ts
admin/
backup-admin-aggregate.module.ts
catalog/
backup-admin.controller.ts
backup-admin.service.ts
backup-admin.module.ts
dto/ (create, update, update-settings, fetch, params, response, settings-response, index)
download/
backup-download-admin.controller.ts
backup-download-admin.service.ts
backup-download-admin.module.ts
dto/backup-download-params.dto.ts
restore/
backup-restore-admin.controller.ts
backup-restore-admin.service.ts
backup-restore-admin.module.ts
dto/restore.dto.ts
maintenance/
maintenance-admin.controller.ts
maintenance-admin.service.ts
maintenance-admin.module.ts
dto/maintenance.dto.ts
upload/
backup-upload-admin.controller.ts
backup-upload-admin.service.ts
backup-upload-admin.module.ts
backup-upload-storage.engine.ts
dto/backup-upload.dto.ts
shared/
backup-artifact.service.ts (pg_dump / tar spawn, hashing)
backup-archive-content.util.ts (schema-SQL construct scan — pure)
backup-manifest.service.ts (snapshot capture, manifest.json, reconcileFromDisk)
backup-path.service.ts (root resolution, containment)
backup-replication.service.ts (rsync-over-ssh push + read-back verify)
backup-restore-preflight.service.ts
backup-retention.service.ts (GFS sweep, prune, floors)
backup-run-create.service.ts (shared insert+enqueue for manual/scheduled)
backup-run-response.util.ts (raw row -> BackupResponseDto shape — pure)
backup-settings.service.ts (settings.json, Zod schema)
backup-signature.service.ts (keyed HMAC sign/verify for uploaded artifacts)
backup-state-file.service.ts (atomic read/write for all 3 state files)
backup-subprocess-excerpt.util.ts (stderr redaction — pure)
backup-tier.util.ts (GFS tier calendar math — pure)
maintenance.guard.ts (APP_GUARD-registered)
maintenance.service.ts (file + Redis + in-process cache)
customer/
maintenance-customer.controller.ts (GET /system/maintenance — @Public())
maintenance-customer.service.ts (maps shared state onto the narrower public DTO)
maintenance-customer.module.ts
dto/maintenance-status.dto.ts
workers/
backup-queue.processor.ts (@Processor QueueName.BACKUP)
backup-run.processor.ts (BackupRunHandler — dispatched, not @Processor)
backup-prune.processor.ts (BackupPruneHandler — dispatched, not @Processor)
backup-restore.processor.ts (@Processor QueueName.BACKUP_RESTORE — spawns + supervises the runner)
restore-runner.ts (detached process — fences DB role, runs pg_restore, heartbeats)
backup-replicate-queue.processor.ts (@Processor QueueName.BACKUP_REPLICATE)
backup-replication.processor.ts (BackupReplicationHandler — dispatched)
backup-retention.scheduler.ts (cron: enqueue daily sweep)
backup-schedule.scheduler.ts (cron: nightly dump, tier assignment)
backup-sweep.scheduler.ts (cron: stall recovery for runs + replication)
backup-worker.module.ts| File | Purpose | Key Exports | Notes |
|---|---|---|---|
backup.constants.ts | Names, layout, thresholds | BACKUP_ARTIFACT_FILES, BACKUP_PARTIAL_SUFFIX, RESTORE_STATE_FILE, SETTINGS_STATE_FILE, MAINTENANCE_STATE_FILE, MAINTENANCE_CACHE_TTL_MS, RESTORE_VERIFICATION_EXCLUDED_TABLES, BACKUP_REMOTE_RELATIVE_DIR_PATTERN | Everything an operator tunes is env-only; this file is layout/names only. |
backup-path.service.ts | Owns every filesystem path; refuses to hand out a path that escapes the root | BackupPathService | Boot-time refuses if BACKUP_DIR is inside a statically-served root; warns if inside the git checkout. |
backup-artifact.service.ts | Spawns pg_dump/tar, hashes output | BackupArtifactService, BackupSubprocessError | .partial + rename; process-group kill on timeout; credentials via child env, never argv. |
backup-manifest.service.ts | Snapshot capture (REPEATABLE READ + pg_export_snapshot()), manifest.json, disk reconciliation | BackupManifestService, BackupRunManifestFile | reconcileFromDisk runs at boot, and is idempotent via ON CONFLICT DO NOTHING. |
backup-settings.service.ts | Admin policy surface, Zod-validated on read and write | BackupSettingsService, BackupSettings | Never throws on read — falls back to env-seeded defaults. |
backup-retention.service.ts | GFS count-based pruning + 3 protective floors | BackupRetentionService | Same floors bind the scheduled sweep and the manual admin delete. |
backup-run-create.service.ts | The one path that inserts a backup_run row and enqueues backup.run | BackupRunCreateService | Shared by "Run now" and the nightly scheduler. |
backup-replication.service.ts | rsync-over-ssh push, local read-back verification | BackupReplicationService | Refuses to boot with replication enabled unless --secluded-args is supported and known_hosts is pinned. |
backup-restore-preflight.service.ts | Every check a restore must pass before a row is inserted | BackupRestorePreflightService | Feature flag, superuser check, acknowledgements, typed database name, schema tag match, artifact integrity (size then sha256). |
backup-signature.service.ts | Keyed HMAC sign/verify for artifacts | BackupSignatureService | HMAC-SHA256(key, "<dumpSha256>:<manifestSha256>"), the key held by BackupKeyStoreService; a plain checksum authenticates nothing when the uploader supplies both the file and the hash. |
backup-download-admin.service.ts | Prepares both downloads | prepareDownload, prepareUploadsDownload, buildTarStream, openUploadsStream | The uploads path deliberately does not hash the archive, and opens the file before the controller sets any header — a failed open must be a typed 410, and that is only possible while nothing has been written to the response. |
backup-key-store.service.ts | Owns the key file and its lifecycle | BackupKeyStoreService | Migrate from env, generate, rotate, import, prune. The file lives OUTSIDE the backup root and outside the upload root, both of which are shipped off-host. |
backup-signature-resolver.service.ts | Records which held key verifies each completed run | BackupSignatureResolverService | Batched; re-examines signed_unknown_key rows, because importing a key changes that answer. |
backup-archive-content.util.ts | Scans a dump's rendered schema SQL for constructs a restore must not execute | scanArchiveSql | Pure; screens SQL text (not the pg_restore --list table of contents, which cannot distinguish a SECURITY DEFINER function from an ordinary trigger function). |
admin/upload/backup-upload-storage.engine.ts | Multer storage engine for the 3-part upload (file, manifest, signature) | BackupUploadStorageEngine | Streams the dump straight to a .partial file on the backup volume, hashing as it writes; buffers only the small manifest/signature parts in memory. |
maintenance.service.ts / maintenance.guard.ts | Site-wide kill switch | MaintenanceService, MaintenanceGuard | 3-layer read (in-process cache → Redis → file); deny-by-default guard. |
backup-tier.util.ts | Pure GFS tier-boundary calendar math | computeBackupTierPeriodBoundaries, formatBackupTierWeekKey | No NestJS import; hand-rolled UTC arithmetic because date-fns reads the process's own zone, not the argument. |
backup-subprocess-excerpt.util.ts | Redacts subprocess stderr to a storable excerpt | sanitizeSubprocessExcerpt | Shared by the dump/tar and rsync/ssh call sites; removes shapes (paths, user@host, IPs), not a name blocklist. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/backup/
index.ts
backup-run.ts
backup-restore.ts
enums.ts5.2 Tables
backup_run
| Column | Type | Nullable | Default | Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | generated | PK | Internal only — never in a response. |
public_id | uuid | No | uuid7() | unique | The only identifier ever exposed. |
kind | backup_kind (scheduled|manual|pre_restore_safety|uploaded) | No | — | — | Decides the retention bucket. manual and uploaded both carry a requester (chk_backup_run_manual_has_actor covers both); uploaded is written only by BackupUploadAdminService.finalize and no code path ever writes pre_restore_safety today — see Does Not Own. |
tier | backup_tier (daily|weekly|monthly) | Yes | — | chk_backup_run_tier_matches_kind | Non-null iff kind = 'scheduled'; assigned at INSERT, not at completion — see enums.ts. |
status | backup_status (queued|running|completed|failed|pruned) | No | queued | several CHECKs below | pruned is terminal and distinct from delete — the row survives. |
pinned | boolean | No | false | — | Operator protection from count-based pruning. BackupUploadAdminService.finalize always inserts uploaded rows with pinned: true — an operator who carried a file in during an incident did not do it so the nightly sweep could delete it an hour later. |
includes_uploads | boolean | No | false | chk_backup_run_uploads_artifact_when_included | Effective value is requested AND driverIsLocal; a safety dump forces it to match the run it protects. |
note | text | Yes | — | — | Operator note, ≤200 chars at the DTO. |
storage_mode | backup_storage_mode (local|both|remote_only) | No | local | — | Recorded per run so history stays readable after a settings change. |
replication_status | backup_replication_status (not_requested|pending|replicated|failed) | No | not_requested | 3 CHECKs below | failed here is not a failed backup — the dump is still usable. |
replicated_at | timestamptz | Yes | — | chk_backup_run_replicated_has_timestamp | Set only once bytes are verified by read-back. |
remote_artifact_dir | text | Yes | — | chk_backup_run_replicated_has_remote_dir | Survives a local prune. |
local_artifact_present | boolean | No | true | chk_backup_run_local_absent_only_when_replicated | Distinct from artifact_dir — manifest.json never leaves under remote_only. |
artifact_dir | text | Yes | — | never set to non-null once pruned | Relative to the backup root; never accepted from a request, never in a response. |
database_dump_bytes | bigint | Yes | — | chk_backup_run_bytes_non_negative | — |
database_dump_sha256 | text | Yes | — | chk_backup_run_sha256_format (^[0-9a-f]{64}$) | Integrity gate for download and restore. |
uploads_archive_bytes / _sha256 | bigint / text | Yes | — | same shape/non-negative rules | Only set when includesUploads produced a real archive. |
postgres_version | text | Yes | — | — | Captured inside the snapshot transaction. |
schema_migration_tag | text | Yes | — | — | Latest drizzle.__drizzle_migrations hash at snapshot time; compared on restore. |
manifest | jsonb (BackupManifest) | Yes | — | — | { tables, postgresVersion, schemaMigrationTag, capturedAt } — table-name to row-count map from the snapshot. |
error_message / error_code | text | Yes | — | chk_backup_run_failed_has_error, chk_backup_run_error_code_shape (^BACKUP_[A-Z_]+$) | error_message stores the sanitised excerpt, never raw subprocess stderr. |
requested_by_admin_user_id | uuid | Yes | — | FK → admin_users, ON DELETE SET NULL | RESTRICT would block admin offboarding permanently. |
requested_by_email | text | Yes | — | chk_backup_run_manual_has_actor | NOT NULL is enforced only for kind = 'manual', by CHECK rather than by column nullability — cron has no actor. |
started_at / finished_at / pruned_at | timestamptz | Yes | — | chk_backup_run_terminal_has_finished_at, chk_backup_run_timestamps_ordered, chk_backup_run_pruned_state | Business timestamps, not updated_at — any later write would overwrite that. |
signature_state | backup_signature_state (unsigned|signed|signed_unknown_key) | Yes | — | 4 CHECKs below | NULL means "not resolved yet", which is not the same as unsigned. |
signing_key_fingerprint | text | Yes | — | chk_backup_run_signing_key_fingerprint_shape (^[0-9a-f]{8}$), chk_backup_run_signed_has_fingerprint | The first 8 hex characters of a labelled digest of the key that signed the artifact. Never the key. |
created_at / updated_at | timestamptz | No | now() | — | updated_at auto-updates via $onUpdateFn. |
Notable CHECK constraints (full list in the schema file):
uq_backup_run_single_running— partial unique index on(true) WHERE status = 'running'. Backstop behind workerconcurrency: 1.chk_backup_run_completed_has_artifact— gated oncompletedonly; disjunction between(localArtifactPresent AND artifactDir non-null)and(NOT localArtifactPresent AND replicationStatus = 'replicated')is what makesremote_onlyrepresentable.idx_backup_run_stalled— partial index onstarted_at WHERE status = 'running', read byBackupSweepScheduler.idx_backup_run_retention—(kind, tier, created_at) WHERE status = 'completed', read by the GFS sweep.chk_backup_run_signed_has_fingerprint—signature_state IS DISTINCT FROM 'signed' OR signing_key_fingerprint IS NOT NULL. Written as an implication, not as a closed disjunction over the enum: a CHECK passes on NULL, andIS DISTINCT FROMis what makes the NULL case explicit rather than accidental. A future enum value therefore does not silently fall into the "must have a fingerprint" branch.chk_backup_run_unsigned_has_no_fingerprint— the converse. Anunsignedrow carrying a fingerprint is a contradiction the resolver could otherwise write during a partial update.chk_backup_run_signature_state_requires_completed— the state is only meaningful once an artifact exists. Aqueuedrow with a signature state is a row somebody wrote out of order.chk_backup_run_signing_key_fingerprint_shape— 8 lowercase hex characters, matching whatBackupKeyStoreServicecomputes. Same reasoning aschk_backup_run_sha256_format: a column that only a program writes still gets a shape constraint, because the program is what gets edited.idx_backup_run_signing_key_fingerprint— partial index onsigning_key_fingerprint WHERE status = 'completed' AND signing_key_fingerprint IS NOT NULL. This is the index the retired-key prune refusal scans, and the partial predicate matches its query exactly; a full index would be larger and would not be used.
All six were probed against a real database in both directions — the row each must reject and a
legitimate row each must accept — by packages/db/src/scripts/probe-0053-backup-signature-state.sql,
which asserts the constraint name rather than only the SQLSTATE. A probe that checks only
23514 passes when a completely different constraint fires.
backup_restore
| Column | Type | Nullable | Default | Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | generated | PK | — |
public_id | uuid | No | uuid7() | unique | — |
source_backup_run_id | integer | No | — | FK → backup_run, ON DELETE RESTRICT | Inert in normal operation — pruning marks rows, never deletes. |
safety_backup_run_id | integer | Yes | — | FK → backup_run, ON DELETE RESTRICT; chk_backup_restore_completed_has_safety (status <> 'completed' OR safety_backup_run_id IS NOT NULL); chk_backup_restore_safety_is_not_source | Written by BackupRestoreProcessor.takeSafetyDump, immediately after the safety dump reaches completed and before spawnRunner is ever called — while the row still exists normally, since pg_restore --clean has not run yet. If that UPDATE matches no row, the restore aborts rather than proceeding without a satisfiable link. BackupRestoreProcessor.finish's later status='completed' write is a separate concern: pg_restore --clean has by then replaced backup_restore with the dump's own pre-restore copy, so that SELECT typically finds no matching row and skips the UPDATE entirely (logged as a warning) — unrelated to whether this column was linked. |
status | backup_restore_status (queued|safety_dump|restoring|verifying|completed|failed) | No | queued | chk_backup_restore_terminal_has_finished_at | Only queued and the two terminal values (completed, failed) are ever persisted here — safety_dump (written by BackupRestoreProcessor.takeSafetyDump), draining, restoring and verifying (written by restore-runner.ts) are all genuinely assigned, but only ever to the state file (RESTORE_STATE_FILE), never this column, because pg_restore --clean replaces the table holding its own row mid-restore. reconciling remains declared but unused anywhere. BackupRestoreProcessor writes the terminal value to this column once the file reports completed/failed. |
maintenance_engaged | boolean | No | false | — | Set true at submit time by BackupRestoreAdminService; disengaged by BackupRestoreProcessor.finish/fail or by force-clear. |
verification | jsonb (BackupRestoreVerification) | Yes | — | — | The declared shape ({ tablesChecked, tablesMatched, mismatches[], excluded[] }) is still never written to this column — restore-runner.ts's exported verifyRowCounts() genuinely compares every manifest table's recorded count against a live count(*) at the new verifying stage, but it reports only a pass/throw, and neither the runner nor BackupRestoreProcessor writes a structured result back into this jsonb column. |
error_message / error_code | text | Yes | — | chk_backup_restore_failed_has_error | Written by BackupRestoreProcessor.fail from whatever the runner reported: BACKUP_RESTORE_VERIFICATION_FAILED if the throw happened during the verifying stage (the restore applied but does not match the manifest's row counts), BACKUP_RESTORE_FAILED for every other stage, or BACKUP_RESTORE_ABANDONED if the heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDS. The runner itself decides which of the first two codes to write, keyed off which stage was active when the exception was thrown. |
requested_by_admin_user_id | uuid | Yes | — | FK → admin_users, ON DELETE SET NULL | — |
requested_by_email | text | No | — | — | Unlike backup_run, always populated — a restore always has an actor. |
heartbeat_at | timestamptz | Yes | — | — | Column, not file. restore-runner.ts heartbeats restore-state.json every 5 seconds, but nothing writes this table column — a sweep-based stall detector for backup_restore rows (the restore analogue of BackupSweepScheduler) cannot exist against this column today. |
runner_pid | integer | Yes | — | — | Same — the live PID is in the state file (BackupRestoreProcessor/restore-runner.ts both write runnerPid there), never in this column. |
started_at / finished_at | timestamptz | Yes | — | chk_backup_restore_timestamps_ordered | — |
created_at / updated_at | timestamptz | No | now() | — | — |
Notable CHECK constraints:
uq_backup_restore_single_active— partial unique index on(true) WHERE status NOT IN ('completed','failed'). Two concurrent restores would racepg_restoreagainst itself;POST .../force-clearis the documented recovery when a row is stuck here.chk_backup_restore_completed_has_safety— a completed restore always records its own rollback.
Retention: backup_run rows are pruned (never deleted) by count-based GFS policy. backup_restore rows are never pruned — a restore is a permanent audit record of a destructive act, and the table is small by construction.
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 BackupPathService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
onModuleInit | Nest lifecycle | env, filesystem | creates root dir (mode 0700) | Refuses boot if BACKUP_DIR is inside a served static root | throws (crashes boot) |
resolveWithinRoot(...segments) | almost every other backup service | — | — | — | throws if the resolved path escapes the root |
buildArtifactDir(publicId, at) | BackupRunHandler, BackupReplicationService | — | — | pure path join <yyyy>/<mm>/<publicId> | — |
freeBytes() | BackupRunCreateService | statfs on the root's filesystem | — | — | — |
6.2 BackupArtifactService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
dumpDatabase({artifactDir, snapshotId}) | BackupRunHandler (inside captureAndRun) | DATABASE_URL, PG_DUMP_PATH | <artifactDir>/database.dump (via .partial + rename) | spawns pg_dump in a new process group; kills the group on dumpTimeoutMinutes timeout | BackupSubprocessError (timeout|authentication|connection|not_found|unknown) |
archiveUploads({artifactDir}) | BackupRunHandler | StorageManager.hasRemoteDriver(), TAR_PATH | <artifactDir>/uploads.tar.gz | spawns tar; returns null under a remote storage driver | same |
sha256File(path) | multiple (download, preflight, replication) | streams the file | — | streaming hash, never buffers the whole file | — |
Input normalization: none needed — every argv value is either a config path or a value this module computed itself (never user input). Transaction boundary: dumpDatabase runs entirely inside the REPEATABLE READ transaction opened by BackupManifestService.captureAndRun, so the exported snapshot stays valid for the dump's full duration. Fail-closed: any subprocess failure deletes the .partial file and rethrows; BackupRunHandler.markFailed stores only the sanitised excerpt.
6.3 BackupManifestService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
captureAndRun(work) | BackupRunHandler | pg_export_snapshot(), version(), drizzle.__drizzle_migrations, every base table's COUNT(*) | — | Opens a REPEATABLE READ tx; runs work(snapshotId) inside it so the export stays valid for pg_dump's whole duration | rethrows anything work throws |
writeManifestFile(dir, file) | BackupRunHandler | — | <dir>/manifest.json (.tmp + rename) | Written before the row is marked completed | — |
reconcileFromDisk() | BackupModule.onModuleInit, restore path (intended), on demand | scans <root>/<yyyy>/<mm>/<publicId>/manifest.json | inserts missing backup_run rows | ON CONFLICT (public_id) DO NOTHING — idempotent | logged and swallowed per-file; never throws at the top level |
reconcileOne re-validates every manifest's artifactDir two ways: containment (resolveWithinRoot) and equality against the directory it was actually found in — a manifest naming a different directory is refused rather than trusted, because the file is operator-editable and the value later reaches an rsync remote-target concatenation elsewhere in the module.
6.4 BackupSettingsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
getEffectiveSettings() | almost every backup service | settings.json (via state-file service) | — | Never throws — falls back to env-seeded defaults on an unreadable or invalid file | — |
updateSettings(input, expectedVersion) | BackupAdminService.updateSettings | current settings | settings.json | Optimistic concurrency: 409 BACKUP_SETTINGS_CONFLICT on a version mismatch | BadRequestException on schema failure, mapped to BACKUP_INVALID_SCHEDULE / BACKUP_REMOTE_NOT_CONFIGURED / BACKUP_SETTINGS_INVALID |
The same Zod schema validates both directions — settings.json is treated as untrusted input by policy, because an operator can hand-edit it.
6.5 BackupRetentionService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
sweep() | BackupPruneHandler (reason: "scheduled") | settings.json (once, for the whole sweep), backup_run per bucket | deletes artifact dirs, updates rows to pruned | Per-row failures logged and skipped — one floor refusing a row must not stop the rest of the sweep | — |
sweepRemoteOnlyLocalCopies() | sweep() | backup_run where storageMode='remote_only' AND replicationStatus='replicated' | deletes only the heavy artifacts, flips localArtifactPresent=false | Never touches manifest.json — artifactDir stays non-null | — |
pruneRun(row) | BackupPruneHandler (reason: "manual"), itself from sweep | — | rm -rf the artifact directory, sets status='pruned', prunedAt, artifactDir=null | No-op if row.status !== "completed" (idempotent against a retried delete) | — |
assertPrunable(row) | admin remove(), worker prune() | backup_run, backup_restore | — | Throws on any of 3 floors: pinned, last-completed-overall, newest-in-own-bucket, or most-recent-restore's-safety-dump | ConflictException (BACKUP_PINNED_CANNOT_DELETE, BACKUP_LAST_REMAINING_CANNOT_DELETE) |
kind: 'uploaded' rows are deliberately absent from RetentionBucket/allBuckets() — the sweep never scans them at all, on top of them being inserted with pinned: true. An operator who carried an archive in during an incident did not do it so the nightly sweep could delete it an hour later; the only path off an uploaded row is an explicit admin DELETE, capped on the way in by BACKUP_UPLOAD_MAX_RETAINED.
6.6 BackupRestorePreflightService
Every check in assertRestoreAllowed, in order: feature-flag + admin-DB-URL configured → application role is not a Postgres superuser → source row exists → both acknowledgements are literally true → typed database name matches current_database() → source status = 'completed' and localArtifactPresent → schema migration tag matches the running application's → recorded size then sha256 match the file on disk → artifact signature. All of this runs before any row is inserted — a rejected restore changes nothing.
The signature step, and why it has four outcomes rather than two
| Condition | Outcome |
|---|---|
The key store is unreadable | REFUSE (BACKUP_KEY_STORE_UNREADABLE). A corrupt key file is not an absent one, and skipping verification because the file is broken is the failure this branch exists to prevent. |
The deployment holds no key at all (hasAnyKey() is false) | Proceed, logging loudly that the restore ran without signature verification. Correct for a deployment that never had a key. |
No artifact.sig beside the dump, and the row's signature_state is not signed | Proceed, logging that none was ever recorded for it. |
No artifact.sig, but the row is recorded as signed | REFUSE (BACKUP_UPLOAD_SIGNATURE_INVALID). The declared adversary is an actor who can write files inside BACKUP_DIR; deleting the .sig would otherwise turn off the only non-circular control, by exactly the actor it exists to stop. signature_state lives in the database, which that actor cannot write. |
| A signature exists and matches a retired key | Proceed, loudly. This is the whole point of keeping a retired set — a rotation must not make every existing backup unrestorable. |
| A signature exists and matches nothing held | REFUSE (BACKUP_UPLOAD_SIGNATURE_INVALID). |
Verification on restore accepts imported keys as well as generated ones (trust: "restore"), because
backup_run.database_dump_sha256 is an independent server-computed anchor the backup directory
cannot forge. Upload verification does not (trust: "upload") — see §6.8.
6.7 MaintenanceService
Three-layer read (in-process cache 1s TTL → Redis → file, the file being authoritative), file-first-then-Redis write. isActive() fails closed on an unreadable/corrupt file (returns true) — the asymmetry is deliberate: absent means open, unreadable means closed, because a corrupt file mid-restore must never be read as "no maintenance."
6.8 BackupSignatureService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
hasCurrentKey() / hasAnyKey() | upload, restore preflight | BackupKeyStoreService | — | Two predicates, not one: signing needs a current key, verifying needs any held key | — |
isStoreUnreadable() | restore preflight, BackupRunHandler | — | — | Separates "no key" from "key file is corrupt" | — |
verifyAgainstHeldKeys(supplied, dump, manifest, trust) | upload, restore preflight | — | — | Tries every candidate key; timingSafeEqual, never === on secret-derived bytes. trust: "upload" excludes imported keys; trust: "restore" includes them | returns { matched, retired, fingerprint }, never throws — a wrong-length signature must be rejected, not turned into a 500 that tells the guesser their length was wrong |
signArtifacts(dir, dumpSha256, manifestSha256) | BackupRunHandler, BackupUploadAdminService.finalize | — | <dir>/artifact.sig | Signs with the CURRENT key only; returns { signed, fingerprint }. On upload it re-signs over the server-generated manifest.json, never the operator's | — |
recordedState({ signed }) | BackupRunHandler, BackupUploadAdminService | — | — | The single owner of the signed / unsigned / NULL decision | — |
heldFingerprints() | BackupAdminService, BackupUploadAdminService | — | — | One key-store read per request, passed to the response mapper as a plain value, so a list of twenty runs is still zero file I/O | — |
isConfigured() was deleted. It answered one question where there are two, and the collapse is
what made "no key held" and "the key file cannot be read" the same branch on the restore path.
recordedState and heldFingerprints exist because two callers each answered the same question
independently and diverged: the scheduled run branched three ways while the upload path used a
two-way ternary that would write unsigned for an unreadable store, and the upload response mapper
passed retired: [] as a literal, rendering a perfectly verifiable archive as "key not held"
whenever a rotation landed between the re-sign and the mapping.
What the signature defends against, stated exactly
It defends against an actor who can write files inside BACKUP_DIR and has nothing else.
It does not defend against an actor who can read files as the API user — that actor holds the
key file itself, and always did. Nor against a superadmin who knows their own password:
reveal-once is a product requirement, so that actor can obtain a valid signing key simply by
rotating. The SQL content scan in backup-archive-content.util.ts is what holds that line, not this
key.
A checksum would authenticate nothing, because the uploader supplies the file and the checksum in the same request — both operands are theirs. A keyed HMAC is what makes one operand the server's.
Known gap: the HMAC covers "<dumpSha256>:<manifestSha256>" and not uploads.tar.gz. A run
taken with includesUploads = true therefore carries an unsigned uploads archive beside a signed
dump. The badge is scoped accordingly — it reads "Signed (database dump)". Extending the input would
change the signature for every future artifact and make every existing .sig incomparable with a
newly computed one, which is its own migration with its own compatibility window.
6.8a BackupKeyStoreService
Owns the file that holds the artifact-signing key, and the lifecycle around it.
| Method | Reads | Writes | Notes |
|---|---|---|---|
onModuleInit | BACKUP_ARTIFACT_HMAC_KEY_PATH, BACKUP_ARTIFACT_HMAC_KEY | the key file | Migrates a legacy env key into the file verbatim, then warns that the variable should be removed. |
currentKey() / retiredKeys() / verificationKeys(trust) | in-memory file | — | verificationKeys("upload") filters out provenance: "imported". |
generate / rotate / import / prune | — | the key file | Each goes through mutate(fn, onCommitted), which rolls the in-memory state back if the audit write fails. |
Why a file, and not the two obvious places. Not the database: a dump would then contain the
key that signs dumps, so anyone holding a dump holds the key. Not settings.json: that resolves
inside BACKUP_DIR, so the key would sit in the directory it protects, and whoever can write a
malicious dump could write the key that signs it.
Boot refuses when the path is not absolute, resolves (via realpath, with a separator guard)
inside BACKUP_DIR or the upload root — both are copied into artifacts and shipped off-host —
or when the file is not mode 0600, or its parent is group/world-writable or not owned by the
process uid. A corrupt file is the one condition that does not refuse the boot: it degrades to
unreadable, because refusing to start is a worse outcome than starting with verification refused.
persist() fsyncs both the file descriptor and the directory. This is the only copy of a retired
key; a rename that survives in the page cache but not a power cut is not good enough.
The migration copies the existing value byte for byte and never generates a fresh key. Every
artifact already on disk is signed with it, and a new key would make them all unverifiable —
surfacing as BACKUP_UPLOAD_SIGNATURE_INVALID, which reads like corruption rather than like a key
change. key is an opaque UTF-8 string and is never hex-decoded: a generated key happens to be
64 hex characters, but a migrated one may be anything an operator typed, and hex-decoding on one
path and not the other would change every HMAC.
6.9 BackupUploadAdminService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
upload(input) | BackupUploadAdminController.upload | settings.json (retained cap), uploaded manifest/signature bytes, PG_RESTORE_PATH | <artifactDir>/database.dump, fresh manifest.json, fresh artifact.sig, backup_run row (kind:'uploaded', status:'completed', pinned:true) | Spawns pg_restore --list (format check) and pg_restore -f - --schema-only (renders SQL for the content scan); records activity (backup.upload) | ServiceUnavailableException, BadRequestException, ConflictException — see Error Handling |
Order of checks in upload(): retained-cap → manifest parse/shape → dump-vs-manifest sha256 (corruption check only) → HMAC signature verify (the actual control) → pg_restore --list format check → schema-SQL content scan (scanArchiveSql) → finalize (move .partial into place, write fresh manifest + signature, insert row). The operator's own manifest.json is never written to disk — only schemaMigrationTag and manifest.tables are read from it in memory — because reconcileFromDisk runs at every boot and would otherwise launder an operator-editable "kind":"manual" claim into the catalog on the next restart. On any exception, the finally block unlinks the .partial dump unless finalize already moved it into place.
7. Runtime Flows
7.1 Manual backup ("Run now")
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | BackupAdminController.create | Accepts CreateBackupDto | DTO validation error (400) |
| 2 | BackupRunCreateService.createRun | Checks backupEnabled, no run in progress, disk space | 503 BACKUP_DISABLED, 409 BACKUP_ALREADY_RUNNING, 503 BACKUP_INSUFFICIENT_DISK_SPACE |
| 3 | Outbox → BackupQueueProcessor → BackupRunHandler.run | Claims the row, dumps, archives, writes manifest, completes | markFailed on any exception; errorMessage stores only the sanitised excerpt |
| 4 | BackupRunHandler (conditional) | Enqueues backup.replicate in the same completion transaction, if storageMode !== "local" and replication is enabled | — |
7.2 Scheduled (nightly) backup
BackupScheduleScheduler registers a CronJob via SchedulerRegistry (not @Cron, because the schedule is admin-editable and must be reloadable without a restart). On each tick, resolveTier assigns the most significant period (month → ISO week → day) with no completed run yet, then calls the same BackupRunCreateService.createRun used by the manual path — kind: "scheduled". A tick that throws is logged and swallowed, never crashing the next tick.
7.3 Retention sweep
BackupRetentionScheduler.enqueueSweep() runs daily at 03:00 (@Cron(CronExpression.EVERY_DAY_AT_3AM)), enqueueing backup.prune {reason:"scheduled"} directly onto QueueName.BACKUP (the documented outbox exemption — a cron tick with no accompanying database write). BackupPruneHandler.prune dispatches to BackupRetentionService.sweep().
7.4 Stall recovery
BackupSweepScheduler runs every 5 minutes and marks any backup_run stuck running past dumpTimeoutMinutes + 5 as failed (using idx_backup_run_stalled), and any replicationStatus = 'pending' past BACKUP_REPLICATE_TIMEOUT_MINUTES + 5 as failed. Direct database writes, not BullMQ jobs — nothing to enqueue, only a row to fix.
7.5 Run a manual restore
The execution logic lives beside its supervisor: workers/restore-runner.ts (moved from an earlier apps/api/scripts/ location — the build is nest build --builder swc with sourceRoot: "src", so anything outside src/ was never emitted, and the first version of this wiring spawned a script that could not exist in dist/; see the file's own docblock). It is a detached process, deliberately not a BullMQ handler: pg_restore --clean holds ACCESS EXCLUSIVE on every table including admin_sessions, which JwtStrategy reads on every authenticated request, so a worker running inside the API would block on its own authentication and could be killed mid-restore by an orchestrator. Its own entrypoint is guarded by require.main === module, so importing the module (e.g. from a spec, to reuse verifyRowCounts) never triggers a restore. Run as node dist/modules/backup/workers/restore-runner.js <restorePublicId>, it:
- Reads
restore-state.jsonforstate.sourceArtifactDir— written byBackupRestoreAdminService.requestRestoreat submit time (source.artifactDir ?? ""). - Fences the application's DB role with
ALTER ROLE "<appRole>" CONNECTION LIMIT 0(notALTER DATABASE— a database-level limit is not in a single-database dump and would survive the restore, locking the site out after success). This stops new connections; it does nothing to ones already open. - Drains before terminating: polls
pg_stat_activityfor the application role's remaining connections every second, heartbeatingrestore-state.json("Waiting for N connection(s) to close") so the supervisor does not abandon a restore that can legitimately take minutes here. The window isBACKUP_RESTORE_DRAIN_SECONDS— admin-editable asrestoreDrainSeconds, injected into the child process's own environment byspawnRunnerfrom the effective settings at spawn time (see below), not read from the raw environment. Whatever is still connected when the window closes is terminated withpg_terminate_backend— a courtesy with a deadline, not a negotiation, sincepg_restore --cleanneedsACCESS EXCLUSIVEand will not get it while a session holds a lock. - Runs
pg_restore --clean --if-exists --single-transaction --no-owner --no-privilegesagainstBACKUP_ADMIN_DATABASE_URL, with a timeout fromBACKUP_RESTORE_TIMEOUT_MINUTES— same admin-editable/env-injected pattern as the drain window (restoreTimeoutMinutes).--single-transactionis load-bearing — probed and confirmed: restoring a truncated dump with it left the database untouched; without it the table was emptied and the original rows were gone. - Moves to
stage:"verifying"and calls the exportedverifyRowCounts(root, sourceDir, adminUrl): readsmanifest.json'smanifest.tables(the source backup's own recorded per-table counts) and, for every table it names except those inRESTORE_VERIFICATION_EXCLUDED_TABLES(admin_sessions,customer_sessions,backup_run,backup_restore,outbox_events,drizzle_migrations), runsSELECT count(*)::bigint FROM public."<table>"against the just-restored database and compares. The exclusion exists because the restore itself changes those tables afterpg_restorereturns — it truncates the session tables, neutralises the outbox, and rebuilds its own catalog rows — andbackup_runin particular is guaranteed to differ, because the pre-restore safety dump inserts a row after the source dump's manifest was captured; comparing them would fail verification on every successful restore, the exact inversion of what the check is for. Table names are validated against/^[a-z_][a-z0-9_]*$/before interpolation into the query — they come from a file on disk in the backup root, not a bound parameter, so an unexpected identifier is refused rather than queried. Any mismatch, or an unreadable/empty manifest, throws — which the outercatchreports asBACKUP_RESTORE_VERIFICATION_FAILEDspecifically (see below), not the generic failure code. - Heartbeats
restore-state.jsonevery 5 seconds (stage,runnerPid,heartbeatAt) throughout, soGET .../livecan show real progress. - Releases the connection fence before publishing
stage:"completed"— the supervisor queries the database the instant it reads that stage, and a still-fenced role would refuse that query and record a successful restore as a failure. Thefinallyblock also unconditionally releases, on every other exit path.
The catch block distinguishes two error codes by which stage was executing at the moment of the throw — failedDuring === "verifying" gets BACKUP_RESTORE_VERIFICATION_FAILED (the restore applied but does not match the manifest's row counts — the more dangerous outcome, because the database is populated but wrong), anything else gets BACKUP_RESTORE_FAILED. Both are read at the throw site in restore-runner.ts's main() catch block, not guessed from behavior.
Before any of this runs, BackupRestoreProcessor.takeSafetyDump takes an inline pre-restore safety dump and links it to the restore. Called from process() immediately before spawnRunner() (only on the branch where no runner is already alive — an attach to an existing runner skips it, since the dump for that restore was already taken on the original attempt), it calls the same BackupRunCreateService.createRun used by manual and scheduled backups, with kind: "pre_restore_safety" and the new enqueue: false (CreateBackupRunInput.enqueue?: boolean, defaults true). Before the dump runs, it writes the new row's publicId into restore-state.json as safetyBackupPublicId — deliberately before, not after, so a crash mid-dump still tells an operator which half-written artifact to look for. Then, instead of letting the outbox dispatcher pick the job up minutes later, it calls BackupRunHandler.run() directly, inline, in the same process, and waits for it. This is deliberate, not a shortcut: enqueuing would return immediately and let the runner start draining connections and running pg_restore --clean while the safety dump was still mid-snapshot — the two would race, and the "safety" dump could capture a half-destroyed database. takeSafetyDump then re-reads the backup_run row by its publicId and requires status === "completed" before proceeding; BackupRunHandler.run swallows its own failures into the row rather than throwing, so the absence of a thrown exception proves nothing — the row is the evidence. If the dump insert throws, the run throws, or the row does not reach completed, takeSafetyDump calls fail() and returns false, process() returns without ever spawning the runner, and maintenance stays engaged — proceeding without a safety dump is judged worse than the extra downtime.
Once the dump is confirmed completed, takeSafetyDump writes backupRestores.safetyBackupRunId onto the restore's own row — UPDATE backup_restore SET safety_backup_run_id = <safety dump's id> WHERE public_id = <restorePublicId>. If that update matches no row, takeSafetyDump fails the restore rather than proceeding with a link that cannot be recorded: without it, the restore would still run to completion and then fail on the chk_backup_restore_completed_has_safety CHECK at the very last write — the database already replaced, the work already done, the row stuck reporting something other than success. This same write is what makes BackupRetentionService.isMostRecentRestoreSafetyDump's retention floor actually fire — the column, the FK, the CHECK constraint and the retention rule were all already in place; only the write was missing. There is still no one-click "undo" — restoring the safety dump back requires an operator to find it (via safetyBackupPublicId on RestoreLiveStateDto, or by browsing the catalog) and submit it as the source of a second, ordinary restore request.
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | assertRestoreAllowed | 8 sequential guards, all before any write | 503 BACKUP_RESTORE_DISABLED, 409 BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER, 400 BACKUP_RESTORE_CONFIRMATION_MISMATCH, 409 BACKUP_NOT_RESTORABLE, 410 BACKUP_ARTIFACT_MISSING, 409 BACKUP_RESTORE_SCHEMA_VERSION_MISMATCH, 409 BACKUP_SIZE_MISMATCH / BACKUP_CHECKSUM_MISMATCH |
| 2 | assertNoActiveRestore / assertNoRunInFlight | Refuses fast rather than letting a partial-unique-index collision surface as a raw 23505 | 409 BACKUP_RESTORE_ALREADY_ACTIVE, 409 BACKUP_RESTORE_RUN_IN_PROGRESS |
| 3 | insert + outbox enqueue, same tx | Row and job commit together or neither does | — |
| 4 | state file + maintenance engage | Happen outside the transaction, in this order, deliberately | A crash between transaction commit and maintenance engaging leaves a brief window of live customer writes — accepted cost, documented in the service |
| 5 | BackupRestoreProcessor.takeSafetyDump | Inline createRun({kind:"pre_restore_safety", enqueue:false}) + inline BackupRunHandler.run(), waited on synchronously; then UPDATE backup_restore SET safety_backup_run_id | Restore aborted, fail() called, maintenance stays engaged, runner never spawned — either if the dump does not complete, or if the linking UPDATE matches no row |
| 6 | BackupRestoreProcessor.spawnRunner → restore-runner.ts main() | Fences DB role, drains existing connections (up to restoreDrainSeconds) before terminating them, runs pg_restore --clean --single-transaction (up to restoreTimeoutMinutes), verifies row counts excluding RESTORE_VERIFICATION_EXCLUDED_TABLES, heartbeats, unfences, writes terminal stage | verifying-stage throw → BACKUP_RESTORE_VERIFICATION_FAILED; any other stage → BACKUP_RESTORE_FAILED; supervisor writes BACKUP_RESTORE_ABANDONED if the heartbeat goes stale |
| 7 | BackupRestoreProcessor.finish/fail | Reads the terminal stage, updates backup_restore if the row still exists, disengages maintenance | Maintenance is not lifted on failure — an operator must look before customers return |
7.6 Force-clear a stuck restore
POST admin/system/restores/{publicId}/force-clear marks the row failed (with BACKUP_RESTORE_ABANDONED) and disengages maintenance — recovery from a runner that never spawned, was killed, or is still running but an operator wants out regardless. It does not check that the row is actually stuck (no age threshold) and does not stop a still-running pg_restore process — it only clears the row and maintenance flag, so calling it while restore-runner.ts is genuinely mid-restore leaves the subprocess to finish or fail on its own with nothing left tracking it.
7.7 Live restore polling
GET admin/system/restores/{publicId}/live reads only restore-state.json — never a table — so it stays answerable while pg_restore --clean holds ACCESS EXCLUSIVE on every table during an active restore. Returns 410 GoneException (BACKUP_RESTORE_NOT_FOUND) if the file is absent or names a different restore.
7.8 Download
The three gates, and why each is necessary
A response can only carry an error while nothing has been written to it. Everything fallible
therefore happens before the first setHeader, and for a spawned process that means waiting for its
first byte. Each gate below exists because the previous one was measured to be insufficient.
| Gate | When | Catches | Why the earlier design missed it |
|---|---|---|---|
| 1. First byte | before any header | spawn failure, bad argv, produced-nothing | pipe does not forward a source error, so the response hung with headers set and no status |
| 2. Timeout kill | before any header | a tar that neither errors nor produces | killChild hangs off stdout's close, which a thrown timeout never triggers — every timeout leaked a process |
| 3. Exit code | after the stream ends | a member deleted mid-transfer | tar writes a structurally valid archive and then exits non-zero. A clean stream end is not a successful process |
Gate 1 must call read() and unshift() the chunk back, not merely listen for readable. Node
emits readable at EOF as well, so a bare listener proceeds to headers and answers 200 with an
empty body; a bare read() without the unshift silently drops the first 64 KB.
Gate 3 is what makes a partial artifact observable. Content-Length is deliberately absent on this
route — the tar is built on the fly — so the response is chunked, and a chunked response whose
terminating chunk never arrives is a truncated transfer that every HTTP client reports as an error.
On a non-zero exit the response is destroyed, never ended.
Without gate 3 the fix would have been worse than the defect: a visible hang replaced by a silent,
structurally valid backup with no database.dump in it, delivered over 200 with an audit row
saying success.
The uploads route needs none of this. It streams a finished file with a known Content-Length, so a
short transfer is already detectable, and there is no child process whose status could disagree with
the bytes.
Two-phase audit
The row written before streaming is pending, not success. When the transfer settles, a
companion row is appended — backup.download.completed / .failed, and likewise for
backup.download_uploads.
Appended rather than updated, because auditLogSchema sets updatedAt: false: an in-place update
would record no finish time at all, and "when did this artifact finish leaving this host" is the
question the trail exists to answer. It also keeps the collection write-once.
The initial write stays blocking and fails closed — an audit failure still refuses the download.
The companion write is best-effort: by the time it runs, bytes are on the wire and there is no
status left to send. A pending row with no companion means the transfer did not complete cleanly,
which is the conservative reading and the correct one.
7.9 Off-host replication
Enqueued by BackupRunHandler in the same transaction as run completion, when storageMode !== 'local' and remoteReplicationEnabled. BackupReplicationHandler.replicate → BackupReplicationService.replicate: recomputes the relative artifact directory from publicId + createdAt (never trusts artifact_dir, which originates in an operator-editable file) and validates it against BACKUP_REMOTE_RELATIVE_DIR_PATTERN; pushes the dump/archive via rsync (--secluded-args, excluding manifest.json); pulls the dump back to a temp dir and hashes it locally (never trusts a remote-reported hash); pushes manifest.json last, only once the heavy artifacts are verified. On any failure the row is marked replicationStatus='failed' — a failed replication does not fail the backup itself.
7.10 Upload and catalogue an operator-supplied archive (SE-4)
This endpoint never restores anything — it only catalogues bytes an operator already has (from a download of this deployment, or another one whose key this deployment generated) so the existing, unchanged restore path (BackupRestoreAdminController/BackupRestoreAdminService/BackupRestorePreflightService) can act on the resulting row exactly like any other completed backup. The multipart body carries three required parts (file, manifest, signature) via BackupUploadStorageEngine, a custom Multer storage engine that streams the dump straight to a .partial file on the backup volume — hashing as it writes — rather than buffering a multi-gigabyte upload in memory; the small manifest/signature parts are buffered.
The plain sha256 comparison against the manifest is a corruption check only — both operands arrive in the same request from the same actor, so it authenticates nothing about provenance. The HMAC signature over "<dumpSha256>:<manifestSha256>", keyed by an environment variable the uploader cannot read or write, is the actual control. The schema-SQL content scan is defence in depth on top of that: pg_restore --list's table of contents records an object's type, not its properties, so it cannot tell an ordinary trigger function from a SECURITY DEFINER one — rendering the schema SQL and scanning the text can.
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | assertRetainedCapNotReached | Counts kind='uploaded' AND status='completed' rows | 409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHED |
| 2 | parseManifest | Requires schemaMigrationTag and databaseDumpSha256 as non-empty strings | 400 BACKUP_UPLOAD_MANIFEST_INVALID |
| 3 | assertNotCorrupted | Lowercase-normalised sha256 comparison | 409 BACKUP_CHECKSUM_MISMATCH |
| 4 | BackupSignatureService.verify | timingSafeEqual HMAC comparison; isConfigured() checked first | 503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED, 409 BACKUP_UPLOAD_SIGNATURE_INVALID |
| 5 | runPgRestoreList + renderArchiveSql + scanArchiveSql | Format check, then schema-only SQL render, then construct scan | 409 BACKUP_ARCHIVE_UNREADABLE (format), 409 BACKUP_UPLOAD_TOC_REJECTED (dangerous construct) |
| 6 | finalize | Move dump into place, write fresh manifest + signature, insert row | — |
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
system:flag=maintenance | CacheKeyUtil.build("system:", [["flag","maintenance"]]) | MaintenanceState JSON | No Redis TTL set; refreshed on every engage/disengage | Explicit set() on every engage/disengage; in-process copy expires after MAINTENANCE_CACHE_TTL_MS (1s) | MaintenanceService |
This is the module's only cache key. It exists to avoid a Redis round trip on every request through MaintenanceGuard (a global guard); the file remains authoritative and Redis failures fall through to it silently.
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.BACKUP | backup.run | BackupRunCreateService (outbox, both manual + scheduled) | BackupQueueProcessor → BackupRunHandler | { backupRunPublicId } | 3 attempts (queue-level default) | Claim is queued OR running -> running; a retry can re-claim a crashed attempt. Not idempotent against a successful re-run — claim guard prevents double-execution. |
QueueName.BACKUP | backup.prune | BackupAdminService.remove (outbox, manual) / BackupRetentionScheduler (direct, scheduled cron exemption) | BackupQueueProcessor → BackupPruneHandler | { reason: "scheduled" | "manual", backupRunPublicId? } | 3 attempts | Idempotent by construction — an already-pruned row's status guard makes a repeat a no-op. |
QueueName.BACKUP_RESTORE | backup.restore | BackupRestoreAdminService.requestRestore (outbox) | BackupRestoreProcessor (concurrency: 1, maxStalledCount: 0) → spawns/supervises restore-runner.ts | { restorePublicId } | BULL_QUEUE_BACKUP_RESTORE_ATTEMPTS = 1 (env-configured defaultJobOptions, same as before) | Attaches to an already-alive runner (isAlive(runnerPid)) rather than double-spawning; maxStalledCount: 0 stops BullMQ's stalled-job recovery from re-running this handler and spawning a second pg_restore after an API restart mid-restore. |
QueueName.BACKUP_REPLICATE | backup.replicate | BackupRunHandler (outbox, same tx as run completion) | BackupReplicateQueueProcessor → BackupReplicationHandler | { backupRunPublicId } | BULL_QUEUE_BACKUP_REPLICATE_ATTEMPTS = 3 (default) | replicate() is a no-op if replicationStatus === 'replicated' already. |
Schedulers (@nestjs/schedule, in-process cron — not queue jobs themselves):
| Scheduler | Cadence | Responsibility |
|---|---|---|
BackupScheduleScheduler | Admin-editable (scheduleCron/scheduleTimezone, default 0 2 * * * Asia/Kathmandu) via SchedulerRegistry, not @Cron | Creates the nightly backup_run row with GFS tier assigned at insert. |
BackupRetentionScheduler | EVERY_DAY_AT_3AM | Enqueues backup.prune {reason:"scheduled"} directly (cron exemption). |
BackupSweepScheduler | EVERY_5_MINUTES (×2 cron handlers) | Fails stalled running dumps and stalled pending replications. |
10. Realtime and Events
None. The module emits no Socket.IO/WebSocket events. The nearest analogue is the outbox aggregateType/eventType pairs used purely for internal enqueue delivery (backup_run.requested, backup_run.prune_requested, backup_run.replication_requested, backup_restore.requested), which are not broadcast anywhere — they exist only to carry a job onto a BullMQ queue.
11. Security, Auth, and Abuse Controls
- Guards: every admin controller carries
@UseGuards(JwtAuthGuard, RoleGuard, IpThrottlerGuard). The one exception isMaintenanceCustomerController(GET /system/maintenance), which carries onlyIpThrottlerGuardand is explicitly@Public()— the single public/guest-reachable route in this module. - The public maintenance-status route is deliberately narrow:
MaintenanceCustomerServicemaps the shared maintenance state ontoMaintenanceStatusDto, which carries onlyactiveandreason— neverengagedBy(an operator's display name) orengagedAt(which would tell an unauthenticated caller exactly how long the database has been offline, the one fact that distinguishes a routine restore from an incident). It is also the one route named literally inMAINTENANCE_EXEMPT_PATHS(/api/system/maintenance) precisely because it must stay answerable through the same maintenance window it reports on — that is not a hole, since it discloses nothing beyond what every other route already shows by answering503. - Permissions:
Backup_READ/CREATE/UPDATE/DELETE/RESTORE,BackupDownload_READ,BackupConfigure_UPDATE,BackupUpload_CREATE,BackupKey_READ/CREATE/UPDATE/DELETE,System_READ/UPDATE(maintenance).Backup,BackupDownload,BackupConfigure,BackupUploadandBackupKeyare all inSUPERADMIN_ONLY_MODULES— theadminrole never holds them, onlysuperadmin.BackupKeyis split fromBackupConfigurebecause the latter changes dump frequency and retention counts, while the former replaces the credential every artifact is authenticated against and hands the caller plaintext key material. - Two independent restore gates:
Backup_RESTORE(a grantable permission, superadmin-only) andBACKUP_RESTORE_ENABLED(a deploy-time environment switch). A permission can be granted by mistake; an environment variable cannot be granted from inside the running application. - Upload is authenticated by a keyed HMAC, not a checksum:
BackupSignatureService.verifyAgainstHeldKeyscomparesHMAC-SHA256(key, "<dumpSha256>:<manifestSha256>")withtimingSafeEqual, against every key held for that trust level. A plain sha256 comparison against an uploader-supplied manifest would authenticate nothing, since both operands arrive in the same request from the same actor — kept only as a corruption check. - Upload content is screened before it is catalogued:
scanArchiveSqlrenders the archive's schema SQL (pg_restore -f - --schema-only) and refuses constructs a restore must not execute, becausepg_restore --list's table of contents records an object's type, not its properties, and cannot distinguish aSECURITY DEFINERfunction from an ordinary trigger function. - Rate limits:
ADMIN_READ(list/detail/settings-read, 30/min IP-keyed),ADMIN_WRITE(pin/prune/settings-write, 10/min IP-keyed),ADMIN_ASYNC_JOB_SUBMIT(manual run, 10/hour IP-keyed),ADMIN_BACKUP_DOWNLOAD(3/hour, user-keyed — an IP-keyed cap on the endpoint that streams every customer record and password hash would be bypassed by rotating IPs),ADMIN_BACKUP_UPLOAD(3/hour, user-keyed, same reasoning),ADMIN_RESTORE_SUBMIT(2/day, user-keyed, matchingAUTH_DAILY_IRREVERSIBLE). - Symlink containment is two controls covering two different path components, not one with a backup. The artifact open uses
O_RDONLY | O_NOFOLLOW, so a symlink at the artifact path is refused atomically withELOOP— there is no window between a check and a use, because there is no separate check. Measured:O_NOFOLLOWguards only the final component; a symlinked parent directory is followed silently and the open succeeds. SoresolveExistingDirWithinRootresolves the directory prefix throughrealpathand re-asserts containment on the resolved value. Neither control is redundant with the other. A path-only check would be decorative here regardless:reconcileOneinsertsstatus,includesUploads,databaseDumpSha256anduploadsArchiveBytesverbatim from an operator-editablemanifest.json, so the same actor supplies both the symlink and the integrity values that would otherwise constrain it. The residual — a directory component swapped between therealpathand the open — is accepted with its reason and recorded, since closing it needs a component-wiseopenatwalk Node does not expose. - An admin route cannot ship without a permission, and this is enforced twice.
RoleGuardreturnstruewhen a handler declares none, which is correct for the customer surfaces that legitimately have none — and means an admin handler shipped without@Permissionswas reachable by any logged-in retail customer, since customer sessions authenticate through the sameJwtAuthGuard. The guard now denies that case for admin surfaces, selected from the controller's ownPATH_METADATA(array declarations, leading slashes and a controller merely namedadministrationare all handled). A structure spec enforces the same property in CI over Nest's real reflected route table, and additionally checks thatRoleGuardis present in the resolved guard chain —@Permissionsis inert without it, and a decoration that reads as enforcement is worse than a missing one. A@Public()handler underadmin/must be explicitly allowlisted rather than passing automatically, because@Public()removes the globalJwtAuthGuardand is strictly weaker than a missing permission. Both mechanisms are mutation-tested; the two exemption lists are deliberately duplicated rather than shared, and a test fails if they drift. - Path containment: every filesystem path this module ever touches is either constructed by
BackupPathServiceitself or re-validated throughresolveWithinRootbefore use — including values read back out of an operator-editablemanifest.jsonorsettings.json. - Subprocess argv discipline: every
spawncall uses an argv array (never a shell string); credentials go in the child's environment (PGPASSWORD), never argv, which is world-readable viaps; every binary path is absolute and env-configured, sincespawnresolves a bare command throughPATHat call time. - rsync/ssh transport:
--secluded-args(asserted at boot) plus a recomputed remote path (neverartifact_dir) — without both, an operator-editable string reaches the remote login shell.StrictHostKeyChecking=yesagainst a boot-time-validatedknown_hostsfile replaces trust-on-first-use on the channel carrying the whole database. - Stderr redaction:
sanitizeSubprocessExcerptstrips paths,user@host, IPs and tab-delimitedCOPYdata rows before anything reaches a database column or an HTTP response; raw text still reaches the server log only. - Filename injection: the downloaded file's
Content-Dispositionfilename is derived frompublicIdalone, never from the operator-editablenotefield, which would otherwise be a CRLF-injection vector into the response header. - Superuser fence check:
assertApplicationRoleIsNotSuperuser(preflight) andrestore-runner.ts's ownALTER ROLE ... CONNECTION LIMITfence — PostgreSQL does not enforcerolconnlimitagainst a superuser role, so the connection fence would silently do nothing if the application connected as one; the preflight check is what actually stops that path before a restore is ever accepted. - Maintenance guard fail-closed: deny-by-default — a handler is exempt only via an explicit literal-path allowlist or by being admin-authenticated; every other
@Public()route is blocked while maintenance is engaged, closing the gap an earlier prefix-based design left open for payment gateway callbacks. Maintenance is disengaged only byBackupRestoreProcessor.finish/failorforce-clear— never automatically by the runner itself, since the runner has no database access to the app's own state once the fence is in place. - A password step-up on every key mutation:
generate,import,rotateandpruneeach re-check the operator's password before acting. A session token proves the session, not the person, and these four are the operations that hand out or destroy key material. The limiter for them is user-keyed (ADMIN_KEY_MATERIAL_WRITE, 5 per 15 min), never IP-keyed — an IP-keyed cap on a password check is bypassed by rotating IPs, and an IP-keyed lockout is a denial of service against the operator. - Key material appears in exactly two responses, both at creation time (
generate,rotate). There is no endpoint that returns an existing key, and neither route carries@IdempotentCreate—IdempotencyInterceptorpersistsresponseJsonto Redis for 24 hours, which would park a plaintext signing key in a store an operator may snapshot. - The forwarded client address is not a rate-limit input.
TrustedClientIpInterceptoracceptsx-internal-client-iponly alongside a matchingINTERNAL_CLIENT_IP_TOKEN, and the resulting address reaches the activity log and nothing else. Wiring it intoIpThrottlerGuardwas considered and rejected: it would let an unauthenticated attacker choose their own bucket on the admin credential path, which is a brute-force bypass, not an improvement. Trust is bound to the token rather than to Express's numerictrust proxybecause a hop count does not bind to the peer at all — measured against Express 5.2.1,req.ipbecomes whatever the caller sent. - Two download routes, one permission, two rate-limit buckets.
BackupDownload_READcovers both the database dump and the uploads archive: an actor holding it can already stream the entire database, so the uploads archive is a strictly smaller disclosure inside the same trust boundary. They do NOT share a budget —IpThrottlerGuardkeys on`throttle:ip:${ControllerClass}:${handlerName}:${principal}`, so the constant names a policy and the handler is the bucket. Per-operator capacity is 3+3 per hour, and refusals consume it because the guard runs before the handler. - A missing
@Permissionsdecorator is a hole, not a default.RoleGuardreturnstruewhen no permission is declared, and customer sessions pass the sameJwtAuthGuard, so an admin handler without the decorator is reachable by any logged-in customer.pnpm ruleshas no permission auditor and the structure snapshot is a change detector, sotest/structure/backup-download-permissions.spec.tsasserts the expected value for these two routes rather than the expected diff. - Sensitive fields never in a response:
artifactDir(a filesystem path), checksums, both integer PKs, and key material never appear inBackupResponseDto,RestoreLiveStateDtoorBackupKeyStateResponseDto.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
BACKUP_NOT_FOUND | 404 | BackupRetentionService.getRowByPublicId, BackupRestorePreflightService.loadSource | No row for that publicId | Verify the id |
BACKUP_ALREADY_RUNNING | 409 | BackupRunCreateService.assertNoRunInProgress | A row is already queued or running | Wait and retry |
BACKUP_DISABLED | 503 | BackupRunCreateService.createRun | settings.backupEnabled === false | Enable backups in settings |
BACKUP_INSUFFICIENT_DISK_SPACE | 503 | BackupRunCreateService.assertSufficientDiskSpace | Free disk below minFreeDiskMb + 1.2x last dump | Free disk or raise the floor |
BACKUP_ARTIFACT_MISSING | 410 | preflight, download, retention | Recorded artifact absent from disk, or reclaimed to remote | Fetch it back, or accept it is gone |
BACKUP_CHECKSUM_MISMATCH / BACKUP_SIZE_MISMATCH | 409 | preflight, download | File on disk does not match the recorded sha256/bytes | Investigate corruption; do not trust the artifact |
BACKUP_NOT_RESTORABLE | 409 | preflight, download | Source status !== 'completed' | Choose a completed backup |
BACKUP_DOWNLOAD_AUDIT_FAILED | 500 | BackupDownloadAdminService.recordDownloadOrRefuse | The AuditLog.create() write itself failed | Retry; the download is refused rather than left unaudited |
BACKUP_PINNED_CANNOT_DELETE / BACKUP_LAST_REMAINING_CANNOT_DELETE | 409 | BackupRetentionService.assertPrunable | One of the 3 retention floors | Unpin, or accept it cannot be deleted |
BACKUP_DUMP_FAILED / BACKUP_DUMP_TIMED_OUT | (stored on the row, not an HTTP response) | BackupRunHandler.markFailed | pg_dump/tar failed or exceeded dumpTimeoutMinutes | Inspect the server log for the raw error; the row only carries a sanitised excerpt |
BACKUP_RESTORE_DISABLED | 503 | preflight | BACKUP_RESTORE_ENABLED=false or no BACKUP_ADMIN_DATABASE_URL | Arm the deploy-time switch |
BACKUP_RESTORE_CONFIRMATION_MISMATCH | 400 | preflight | Acknowledgements not both true, or typed database name mismatch | Re-confirm carefully |
BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER | 409 | preflight | Application's own DB role has rolsuper | Downgrade DATABASE_URL's role |
BACKUP_RESTORE_SCHEMA_VERSION_MISMATCH | 409 | preflight | Backup's schemaMigrationTag differs from (or is unknown vs.) the running app's | No override — restore a compatible backup |
BACKUP_RESTORE_ALREADY_ACTIVE / BACKUP_RESTORE_RUN_IN_PROGRESS | 409 | assertNoActiveRestore / assertNoRunInFlight | Another restore, or a backup run, is in flight | Wait |
BACKUP_RESTORE_NOT_FOUND | 410 | getLiveState/forceClear | No live-state file, or it names a different restore | Restore is not in progress |
BACKUP_RESTORE_ABANDONED | (stored, not thrown as an HTTP response) | forceClear, BackupRestoreProcessor.supervise | Operator manually cleared a stuck restore, or the runner's heartbeat went stale past BACKUP_RESTORE_ABANDON_SECONDS | — |
BACKUP_RESTORE_FAILED | (stored, not an HTTP response) | restore-runner.ts main()'s catch (writes to the state file for any failure NOT during the verifying stage), BackupRestoreProcessor.fail (default errorCode when the runner reports failure without one), BackupRestoreProcessor.takeSafetyDump (safety-dump insert/run/completion failure aborts the restore under this same code) | pg_restore, the DB-role fence, the pre-restore safety dump, or any other non-verification step throws | Inspect the server log; maintenance stays engaged |
BACKUP_RESTORE_VERIFICATION_FAILED | (stored, not an HTTP response) | restore-runner.ts main()'s catch, specifically when failedDuring === "verifying" | verifyRowCounts() found at least one table whose live count(*) does not match the source manifest's recorded count, after pg_restore itself succeeded | The database is populated but wrong — more dangerous than a failed restore. The pre-restore safety dump is already in the catalog; maintenance stays engaged for an operator to investigate before deciding whether to restore the safety dump instead |
BACKUP_ARCHIVE_UNREADABLE | 400/409 | BackupUploadAdminController.upload (missing file part), BackupUploadAdminService.runPgRestoreList | The uploaded dump is missing, or pg_restore --list cannot read it as a PostgreSQL archive | Re-export the dump and retry |
BACKUP_UPLOAD_MANIFEST_INVALID | 400 | BackupUploadAdminService.parseManifest | Manifest part missing/not JSON/not an object, or missing schemaMigrationTag/databaseDumpSha256 | Re-upload with the manifest downloaded alongside the archive |
BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED | 503 | BackupUploadAdminService.upload | This deployment holds no current signing key | Generate one at POST admin/system/backup-key/generate |
BACKUP_UPLOAD_SIGNATURE_INVALID | 400/409 | BackupUploadAdminController.upload (missing part), BackupSignatureService.verifyAgainstHeldKeys (mismatch), BackupRestorePreflightService (a row recorded signed with no .sig on disk, or a signature matching nothing held) | Signature part missing, the HMAC does not match this archive+manifest, or a recorded signature has been removed from disk | The archive was not produced by a deployment holding one of this system's keys — or its signature file was deleted, which is the shape the control exists to catch |
BACKUP_UPLOAD_TOC_REJECTED | 409 | BackupUploadAdminService.upload | scanArchiveSql found a construct a restore must not execute | Remove the flagged object from the source database before re-dumping |
BACKUP_UPLOAD_RETAINED_LIMIT_REACHED | 409 | BackupUploadAdminService.assertRetainedCapNotReached | BACKUP_UPLOAD_MAX_RETAINED completed uploads already exist | Delete an uploaded backup before uploading another |
BACKUP_REPLICATION_FAILED / _VERIFY_FAILED / BACKUP_REMOTE_UNREACHABLE / BACKUP_REMOTE_DISK_FULL | (stored on the row) | BackupReplicationService.replicate | Transport, read-back verification, or capacity failure | Inspect replicationFailureStreak in settings response |
BACKUP_SETTINGS_CONFLICT | 409 | BackupSettingsService.updateSettings | version mismatch | Reload and retry |
BACKUP_INVALID_SCHEDULE / BACKUP_SETTINGS_INVALID / BACKUP_REMOTE_NOT_CONFIGURED | 400 | updateSettings | Cron/timezone invalid, generic field invalid, or storageMode selected without a configured remote | Fix the field named in the message |
BACKUP_KEY_PATH_NOT_CONFIGURED | 422 | BackupKeyAdminService.assertPathConfigured | BACKUP_ARTIFACT_HMAC_KEY_PATH is unset | Set it and restart before any key operation |
BACKUP_KEY_STORE_UNREADABLE | 422 / 409 | BackupKeyAdminService, BackupRestorePreflightService | The key file exists and cannot be parsed | Repair the file. Verification is refused, never skipped |
BACKUP_KEY_ALREADY_EXISTS | 409 | generate, import | A current key is already held, or that fingerprint is already in the set | Use rotate, or nothing |
BACKUP_KEY_NOT_FOUND | 404 / 422 | rotate, prune | No current key to rotate, or no retired key with that fingerprint | Generate one, or check the fingerprint |
BACKUP_KEY_FINGERPRINT_UNKNOWN | 409 | rotate, prune | confirmFingerprint does not match the target | Re-read the panel and retry |
BACKUP_KEY_STILL_IN_USE | 409 | prune | Completed backups still carry the fingerprint, or some completed run is still unresolved | Run resolve-signatures; then decide deliberately, with force |
BACKUP_KEY_INVALID_MATERIAL | 422 | import | Shorter than BACKUP_KEY_MIN_IMPORT_LENGTH | Supply the whole key |
BACKUP_KEY_REAUTH_REQUIRED | 400 | every mutating key route | currentPassword absent | Distinct from a wrong password on purpose, so the panel can say "you left it blank" |
BACKUP_KEY_REAUTH_FAILED | 401 | every mutating key route | Password did not match | Re-enter it |
BACKUP_KEY_NO_PASSWORD_SET | 409 | every mutating key route | The admin account has no password (OAuth-only) | Set a password before managing keys |
BACKUP_UPLOADS_ARCHIVE_NOT_INCLUDED | 409 | BackupDownloadAdminService.prepareUploadsDownload | The run archived no uploaded files, so there is no second artifact | Take a new backup with uploads included. 409, not 404 — the backup exists and its database half downloads fine |
SYSTEM_MAINTENANCE_ACTIVE | 503 | MaintenanceGuard | Maintenance engaged and the caller is not exempt | Retry later, or authenticate as an admin |
Every code in this table has a throw site. BACKUP_RESTORE_EXTENSION_NOT_OWNED was previously
declared with none — reserved for extension-ownership verification that was never implemented — and
has been removed. pnpm rules:error-codes checks registration, not use, so a code that is
validated and unreachable looks load-bearing and is not; the gate cannot see it, and a reader
cannot tell the difference without grepping.
13a. Audit Retention
audit_logs carried one collection-wide 90-day TTL on createdAt, for every module. The record of
which backup artifact left this host is the answer to the only question worth asking after a
disclosure incident, and those investigations routinely begin later than ninety days.
Retention is now per document. expiresAt carries a TTL index with expireAfterSeconds: 0,
stamped by a pre("validate") hook on the schema so a writer cannot omit it.
Scoped by action, not by module. The long window applies to actions beginning
backup.download — which covers both download routes and both of their .completed / .failed
companions. Scoping by module: "backup" would have been wrong: seven sites carry that tag,
including the key-custody service, which passes metadata through wholesale with signing-key
fingerprints in it. Two years is justified for "which artifact left this host", not for everything
the module happens to log.
| Variable | Default | Applies to |
|---|---|---|
AUDIT_RETENTION_DAYS | 90 | everything else |
AUDIT_RETENTION_DAYS_BACKUP_DOWNLOAD | 730 | actions matching the backup.download prefix |
Both are read in apps/api and injected into @skoolsewa/mongodb; the schema package never reads
process.env. That is not stylistic — rules:env-orphans scans apps/api only, so a variable
consumed in the package would be declared-and-unread and fail the gate.
Why the old index had to be deleted from the schema, not just dropped
A document is removed on the earliest deadline any TTL index asserts — measured: a document with
expiresAt a year in the future was deleted by a createdAt TTL. So the old index makes the new
field inert.
Dropping it at runtime is not enough on its own. createMongoConnection sets autoIndex: true, so
mongoose rebuilds every declared index at model init: leaving the declaration in place would bring
the index straight back, on that boot or the next, and the whole change would be a no-op with
every gate green. The declaration is deleted; the runtime dropIndex exists only to clean up
servers that already hold it on disk.
The cost of that, stated: the old index did not care what a document contained, so a row written
without expiresAt still died at 90 days. Such a row is now retained forever. Every writer today
goes through AuditLog.create(), so the hook fires; a future writer using collection.insertOne or
an upsert without setDefaultsOnInsert would bypass mongoose middleware entirely. The guarantee is
"every writer that goes through the model", not "every writer".
AuditLogRetentionMigration
An OnModuleInit provider in MongoDBModule, ordered so a crash at any point leaves the old
index in place — nothing is ever retained for less time than it is today:
- create the
expiresAtindex - verify it exists, and abort if not
- backfill
expiresAt = createdAt + retention(action)in bounded batches - only then drop
createdAt_1, toleratingIndexNotFoundfor the concurrent-boot race
Model.syncIndexes() is the obvious instrument and is wrong twice over: it calls cleanIndexes
before createIndexes, so it passes through a state with no TTL index at all, and it drops every
index absent from the schema rather than a named one.
The backfill pages on an _id cursor. Using modifiedCount === 0 as the terminator works only while
the filter is { expiresAt: { $exists: false } }; on a re-stamp it returns the same first batch
forever, and a version that did so stamped 5,000 of 12,000 rows and dropped the index anyway.
An incomplete backfill now skips the drop and does not write the marker, so the next boot resumes.
A marker document in its own audit_retention_state collection holds a hash of the retention
configuration. An unchanged boot is a no-op; a changed one re-stamps every row, which is what keeps
a retention reduction effective rather than forward-only. The marker is deliberately not in
audit_logs — it would be subject to the very TTL it governs.
14. Observability
| Signal | Location | Purpose |
|---|---|---|
Logger (module-scoped, [backup]-prefixed messages throughout) | Every service and worker | Failure and state-change visibility; the only place raw (unredacted) subprocess stderr is ever written. |
this.logger.warn on RESTORE ACCEPTED | BackupRestoreAdminService.requestRestore | Deliberately warn, not log — a restore request is the highest-consequence write this module accepts. |
replicationFailureStreak / lastReplicationFailureAt | BackupAdminService.getSettings (computed from backup_run on each read) | The answer to "is off-site copying still working" for a remote_only deployment — explicitly built because "it logs" was judged not an answer on its own. |
Activity log (activity table via ActivityRecordService) | backup.create, backup.pin/unpin, backup.delete_requested, backup.settings_update, backup.restore.requested, backup.upload, maintenance.engage/disengage | Admin-facing audit trail; fire-and-forget, never blocks the response. |
AuditLog (MongoDB, via AuditLog.create) | backup.download only | The one write in this module that is not fire-and-forget — a failed audit write refuses the download itself. |
| BullMQ / Bull Board | QueueName.BACKUP, QueueName.BACKUP_RESTORE, QueueName.BACKUP_REPLICATE (all in REGISTERED_QUEUES) | Job-level success/failure visibility for dumps, prunes, restores, and replication transfers. |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | admin/catalog/backup-admin.service.spec.ts, admin/download/backup-download-admin.service.spec.ts, admin/upload/backup-upload-admin.service.spec.ts, shared/backup-artifact.service.spec.ts, shared/backup-archive-content.util.spec.ts, shared/backup-manifest.service.spec.ts, shared/backup-path.service.spec.ts, shared/backup-replication.service.spec.ts, shared/backup-restore-preflight.service.spec.ts, shared/backup-retention.service.spec.ts, shared/backup-run-create.service.spec.ts, shared/backup-settings.service.spec.ts, shared/backup-signature.service.spec.ts, shared/backup-subprocess-excerpt.util.spec.ts, shared/backup-tier.util.spec.ts, shared/maintenance.guard.spec.ts, shared/maintenance.service.spec.ts, workers/backup-run.processor.spec.ts, workers/backup-schedule.scheduler.spec.ts, workers/backup-sweep.scheduler.spec.ts | Service methods and edge cases, colocated per the repo convention. |
| Coverage/dispatch specs | workers/backup-job-coverage.spec.ts, workers/backup-replicate-job-coverage.spec.ts, workers/backup-restore-wiring.spec.ts | Assert every BackupJob/BackupReplicateJob member has a registered handler (the single-worker-per-queue rule), and that QueueName.BACKUP_RESTORE has exactly one @Processor. |
| Integration/E2E | Not present in this module's own directory | No *.e2e-spec.ts or *.int-spec.ts under apps/api/src/modules/backup/. restore-runner.ts itself (a standalone script spawning real pg_restore) has no automated test — it is exercised only by its supervisor's specs and by the archive-content/signature unit specs around it. |
Validation commands: pnpm --filter api test (unit), pnpm --filter @skoolsewa/api test:structure to regenerate structure.baseline.json after a route change, pnpm --filter @skoolsewa/api permissions:sync after adding a permission module.
16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
BackupAdminController | Controller | List/create/settings/detail/pin/prune HTTP surface | BackupAdminService | HTTP | Service | — | DTO validation (400), permission (403) |
BackupAdminService | Service | Pagination, response mapping, settings orchestration, replication health | BackupRunCreateService, BackupRetentionService, BackupSettingsService, BackupScheduleScheduler, OutboxService, ActivityRecordService | Controller | DB, activity log | backup_run reads/writes | — |
BackupDownloadAdminController / Service | Controller/Service | Streaming download with pre-stream integrity checks | BackupRetentionService, BackupPathService, BackupArtifactService | HTTP | filesystem, AuditLog | none written beyond audit | 404/409/410/500 |
BackupRestoreAdminController / Service | Controller/Service | Restore request/accept, live polling, force-clear | BackupRestorePreflightService, BackupStateFileService, MaintenanceService, OutboxService | HTTP | DB (tx), state file, maintenance | backup_restore, state files | see 7.5 |
BackupUploadAdminController / Service | Controller/Service | Catalogue an operator-supplied, HMAC-verified archive | BackupPathService, BackupArtifactService, BackupManifestService, BackupSignatureService, scanArchiveSql | HTTP | filesystem, subprocess (pg_restore --list/-f -), DB, activity log | backup_run (kind:'uploaded') | see 7.10 |
MaintenanceAdminController / Service | Controller/Service | Site-wide kill switch | MaintenanceService | HTTP | state file, Redis | maintenance state | — |
MaintenanceCustomerController / Service | Controller/Service | The one public read: is the store refusing traffic | MaintenanceService | HTTP (public, unauthenticated) | state file, Redis (read-only) | none | — (never throws; always returns a status) |
BackupPathService | Provider | Path root, containment | env, filesystem | almost everything | filesystem | — | throws at boot on unsafe BACKUP_DIR |
BackupArtifactService | Provider | pg_dump/tar spawn + hashing | env, StorageManager, BackupSettingsService | run/download/preflight/replication | child process, filesystem | writes artifact files | BackupSubprocessError |
BackupManifestService | Provider | Snapshot capture, manifest.json, reconcile | DB, BackupPathService | BackupRunHandler, BackupModule boot | DB tx, filesystem | backup_run (reconcile insert) | logged per-file, never throws overall |
BackupSettingsService | Provider | settings.json schema/read/write | env, BackupStateFileService | nearly everything | filesystem | settings.json | never throws on read |
BackupRetentionService | Provider | GFS sweep, prune, floors | DB, BackupPathService, BackupSettingsService | BackupPruneHandler, admin service, download service | DB, filesystem | backup_run | ConflictException on floors |
BackupRunCreateService | Provider | Shared insert+enqueue for manual/scheduled | DB, BackupSettingsService, BackupPathService, OutboxService | admin create, BackupScheduleScheduler | DB tx, outbox | backup_run, outbox_events | 503/409 |
BackupReplicationService | Provider, OnModuleInit | rsync push + verified read-back | env, BackupPathService | BackupReplicationHandler | child process (rsync/ssh), DB | backup_run replication columns | boot-time throw if misconfigured; row marked failed on transport error |
BackupRestorePreflightService | Provider | All pre-insert restore guards | DB, env, BackupPathService, BackupArtifactService | BackupRestoreAdminService | DB, filesystem | none (read-only) | 8 distinct exception types |
BackupSignatureService | Provider | Keyed HMAC sign/verify | BackupKeyStoreService | BackupUploadAdminService, BackupRunHandler, BackupRestorePreflightService, BackupSignatureResolverService | filesystem (artifact.sig) | writes artifact.sig on sign | never throws — verifyAgainstHeldKeys returns a result object |
BackupKeyStoreService | Provider | Owns the key file | env (path), filesystem | BackupSignatureService, BackupKeyAdminService | the key file (fsynced, mode 0600) | migrates a legacy env key at boot | refuses boot on an unsafe path or mode; degrades to unreadable on a corrupt file |
BackupUploadAdminService | Provider | SE-4 catalogue-an-upload flow | BackupPathService, BackupArtifactService, BackupManifestService, BackupSignatureService, scanArchiveSql, ActivityRecordService | BackupUploadAdminController | DB, filesystem, subprocess, activity log | backup_run insert | ServiceUnavailableException/BadRequestException/ConflictException |
scanArchiveSql (backup-archive-content.util.ts) | Pure util | Refuses dangerous SQL constructs in an uploaded schema | none (pure) | BackupUploadAdminService | — | — | returns violations array; caller throws |
BackupStateFileService | Provider | Atomic read/write for 3 state files | BackupPathService | maintenance, restore, settings services | filesystem | 3 JSON files | throws on unreadable/malformed (except settings, which the caller wraps) |
MaintenanceService | Provider | 3-layer maintenance read/write | BackupStateFileService, Redis | MaintenanceGuard, admin service, restore service | filesystem, Redis, in-process cache | maintenance state | fails closed on unreadable file |
MaintenanceGuard | APP_GUARD | Deny-by-default customer-traffic block | MaintenanceService, Reflector | every HTTP request in the app | — | — | ServiceUnavailableException |
BackupTierUtil | Pure util | GFS calendar boundaries | none (pure) | BackupScheduleScheduler | — | — | throws on Intl failure only |
BackupSubprocessExcerptUtil | Pure util | stderr redaction | none (pure) | artifact + replication services | — | — | — |
BackupQueueProcessor | @Processor | Sole worker on QueueName.BACKUP | BackupRunHandler, BackupPruneHandler | BullMQ | dispatch | — | throws loudly on unknown job name |
BackupRunHandler | Dispatched (not @Processor) | backup.run execution | manifest/artifact/settings/outbox services | BackupQueueProcessor | DB tx, filesystem, child process | backup_run | markFailed |
BackupPruneHandler | Dispatched | backup.prune execution | BackupRetentionService | BackupQueueProcessor | DB, filesystem | backup_run | rethrows |
BackupReplicateQueueProcessor | @Processor | Sole worker on QueueName.BACKUP_REPLICATE | BackupReplicationHandler | BullMQ | dispatch | — | throws on unknown job name |
BackupReplicationHandler | Dispatched | backup.replicate execution | BackupRetentionService, BackupReplicationService | BackupReplicateQueueProcessor | DB, filesystem, child process | backup_run replication columns | rethrows |
BackupScheduleScheduler | OnModuleInit scheduler | Nightly cron + tier assignment | DB, BackupSettingsService, SchedulerRegistry, BackupRunCreateService | Nest lifecycle, BackupAdminService.updateSettings (reload) | DB, SchedulerRegistry | none directly (delegates insert) | logged and swallowed per tick |
BackupRetentionScheduler | Cron | Daily sweep enqueue | BullMQ queue | Nest scheduler | queue add | none | logged on enqueue failure |
BackupSweepScheduler | Cron (×2) | Stall recovery | DB, env | Nest scheduler | DB | backup_run status/replication | — |
BackupRestoreProcessor | @Processor(QueueName.BACKUP_RESTORE) | Sole worker on QueueName.BACKUP_RESTORE; takes and links the inline pre-restore safety dump, then spawns and supervises the runner with settings-derived timeout/drain values | BackupStateFileService, MaintenanceService, BackupSettingsService, Database, BackupRunCreateService, BackupRunHandler | BullMQ | inline BackupRunHandler.run() (safety dump), spawns detached process (env carries restoreTimeoutMinutes/restoreDrainSeconds), polls state file, DB | backup_run (pre_restore_safety insert, via BackupRunHandler), backup_restore (safety_backup_run_id link, then terminal status) | Aborts the restore (fail()) if the safety dump does not reach completed, or if the linking UPDATE matches no row; fails the row on a missing/mismatched state file, or an abandoned heartbeat |
restore-runner.ts (workers/) | Standalone script, not a Nest provider (lives beside its supervisor, not under apps/api/scripts/ — see its own docblock for why that location silently failed); entrypoint guarded by require.main === module | Fences the app DB role, drains existing connections before terminating them, pg_restore --clean --single-transaction, verifies row counts against the manifest excluding RESTORE_VERIFICATION_EXCLUDED_TABLES (verifyRowCounts, exported), heartbeats restore-state.json, always unfences | BACKUP_ADMIN_DATABASE_URL, DATABASE_URL, restore-state.json, source manifest.json, env-injected BACKUP_RESTORE_TIMEOUT_MINUTES/BACKUP_RESTORE_DRAIN_SECONDS (from settings, via spawnRunner) | BackupRestoreProcessor.spawnRunner | pg_restore subprocess, pg Client direct queries (fence, drain poll, and per-table count(*)) | live database (via pg_restore), restore-state.json | Writes stage:"failed" with BACKUP_RESTORE_VERIFICATION_FAILED if the throw happened during verifying, BACKUP_RESTORE_FAILED otherwise. |
16.2 UML and Architecture Diagram Pack
16.3 Code Flow Narrative
See Section 7 for the full per-flow narrative and branch table — flows 7.1 (manual backup), 7.2 (scheduled backup), 7.5 (restore request), 7.8 (download) and 7.9 (replication) each include a numbered step table with code location, behavior and failure case, satisfying this section's requirement without duplicating it here.
16.4 Data Layer Deep Dive
Covered in full in Section 5. Money units: not applicable — this module has no monetary fields. Timezone: BACKUP_SCHEDULE_TIMEZONE (default Asia/Kathmandu, UTC+05:45) governs GFS tier-boundary computation only; every stored timestamp is timestamptz. JSON schema examples: BackupManifest ({tables: Record<string, number>, postgresVersion, schemaMigrationTag, capturedAt}), BackupRestoreVerification ({tablesChecked, tablesMatched, mismatches[], excluded[]} — declared, never populated by any current code path).
Index rationale:
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
uq_backup_run_single_running | (true) | partial unique | At most one running dump at a time (backstop behind worker concurrency:1) | Trivial write overhead |
idx_backup_run_status_created_at | status, created_at | btree | Admin list filtering/sorting | Standard |
idx_backup_run_stalled | started_at | partial (WHERE status='running') | BackupSweepScheduler's stall query | Small, since running rows are rare |
idx_backup_run_retention | kind, tier, created_at | partial (WHERE status='completed') | GFS sweep's per-bucket ordering | — |
uq_backup_restore_single_active | (true) | partial unique (WHERE status NOT IN (completed,failed)) | At most one restore in flight | Recovery requires force-clear if the row is orphaned |
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
Only one backup queued or running at a time | uq_backup_run_single_running (backstops running) + assertNoRunInProgress (also refuses a second queued) | A dump saturates disk I/O on a single VPS; a second request while one is merely queued (not yet claimed by a worker) must be refused too, not just a second running one | BACKUP_ALREADY_RUNNING | backup-run-create.service.spec.ts |
| Scheduled rows always carry a tier; manual/safety never do | chk_backup_run_tier_matches_kind | Tier assigned at insert, not completion (unknowable-at-insert alternative would fail every scheduled row) | DB constraint violation (should never surface — assigned correctly by the scheduler) | backup-schedule.scheduler.spec.ts |
| A completed row always has a usable artifact reference | chk_backup_run_completed_has_artifact | Disjunction makes remote_only representable without lying about completed | DB constraint | — |
| Local artifact absent only once remote is verified | chk_backup_run_local_absent_only_when_replicated | A pending/failed transfer must never justify deleting the only copy | DB constraint | backup-retention.service.spec.ts |
| Newest-of-bucket / last-overall / most-recent-restore's-safety-dump never pruned | BackupRetentionService.assertPrunable, bound identically in sweep() and manual delete | An earlier design let one path delete every backup down to zero | BACKUP_LAST_REMAINING_CANNOT_DELETE, BACKUP_PINNED_CANNOT_DELETE | backup-retention.service.spec.ts |
| A restore's schema tag must match the running app's | assertSchemaVersionMatches | pg_restore --clean replaces the migrations table; a mismatch means new code against an old schema | BACKUP_RESTORE_SCHEMA_VERSION_MISMATCH | — |
| Application DB role must not be a superuser to restore | assertApplicationRoleIsNotSuperuser | rolconnlimit fencing is a no-op against a superuser | BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER | — |
Restore requires two literal-true acknowledgements | assertAcknowledged | The confirmation dialog is a speed bump; the server check is the control | BACKUP_RESTORE_CONFIRMATION_MISMATCH | — |
| Maintenance fails closed on an unreadable file | MaintenanceService.isActive | A corrupt file mid-restore must never read as "no maintenance" | traffic blocked (503) | maintenance.service.spec.ts |
| Maintenance blocks by default; exempt only by literal path or admin actor | MaintenanceGuard | A prefix-based exemption previously let a payment gateway callback write during a restore | SYSTEM_MAINTENANCE_ACTIVE | maintenance.guard.spec.ts |
| Replicated artifact directory must match the recomputed, pattern-checked path | BackupReplicationService.replicate | artifact_dir is operator-editable; trusting it would let rsync hand a crafted path to the remote shell | thrown BackupReplicationError before any rsync call | backup-replication.service.spec.ts |
| An uploaded archive is authenticated by a keyed HMAC, never a plain checksum | BackupSignatureService.verify | Both a checksum and the file it hashes are uploader-controlled in the same request; only an environment-held key proves provenance | BACKUP_UPLOAD_SIGNATURE_INVALID | backup-signature.service.spec.ts |
| Uploaded archive schema SQL must not contain a refused construct | scanArchiveSql | pg_restore --list's TOC cannot distinguish an ordinary trigger function from a SECURITY DEFINER one; rendering and scanning the SQL can | BACKUP_UPLOAD_TOC_REJECTED | backup-archive-content.util.spec.ts |
At most BACKUP_UPLOAD_MAX_RETAINED completed uploaded backups exist | BackupUploadAdminService.assertRetainedCapNotReached | Uploaded rows are pinned and excluded from GFS pruning, so without a cap the feature could fill the volume and disable every future backup | BACKUP_UPLOAD_RETAINED_LIMIT_REACHED | — |
| At most one restore-runner process runs per restore | BackupRestoreProcessor.isAlive check + maxStalledCount: 0 | BullMQ's stalled-job recovery is a separate budget from attempts; without disabling it, an API restart mid-restore would re-run the handler and spawn a second pg_restore | — (defence in depth, not a thrown error) | — |
A pre-restore safety dump must reach completed before pg_restore is ever invoked | BackupRestoreProcessor.takeSafetyDump (inline createRun + inline BackupRunHandler.run, then a row status check) | Proceeding without a way back trades a few minutes of downtime for an unrecoverable mistake | BACKUP_RESTORE_FAILED, restore aborted, maintenance stays engaged | — |
A restore's post-pg_restore state must match the source manifest's row counts | verifyRowCounts at the verifying stage | --single-transaction only guarantees the database-level outcome is all-or-nothing, not that every table matches what was dumped | BACKUP_RESTORE_VERIFICATION_FAILED | — |
A table name from a manifest must match /^[a-z_][a-z0-9_]*$/ before it is interpolated into a query | verifyRowCounts | The manifest is a file on disk in the backup root, not a bound parameter — an unexpected identifier is refused rather than queried | Table reported as a mismatch (refusing to query an unexpected identifier), not queried at all | — |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Catalog storage | Where does the backup index live | Postgres table + independently-authoritative manifest.json per run | Database table only | A catalog living only in the database it backs up is worthless in the disaster it exists for; pg_restore --clean replaces the row mid-restore anyway | Two sources of truth to keep consistent; reconcileFromDisk is the reconciler | If reconcile ever needs to run more than at boot/on-demand |
| Retention policy | Time-based expires_at vs. count-based GFS | Count-based, evaluated at sweep time | Write-time expires_at (the v1 design) | expires_at never expired manual backups and conflated 3 meanings in one column | Sweep must scan and order per bucket rather than a single indexed WHERE expires_at < now() | — |
| Uploads archive default | Ship uploads with every dump, or make it opt-in | Opt-in, defaulting false | Always include | Roughly doubles steady-state disk on a single VPS | An admin restoring a database-only backup gets broken images with no error — explicit tradeoff documented in the feature doc | If disk becomes cheap relative to restore-completeness risk |
| Settings storage | settings.json vs. a database table | File under the backup root | Database table | A restore (pg_restore --clean) would silently revert every policy field, including the replication destination | Requires its own atomic-write discipline and its own Zod validation on every read | — |
| Restore state | Database column vs. file | File (restore-state.json) | Database column | The table holding the restore's own row is itself replaced by pg_restore --clean | The live-poll endpoint reads a file rather than a table, by design | — |
| Storage mode granularity | Per-run column vs. read-time settings lookup | Recorded per run (storage_mode) | Look up current settings at read time | History stays readable after an operator later changes the setting | Slight duplication between settings and each row | — |
| Verification transport for replication | Local read-back hash vs. remote-computed hash | Local read-back | ssh … sha256sum on the remote | Remote computation asks the remote to grade its own homework, and is incompatible with a write-only deploy key | Costs a full re-download during verification | If deploy keys are ever given read access |
| Restore execution | In-process BullMQ handler vs. a detached, supervised subprocess | Detached process (restore-runner.ts) spawned by BackupRestoreProcessor | An in-process @Processor running pg_restore directly | pg_restore --clean holds ACCESS EXCLUSIVE on every table including admin_sessions; a worker inside the API would block its own authentication and could be killed mid-restore by an orchestrator redeploying the pod | Two processes to reason about; state lives in a file, not a table, because pg_restore --clean replaces the table too | If a future deploy model guarantees no redeploy can interrupt a long-running in-process job |
| Upload authenticity | Keyed HMAC vs. plain checksum | HMAC-SHA256 over dumpSha256:manifestSha256, keyed by a key held in the app-owned key file | Comparing the file against an uploader-supplied checksum | A checksum authenticates nothing when both operands arrive in the same request from the same actor; a key the server alone holds is the only thing that changes that | Operators must provision and protect one more secret, and it must match across the deployment that produced the archive and the one accepting it | If a PKI-based signing scheme is ever needed for multi-party provenance |
| Pre-restore safety dump: inline and synchronous vs. enqueued | Inline, awaited call to BackupRunHandler.run() from inside BackupRestoreProcessor, before spawnRunner | Enqueue backup.run onto QueueName.BACKUP and wait for it to complete | Enqueuing returns immediately; the runner could start draining connections and running pg_restore --clean while the safety dump was still mid-snapshot — the two would race, and the dump could capture a half-destroyed database | Blocks the restore job's own processing until the safety dump finishes; a slow safety dump delays the actual restore | If the safety dump ever needs to run against a different, non-QueueName.BACKUP execution path | |
| Post-restore verification: row counts vs. full checksum comparison | count(*) per manifest-listed table | Re-hash or diff full table contents | Counting is cheap and catches the failure mode that matters most (a pg_restore that silently dropped or truncated rows); a full content diff would be a second, much slower pg_dump-scale operation immediately after the first | Does not catch a row that changed value without changing row count | If corruption-without-count-change is ever observed in practice |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Backup dump | GET admin/system/backups, server log [backup] lines, Bull Board on QueueName.BACKUP | Rows reach completed with non-null databaseDumpSha256 | Row stuck queued/running past dumpTimeoutMinutes, or status='failed' | BackupSweepScheduler self-heals a stalled running row every 5 minutes; a failed row can be retried by requesting a new manual backup |
| Retention | GET .../settings for retain* counts, server log retention sweep pruned N run(s) | Bucket counts hold near their configured retain* values | A bucket never shrinking (floors blocking every candidate) | Check pinned flags and the 3-floor logic in assertPrunable; unpin if appropriate |
| Replication | GET .../settings → remoteConfigured, lastReplicationFailureAt, replicationFailureStreak | replicationFailureStreak: 0 | Non-zero streak, or remoteConfigured: true with a growing streak | Check server log for the sanitised rsync/ssh excerpt; verify known_hosts pinning and key validity out of band |
| Maintenance | GET admin/system/maintenance | active: false outside a restore | active: true with no restorePublicId and no operator recalling engaging it | PUT admin/system/maintenance {active:false} |
| Restore | GET admin/system/restores/{publicId}/live, server log [backup] restore ... lines, Bull Board on QueueName.BACKUP_RESTORE | Stage advances safety_dump → draining → restoring → verifying → completed; maintenance disengages | heartbeatAt older than BACKUP_RESTORE_ABANDON_SECONDS, stage:"failed" (generic), or errorCode:"BACKUP_RESTORE_VERIFICATION_FAILED" specifically (restore applied but row counts do not match) | POST .../force-clear to release maintenance/the row; a pre-restore safety dump is always in the catalog by the time pg_restore has run — RestoreLiveStateDto.safetyBackupPublicId names it directly, so submit a fresh restore against that id if the outcome needs undoing; no automated retry exists (attempts:1) |
| Upload | POST .../backups/upload response, server log [backup] uploaded and catalogued ... | 201 with a kind:'uploaded' row, immediately status:'completed' | 409/400/503 — see Error Handling | Re-export a fresh dump+manifest+signature from a deployment whose key this one generated |
| Public maintenance status | curl /api/system/maintenance (no auth required) | {active:false, reason:null} outside a restore/maintenance window | active:true when no restore or operator maintenance window is expected | Cross-check GET admin/system/maintenance (the admin view) for restorePublicId/engagedBy to find the cause; this endpoint itself carries no diagnostic fields by design |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
No post-restore verification of anything beyond row counts. verifyRowCounts genuinely runs at the new verifying stage and compares every manifest-listed table's count(*), and a mismatch is reported as BACKUP_RESTORE_VERIFICATION_FAILED — but it checks only counts, not content. A pg_restore that silently dropped and re-inserted different rows in the same quantity, or altered --no-owner/--no-privileges-affected permissions, would report identical counts and pass. reconciling also remains a declared Stage value the runner never assigns, and backup_restore.verification (the structured {tablesChecked, tablesMatched, mismatches[], excluded[]} shape) is never written to that column even though the comparison itself now happens. | Restore | A restore that reaches completed is trusted on row counts alone, not on row-level content or on permissions. | --single-transaction makes the database-level outcome all-or-nothing; row-count verification is a real, meaningful check on top of that, just not an exhaustive one. | A structured, persisted verification result (writing to the verification jsonb column) and any content-level (not just count-level) check are both unbuilt. |
backup_restore.heartbeat_at / runner_pid (the table columns) are never written | Restore | A sweep-based stall detector for backup_restore rows (the analogue of BackupSweepScheduler for backup_run) cannot be built against this table, because nothing populates it — the live heartbeat exists only in restore-state.json | BackupRestoreProcessor's own in-memory supervision loop (BACKUP_RESTORE_ABANDON_SECONDS) already detects a stalled runner without needing the column; force-clear is the manual fallback | No cross-restart or cross-process visibility into staleness from the database alone — only from the file, or from Bull Board showing the job still active |
| Declared but unthrown error code | Restore | BACKUP_RESTORE_EXTENSION_NOT_OWNED exists in the registry with no current throw site | None needed today | Reserved for extension-ownership verification that has not been implemented |
| A crash between the restore transaction committing and maintenance engaging | Restore | A brief window of live customer writes to a database about to be replaced | Documented and accepted in requestRestore's own comments | Inherent to the ordering choice; not revisited |
force-clear does not stop a genuinely running pg_restore | Restore | Calling force-clear while restore-runner.ts is still mid-restore clears the row/maintenance flag but leaves the subprocess running unsupervised — it will still finish or fail, but nothing is tracking it any more | None — the endpoint's own docs note it does not check the row is actually stuck | An operator must confirm the runner process is actually gone (or wait it out) before treating the site as safely back online |
| No one-click "undo a completed restore" | Restore | Restoring the safety dump back requires an operator to manually locate it in the catalog and submit a second, ordinary restore request against it — there is no dedicated "roll back to the pre-restore safety dump" action | The safety dump exists and is a valid restore source like any other completed backup | A guided rollback flow (which would also need the linkage fix above to auto-select the right safety dump) is unbuilt |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime.
- Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
- Every method with business behavior has a code-flow narrative (Section 6/7).
- Every table/collection/cache object/job payload has field-level detail.
- Every index, constraint, relation, and delete behavior has rationale.
- Every lifecycle/status transition is covered (Section 5, 7; full state diagram in the features/flows doc).
- Every read/write/action/job flow has a sequence diagram and branch notes.
- Every business invariant is cataloged.
- Every cache key, invalidation path, queue job, and external call is documented.
- Every architectural tradeoff is documented with alternatives and revisit triggers.
- Every operational failure mode has a runbook entry, including the still-missing safety-dump/restore linkage and the row-count-only scope of post-restore verification.
18. Backend Completion Checklist
- Module boundaries are documented.
- Every controller, service, DTO, schema file, job, cache key, and event is covered.
- Every database table has a field table and relationship diagram.
- Every runtime flow has a diagram and branch notes.
- API, feature/flows, and TDD docs are linked.
- No claim is made without a source file or documented source reference — the safety dump and row-count verification that now exist, and the linkage/content-verification gaps that remain, are both stated explicitly rather than assumed or invented.
See Also
- API doc:
/docs/developer/backup/api - Features and flows doc:
/docs/developer/backup/feature - TDD: not present for this module at time of writing.