Backup Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for backup.
Backup Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Backend | /docs/developer/backup/backend, and directly: every service under apps/api/src/modules/backup/shared/, admin/*/, workers/*.ts, including workers/backup-restore.processor.ts and workers/restore-runner.ts | Service behavior, side effects, persistence, state machines, and exactly what the restore executor does and does not do. |
| API | /docs/developer/backup/api, and directly: admin/*/*.controller.ts files | Route surface, actors, auth, response-visible behavior. |
| Schema | packages/db/src/schema/backup/*.ts | Enums, constraints, and the floors retention enforces. |
| Route ground truth | apps/api/test/structure/structure.baseline.json | Confirmed the 15 routes documented here: 14 admin, plus the public GET /api/system/maintenance. |
| Env | apps/api/src/config/env.validation.ts | Defaults for schedule, retention counts, timeouts, and the restore/replication/upload switches (BACKUP_ARTIFACT_HMAC_KEY_PATH, BACKUP_UPLOAD_MAX_MB, BACKUP_UPLOAD_MAX_RETAINED, INTERNAL_CLIENT_IP_TOKEN). |
2. Feature Summary
| Field | Value |
|---|---|
| Module | backup |
| Submodule | N/A |
| Primary user value | An operator can prove the database is being protected, recover disk space automatically, get an off-site copy without babysitting it, catalogue an archive from another deployment, and genuinely put the database back the way it was after a mistake — with an automatic pre-restore safety dump and post-restore row-count verification, though without automatic linkage back to that safety dump yet (see 5.4). A customer/guest actor can also learn whether the store is currently offline. |
| Actors | Superadmin (every admin route requires superadmin-level permissions), worker/system (cron schedulers, BullMQ processors, the detached restore-runner process), and guest/customer (the one public route). |
| Main entry points | admin/system/backups* (including .../upload) and admin/system/restores* HTTP routes; BackupScheduleScheduler's nightly cron; BackupRetentionScheduler's daily cron; BackupSweepScheduler's 5-minute cron. |
| Main outputs | backup_run/backup_restore rows, manifest.json/settings.json/restore-state.json/maintenance.json files, a streamed pg_dump download, an off-host rsync copy. |
| Related docs | API, Backend. |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
| Superadmin | List/create/pin/prune/download backups; read and write backup settings; request a restore; poll live restore state; force-clear a stuck restore | Anything as a regular admin account — Backup, BackupDownload and BackupConfigure are all withheld from admin | Admin JWT + Backup_*/BackupDownload_READ/BackupConfigure_UPDATE | The only human actor this module recognises. |
| Admin (regular) | Nothing directly in this module's backup surfaces | Everything above | — | Sees 403 on every admin/system/backups* and admin/system/restores* route. |
| Admin (any, via maintenance) | Read and toggle site-wide maintenance | — | Admin JWT + System_READ/System_UPDATE | Maintenance is gated on System_*, not a Backup_* permission, and not on Settings_* either — deliberately, so no content administrator can take the storefront offline or disengage maintenance mid-restore. |
| Worker/system (schedulers) | Create a scheduled backup nightly; enqueue the daily retention sweep; fail stalled runs and stalled replications every 5 minutes | Skip the same guards a human request would hit — the scheduled path shares BackupRunCreateService and BackupRetentionService with the admin-triggered path | In-process cron (SchedulerRegistry/@Cron), no HTTP | — |
| Worker/system (BullMQ) | Execute backup.run, backup.prune, backup.replicate, backup.restore (the last by taking an inline safety dump, then spawning and supervising the detached runner) | Link the safety dump back to the restore it protects, or verify anything beyond row counts | BullMQ worker (concurrency: 1 per queue) | See 5.5 and 5.4 for what each queue actually does. |
Restore runner (workers/restore-runner.ts) | Fence the app DB role, run pg_restore --clean --single-transaction, verify row counts against the manifest at the verifying stage, heartbeat a state file, unfence | Take the safety dump itself (that is the processor's job, before the runner is even spawned), or verify anything beyond row counts | Detached process (setsid), spawned by BackupRestoreProcessor on QueueName.BACKUP_RESTORE | See 5.4. |
| Guest / customer | Check whether the store is currently in maintenance (GET /api/system/maintenance) | Anything else — every other route in this module requires superadmin | None — @Public() | The one customer-facing route in this module; deliberately narrower than the admin maintenance DTO. See 5.7. |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| List backups | Admin | Superadmin | GET admin/system/backups | backup_run | — | API 8.1 |
| Run a manual backup | Admin | Superadmin | POST admin/system/backups | settings, disk free space | backup_run, outbox_events | API 8.2 |
| Read backup settings | Admin | Superadmin | GET admin/system/backups/settings | settings.json, env, backup_run (health) | — | API 8.3 |
| Update backup settings | Admin | Superadmin | PUT admin/system/backups/settings | current settings | settings.json, possibly reloads the cron job | API 8.4 |
| View backup detail | Admin | Superadmin | GET admin/system/backups/{publicId} | backup_run | — | API 8.5 |
| Pin / unpin a backup | Admin | Superadmin | PATCH admin/system/backups/{publicId} | backup_run | backup_run.pinned | API 8.6 |
| Prune a backup now | Admin | Superadmin | DELETE admin/system/backups/{publicId} | backup_run, backup_restore (floors) | outbox_events, later backup_run + filesystem | API 8.7 |
| Download a backup | Admin | Superadmin | GET admin/system/backups/{publicId}/download | backup_run, filesystem | MongoDB AuditLog (must succeed) | [API 8.8](/docs/developer/backup/api#88-get-apiadminsystembackupspublicid download) |
| Upload and catalogue an archive | Admin | Superadmin | POST admin/system/backups/upload | settings.json (retained cap), uploaded manifest/signature | backup_run (kind:'uploaded') | API 8.14 |
| Request a restore | Admin | Superadmin | POST admin/system/backups/{publicId}/restore | everything preflight checks | backup_restore, restore-state.json, maintenance.json, outbox_events, and (inline, before the runner spawns) a new pre_restore_safety backup_run | API 8.9 |
| Poll live restore state | Admin | Superadmin | GET admin/system/restores/{publicId}/live | restore-state.json only | — | API 8.10 |
| Force-clear a stuck restore | Admin | Superadmin | POST admin/system/restores/{publicId}/force-clear | backup_restore | backup_restore, maintenance.json | API 8.11 |
| Read maintenance state | Admin | Any admin | GET admin/system/maintenance | maintenance.json/Redis/cache | — | API 8.12 |
| Toggle maintenance | Admin | Any admin (with System_UPDATE) | PUT admin/system/maintenance | — | maintenance.json, Redis mirror | API 8.13 |
| Check maintenance status (public) | Public | Guest/customer | GET system/maintenance | maintenance.json/Redis/cache | — | API 8.15 |
| Download a backup's uploaded files | Admin | Superadmin | GET admin/system/backups/{publicId}/download/uploads | backup_run, filesystem | MongoDB AuditLog (backup.download_uploads, must succeed) | API 8.23 |
| Read the key custody state | Admin | Superadmin | GET admin/system/backup-key | key file, backup_run (counts) | — | API 8.16 |
| Generate the signing key | Admin | Superadmin + password | POST admin/system/backup-key/generate | key file | key file | API 8.17 |
| Import a key from another host | Admin | Superadmin + password | POST admin/system/backup-key/import | key file | key file (retired set) | API 8.18 |
| Rotate the signing key | Admin | Superadmin + password | POST admin/system/backup-key/rotate | key file | key file (old key → retired) | API 8.19 |
| Resolve backup signatures | Admin | Superadmin | POST admin/system/backup-key/resolve-signatures | key file, artifact.sig on disk | backup_run.signature_state, .signing_key_fingerprint | API 8.20 |
| Prune a retired key | Admin | Superadmin + password | DELETE admin/system/backup-key/retired/{fingerprint} | key file, backup_run counts | key file | API 8.21 |
| Create a scheduled backup | Worker | System | Nightly cron (BackupScheduleScheduler) | settings, backup_run (tier resolution) | backup_run, outbox_events | Backend 7.2 |
| Sweep retention | Worker | System | Daily cron (BackupRetentionScheduler) → backup.prune {reason:"scheduled"} | settings, backup_run per bucket | filesystem (rm), backup_run | Backend 7.3 |
| Recover stalled runs/replications | Worker | System | 5-minute cron (BackupSweepScheduler) | backup_run (running/pending) | backup_run (→ failed) | Backend 7.4 |
| Replicate off-host | Worker | System | backup.replicate job (enqueued at run completion) | backup_run, filesystem | backup_run replication columns, remote host | Backend 7.9 |
5. User-Facing Flows
5.1 Take a backup — manual ("Run now")
Summary
An operator wants a backup right now — before a risky migration, before a release, or just to confirm the feature works. They click "Run now" and see the row move from queued to running to completed on the list.
Preconditions
backupEnabledistruein settings (defaultstrue).- No other backup is currently
queuedorrunning. - Free disk space is at least
minFreeDiskMbplus 1.2× the last completed dump's size (or justminFreeDiskMbfor the very first backup ever taken). - Superadmin session with
Backup_CREATE.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | POST admin/system/backups, optionally with includeUploads/note | 201, row queued | BackupAdminController.create |
| 2 | Backend | Validates enabled/no-run-in-progress/disk space, inserts the row and an outbox event in one transaction | Row exists, job scheduled | BackupRunCreateService.createRun |
| 3 | Worker | Outbox dispatcher relays to QueueName.BACKUP (~5s), BackupRunHandler claims the row | Row running | BackupRunHandler.claim |
| 4 | Worker | Opens a REPEATABLE READ snapshot, runs pg_dump --format=custom --snapshot=<id>, optionally tars the upload root | Two artifact files on disk (or one, if uploads excluded) | BackupManifestService.captureAndRun, BackupArtifactService |
| 5 | Worker | Writes manifest.json before marking the row complete | Catalog is durable on disk even if the process dies right after | BackupManifestService.writeManifestFile |
| 6 | Worker | Updates the row to completed with bytes/sha256/manifest; enqueues backup.replicate in the same transaction if configured | Row completed, appears on the list | BackupRunHandler.run |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Backups disabled | backupEnabled=false | Reject before any row is created | 503 BACKUP_DISABLED |
| Another run in progress | A row is queued or running | Reject synchronously — a second click is refused even before the first has been claimed by a worker | 409 BACKUP_ALREADY_RUNNING |
| Low disk | Free space below the computed floor | Reject synchronously | 503 BACKUP_INSUFFICIENT_DISK_SPACE |
pg_dump fails or times out | Transient DB error, disk full, or exceeds dumpTimeoutMinutes | Row marked failed; only a sanitised excerpt is stored, never raw stderr | BACKUP_DUMP_FAILED / BACKUP_DUMP_TIMED_OUT |
| API crashes between artifact write and row completion | Process killed mid-dump | manifest.json may already exist with no matching row | reconcileFromDisk (runs at boot) rebuilds the row from the manifest — this is the exact reason the manifest is written before the row is marked complete |
API crashes with the row stuck running | Worker process dies without a clean failure | Row sits running indefinitely | BackupSweepScheduler marks it failed within dumpTimeoutMinutes + 5 minutes |
5.2 Take a backup — scheduled (nightly)
Summary
No human is involved. Every night at scheduleCron/scheduleTimezone (default 0 2 * * * Asia/Kathmandu), the system decides which grandfather-father-son (GFS) tier is due and creates that backup automatically.
Preconditions
backupEnabledistrue.- Same disk-space and no-run-in-progress checks as the manual path — the scheduler calls the identical
BackupRunCreateService.createRun.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Scheduler | Cron ticks in scheduleTimezone | — | BackupScheduleScheduler (SchedulerRegistry, not @Cron, because the schedule is admin-editable) |
| 2 | Scheduler | Resolves the tier: the most significant period with no completed run yet — month, then ISO week, then day | daily/weekly/monthly chosen | resolveTier / computeBackupTierPeriodBoundaries |
| 3 | Scheduler | Calls the same createRun({kind:"scheduled", tier}) the manual path uses | Row queued, kind='scheduled' | shared with 5.1 from here |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Tier already has a completed run this period | e.g. a monthly already completed this calendar month | Falls through to the next-most-significant period still due | weekly, then daily |
| Every period already covered | Rare — would mean a daily also already ran today | resolveTier always returns at least daily as the floor; a second daily in one day is only possible via a retried tick after a failure | — |
| Tick throws | Any error in runTick | Logged and swallowed | The next night's tick is unaffected |
| Backups disabled at tick time | backupEnabled=false | Same as 5.1's disabled branch, caught by runTick's try/catch | Logged, no row created, no exception propagates |
| Tier assigned before success is known | The row is inserted queued with a tier already set | This is deliberate — chk_backup_run_tier_matches_kind requires a non-null tier on every scheduled row at INSERT, before the dump has even started | A failed scheduled run still "used" its tier slot for that tick; the next tick reassigns it since a failed run does not count as completed |
5.3 Retention pruning
Summary
Old backups disappear on their own, keeping disk usage bounded, while always leaving enough history to recover from. An operator never manually deletes backups to manage disk space unless they want to remove one specific run early.
Preconditions
- At least one bucket (
daily/weekly/monthly/manual/pre_restore_safety) has more completed, unpinned rows than itsretain*count.
Main Flow — scheduled sweep
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Scheduler | Daily at 03:00, enqueues backup.prune {reason:"scheduled"} directly (the documented outbox exemption — a cron tick with no accompanying database write) | Job on QueueName.BACKUP | BackupRetentionScheduler.enqueueSweep |
| 2 | Worker | For each of the 5 buckets, reads completed+unpinned rows newest-first, slices off everything past the retain count | Candidate list per bucket | BackupRetentionService.sweep |
| 3 | Worker | For each candidate, re-checks the 3 floors, then deletes the artifact directory and marks the row pruned | Disk reclaimed; row survives as history | assertPrunable, pruneRun |
| 4 | Worker | Separately, for any remote_only run whose replication is verified and is not the overall-newest backup, deletes only the heavy local artifacts (keeps manifest.json) | localArtifactPresent=false, row stays completed | sweepRemoteOnlyLocalCopies |
Main Flow — manual delete
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | DELETE admin/system/backups/{publicId} | Floors checked synchronously; 409 immediately if ineligible | BackupAdminService.remove |
| 2 | Backend | Enqueues backup.prune {reason:"manual", backupRunPublicId} through the outbox | — | same |
| 3 | Worker | Re-checks the floors (they can change between click and pickup) then prunes exactly like the scheduled path | Row pruned | BackupPruneHandler.prune |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Pinned row is a candidate | pinned=true | Skipped, logged (scheduled) or 409 (manual) | BACKUP_PINNED_CANNOT_DELETE |
| Row is the newest in its own bucket | e.g. the only daily row today | Protected regardless of the retain count | BACKUP_LAST_REMAINING_CANNOT_DELETE |
| Row is the newest completed backup overall | Across every bucket | Protected — the site must always have at least one restorable backup | same code |
| Row is the safety dump of the most recent restore | kind='pre_restore_safety' and matches the latest backup_restore.safety_backup_run_id | Protected — BackupRestoreProcessor.takeSafetyDump writes safety_backup_run_id onto the restore's own row immediately after the dump completes, so this comparison is genuinely satisfiable (see 10) | same code |
| One floor blocks one row during a sweep | Any of the above, during the scheduled sweep | That row is skipped and logged; the rest of the sweep continues | Never stops the whole sweep |
A remote_only run is the overall-newest | Would otherwise have its local copy reclaimed | Local copy is kept regardless — a restore that has to pull gigabytes over SSH during an incident is a restore that will not happen | — |
| Retried delete request | remove() called twice for an already-pruned row | pruneRun is a no-op for a non-completed row | Idempotent, not an error |
5.4 Restore the live database
Summary
An operator needs to undo something catastrophic — replace the entire live database with a known-good backup. This is the single most irreversible operation in the product, and it is the only flow in this module gated by two independent switches: a grantable permission and a deploy-time environment variable that cannot be granted from inside the running application.
Read this before anything else in this section: requesting a restore genuinely replaces the database — preflight validates everything, maintenance engages, an automatic pre-restore safety dump is taken, linked to this specific restore, and must succeed before anything destructive runs, existing connections are drained before being terminated, a detached process runs pg_restore --clean --single-transaction against the confirmed dump, and row counts are verified against the manifest afterward. What still is not automated: verification beyond row counts (content, permissions), and a one-click "undo" that automatically resubmits the linked safety dump. See 10 below for exactly what is and is not covered.
Preconditions
BACKUP_RESTORE_ENABLED=trueat the deploy level (defaultfalse— restore is meant to be armed deliberately, after an operator has already seen a backup complete and downloaded one, never on day one).BACKUP_ADMIN_DATABASE_URLconfigured — a separate database role from the application's ownDATABASE_URL, one permitted to terminate backends and drop objects. The web application's own credential must never carry that authority, so that a SQL injection anywhere in the API cannot inherit it.- The application's own DB role (
DATABASE_URL's role) must not be a Postgres superuser. This is checked at preflight, before any write: the restore fences the application out withALTER ROLE <app> CONNECTION LIMIT 0sopg_restore --cleanis not contending forACCESS EXCLUSIVElocks against live traffic — and PostgreSQL does not enforcerolconnlimitagainst a role withrolsuper. If the application ever connected as a superuser, the fence would silently do nothing. - Superadmin session with
Backup_RESTORE. - The source backup is
completedwith its artifact still present locally, its recorded size and sha256 matching the file on disk, and itsschemaMigrationTagmatching the currently running application's schema exactly (no override — a mismatch means the running code and the restored schema disagree, which surfaces later as a missing column somewhere unrelated). - No other restore is non-terminal, and no backup dump is currently
queued/running.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Types the live database name, checks both "I understand" boxes, submits POST .../restore | — | Admin UI + RestoreBackupDto |
| 2 | Backend | Runs 8 sequential preflight guards (feature flag, superuser check, source exists, acknowledgements, typed name matches current_database(), source restorable, schema tag matches, size+sha256 match on disk) | All pass, or the whole request is refused with nothing changed | BackupRestorePreflightService.assertRestoreAllowed |
| 3 | Backend | Inserts a backup_restore row (queued) and an outbox event, one transaction | Row exists | BackupRestoreAdminService.requestRestore |
| 4 | Backend | Writes restore-state.json (stage:"queued", including sourceArtifactDir the runner will need) | The live-poll endpoint has something to read immediately, and the runner has everything it needs once it starts | BackupStateFileService.writeRestore |
| 5 | Backend | Engages maintenance immediately, not deferred to a worker | Customer traffic is refused from this instant, closing the window where writes would otherwise be destroyed on the eventual restore | MaintenanceService.engage |
| 6 | Backend | Returns 202 RestoreLiveStateDto | Admin sees "Restore accepted" | — |
| 7 | Worker | Outbox dispatcher relays backup.restore to QueueName.BACKUP_RESTORE; BackupRestoreProcessor confirms the state file matches this restore, reads the effective settings once, then — before spawning anything — takes an inline, awaited pre-restore safety dump: a real kind:'pre_restore_safety' backup, run synchronously in the same process via BackupRunHandler.run(), not enqueued. Its id is written into the state file as safetyBackupPublicId before the dump runs | State file moves to stage:"safety_dump"; if the dump does not reach completed, the restore aborts here and maintenance stays engaged | workers/backup-restore.processor.ts (takeSafetyDump) |
| 8 | Worker | Once the safety dump is confirmed completed, links it to the restore: UPDATE backup_restore SET safety_backup_run_id. If that update matches no row, the restore aborts here too, rather than proceeding with a link it could never record | backup_restore.safety_backup_run_id set | workers/backup-restore.processor.ts (takeSafetyDump) |
| 9 | Worker | Spawns restore-runner.ts <restorePublicId> as a detached process — deliberately not an in-process BullMQ worker, because pg_restore --clean holds ACCESS EXCLUSIVE on every table including admin_sessions, which JwtStrategy reads on every authenticated request — with the effective restoreTimeoutMinutes/restoreDrainSeconds injected into its environment | State file advances draining → restoring → verifying → completed/failed (reconciling remains declared but never assigned) | workers/backup-restore.processor.ts (spawnRunner) |
| 10 | Runner | Fences the application's DB role (ALTER ROLE ... CONNECTION LIMIT 0), then drains: polls open connections for that role once a second, heartbeating progress, until either none remain or restoreDrainSeconds elapses — only then terminates whatever is left with pg_terminate_backend | New connections blocked immediately; existing ones get a grace window to finish in-flight work before being cut | workers/restore-runner.ts |
| 11 | Runner | Runs pg_restore --clean --if-exists --single-transaction --no-owner --no-privileges against the confirmed dump, bounded by restoreTimeoutMinutes | Database replaced, or left untouched on failure | workers/restore-runner.ts |
| 12 | Runner | Moves to stage:"verifying" and runs verifyRowCounts(): reads the source backup's own manifest.json and compares every listed table's recorded row count against a live count(*) on the just-restored database, excluding RESTORE_VERIFICATION_EXCLUDED_TABLES (session tables, backup_run, backup_restore, outbox_events, drizzle_migrations — tables the restore and the safety dump themselves are expected to change) | Any mismatch throws, reported as BACKUP_RESTORE_VERIFICATION_FAILED specifically (not the generic failure code) | workers/restore-runner.ts (verifyRowCounts) |
| 11 | Runner | Heartbeats the state file every 5 seconds throughout, then always releases the connection fence in a finally block, releasing it before publishing stage:"completed" so the supervisor's own DB query is not itself blocked by the fence | — | workers/restore-runner.ts |
| 13 | Worker | BackupRestoreProcessor reads the terminal stage, updates backup_restore if the row still exists (it usually does not — pg_restore --clean already replaced it with the dump's own pre-restore copy), and disengages maintenance on success | Customer traffic resumes; on failure, maintenance stays engaged for an operator to inspect | BackupRestoreProcessor.finish/fail |
| — | Not built | Verifying anything beyond row counts (permissions, content) — the structured backup_restore.verification shape is never written even though the row-count comparison itself runs; and a one-click "undo" that automatically selects the linked safety dump as the new restore source | A restore that reaches completed is trusted on row counts alone; undoing a bad restore still means an operator manually submitting the (now directly discoverable, via safetyBackupPublicId) safety dump as a fresh restore request | backup-restore.ts, workers/restore-runner.ts |
Why --single-transaction is load-bearing
Probed directly, not assumed: restoring a truncated dump with --single-transaction left the target database completely untouched — the whole restore rolled back as one unit. Restoring the same truncated dump without the flag emptied the table and the original rows were gone. The automatic pre-restore safety dump is a second, independent safety net on top of this — the way back if the chosen backup itself turns out to be wrong, not just if pg_restore fails partway. This is why --single-transaction is a fixed part of pg_restore's argv in the runner script, never a configurable option.
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Restore not armed | BACKUP_RESTORE_ENABLED=false, or BACKUP_ADMIN_DATABASE_URL unset | Refused before any write | 503 BACKUP_RESTORE_DISABLED |
| App role is superuser | rolsuper=true on the DATABASE_URL role | Refused — the connection fence would be a no-op | 409 BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER |
| Wrong database name typed | Does not match current_database() | Refused — the confirmation dialog is a speed bump, this check is the actual control | 400 BACKUP_RESTORE_CONFIRMATION_MISMATCH |
| Schema version mismatch or unknown | Backup's schemaMigrationTag differs from, or either side is missing | Refused, no override | 409 BACKUP_RESTORE_SCHEMA_VERSION_MISMATCH |
| Artifact tampered or corrupted | Size or sha256 mismatch on disk | Refused before any restore attempt | 409 BACKUP_SIZE_MISMATCH / BACKUP_CHECKSUM_MISMATCH |
| Another restore already in flight | A backup_restore row is non-terminal | Refused fast, before the partial unique index would surface a raw 23505 | 409 BACKUP_RESTORE_ALREADY_ACTIVE |
| A backup dump is running | backup_run queued/running | Refused — restoring while a snapshot transaction is open is not safe | 409 BACKUP_RESTORE_RUN_IN_PROGRESS |
Pre-restore safety dump fails or does not reach completed | Disk full, pg_dump error, or the dump insert itself throws | Restore aborts before pg_restore ever runs; maintenance stays engaged | BACKUP_RESTORE_FAILED |
pg_restore succeeds but row counts do not match the manifest | Corruption, or an unexpected schema drift the tag check missed | The database is populated but wrong — treated as more dangerous than an outright failure | BACKUP_RESTORE_VERIFICATION_FAILED |
| Runner never spawns, or dies mid-restore | restore-runner.ts fails to start, or its heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDS | BackupRestoreProcessor marks the row failed (BACKUP_RESTORE_ABANDONED), maintenance stays engaged | Operator must inspect the server log |
| Restore accepted, then the operator needs to abandon it regardless | A runner that never spawned, was killed, or is genuinely still running and the operator wants out anyway | Marks the row failed, disengages maintenance — but does not stop a still-running pg_restore subprocess | POST .../force-clear |
| Live-state poll during a restore | The state file is being written concurrently | The endpoint reads the file directly and touches no table, so it stays answerable while pg_restore --clean holds ACCESS EXCLUSIVE locks and JwtStrategy would otherwise block reading admin_sessions | Exercised by real restores now, not only designed for them |
5.5 Off-host replication
Summary
An operator wants a copy of every backup somewhere other than the VPS the database lives on, so a lost or compromised host does not also mean a lost backup. This is opt-in, transport-hardened, and verified by reading the bytes back rather than trusting the transfer's own exit code.
Preconditions
BACKUP_REMOTE_ENABLED=true,BACKUP_REMOTE_TARGET(auser@host:/path),BACKUP_REMOTE_SSH_KEY_PATHandBACKUP_REMOTE_KNOWN_HOSTS_PATHall configured at deploy time.remoteReplicationEnabled=truein settings, andstorageModeset tobothorremote_only.- At boot,
BackupReplicationServicerefuses to start with replication enabled unless the configuredrsyncbinary supports--secluded-args(rsync 3.2.3+) and the known-hosts file exists and is non-empty — both are asserted once, at startup, rather than discovered failing at 02:00.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Worker | On dump completion, if storageMode !== 'local' and replication is enabled, enqueues backup.replicate in the same transaction that marks the run completed | replicationStatus='pending' | BackupRunHandler.run |
| 2 | Worker | Recomputes the run's relative artifact directory from publicId+createdAt — never trusts the artifact_dir column, which originates in an operator-editable manifest.json — and validates it against a fixed <yyyy>/<mm>/<uuid> pattern | Refuses before any transfer if the shape is wrong | BackupReplicationService.replicate |
| 3 | Worker | Pushes the dump (and archive, if present) via rsync --secluded-args, excluding manifest.json | Heavy artifacts land on the remote host | rsyncPush |
| 4 | Worker | Pulls the dump back to a local temp directory and hashes it locally — never trusts a remote-reported hash, and this is compatible with a write-only deploy key that cannot run remote commands | Verified byte-for-byte | verifyByReadBack |
| 5 | Worker | Pushes manifest.json last, only once the heavy artifacts are verified | A disaster-recovery-side reconcileFromDisk never sees a manifest naming a completed run whose dump has not fully arrived | rsyncPush (manifest-only pass) |
| 6 | Worker | Updates the row: replicationStatus='replicated', replicatedAt, remoteArtifactDir | Settings' replication-health fields reflect success | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Already replicated | Duplicate delivery of the same job | No-op — nothing to replicate, nothing to retry | — |
| No local artifact to replicate | Run not completed, or already local-copy-reclaimed | No-op, logged | — |
| Recomputed path does not match the stored/expected shape | Tampered or unexpected manifest.json | Refused before any rsync call | Marked failed |
| Transport failure | Network unreachable, auth failure, timeout | Row marked replicationStatus='failed' — the backup itself stays completed, since a dump that succeeded and could not be shipped off-box is still a usable backup | BACKUP_REMOTE_UNREACHABLE, BACKUP_REPLICATION_FAILED |
| Remote disk full | rsync reports no space | Row marked failed with a specific code | BACKUP_REMOTE_DISK_FULL |
| Read-back hash mismatch | Bytes arrived corrupted despite rsync reporting success | Treated as a failure, same as a transport error | BACKUP_REPLICATION_VERIFY_FAILED |
Replication stuck pending | A worker died mid-transfer | BackupSweepScheduler ages it to failed past BACKUP_REPLICATE_TIMEOUT_MINUTES + 5 | — |
storageMode='remote_only', retention sweep runs | Run is verified-replicated and is not the overall-newest backup | Local heavy artifacts deleted, manifest.json kept, localArtifactPresent=false | reconcileFromDisk still works, since the manifest never left |
| Remote-side retention | Old copies piling up on the backup host | Deliberately out of scope for this module — the backup host is expected to run its own retention/rotation; this feature only ever pushes, never prunes remotely | — |
5.6 Upload and catalogue an operator-supplied archive (SE-4)
Summary
An operator has a pg_dump archive from somewhere other than this deployment's own scheduled/manual dumps — moved from another environment, or pulled off a downed host — and wants it in the catalog so the existing restore flow can act on it. This never restores anything by itself; it only authenticates and files the archive away.
Preconditions
- Superadmin session with
BackupUpload_CREATE. - A current signing key held in the key file — see 5.8.
- The archive, its
manifest.json, and itsartifact.sigwere all produced by a deployment holding a key this one generated — the signature is the actual authentication, not the checksum inside the manifest. An imported key does not count here: on upload the caller supplies the archive, the signature and the key, which is three operands from one actor. - Fewer than
BACKUP_UPLOAD_MAX_RETAINEDcompleteduploadedbackups already exist.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | Selects the three files (file, manifest, signature) and submits POST .../backups/upload | Multipart request sent | Admin UI + BackupUploadDto |
| 2 | Backend | Streams the dump to a .partial file, hashing as it writes; checks the retained cap; parses the manifest for schemaMigrationTag/databaseDumpSha256 | Corruption/shape checks pass, or refused immediately | BackupUploadStorageEngine, BackupUploadAdminService |
| 3 | Backend | Verifies the HMAC signature over dumpSha256:manifestSha256 — the actual authentication control | Proceeds only if the signature matches | BackupSignatureService.verify |
| 4 | Backend | Format-checks the archive (pg_restore --list), renders its schema SQL, and scans it for constructs a restore must not execute | Refused if a dangerous construct is found | scanArchiveSql |
| 5 | Backend | Moves the dump into a real artifact directory, writes a fresh, server-generated manifest and signature (never the operator's own manifest), inserts a backup_run row (kind:'uploaded', pinned:true, status:'completed') | 201, appears in the catalog immediately, restorable like any other completed backup | BackupUploadAdminService.finalize |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| A required part is missing | file, manifest, or signature absent | Refused before any processing | 400 BACKUP_ARCHIVE_UNREADABLE / BACKUP_UPLOAD_MANIFEST_INVALID / BACKUP_UPLOAD_SIGNATURE_INVALID |
| Manifest is malformed or missing required fields | Not JSON, not an object, or missing schemaMigrationTag/databaseDumpSha256 | Refused | 400 BACKUP_UPLOAD_MANIFEST_INVALID |
| Transfer was truncated or corrupted | Dump's sha256 does not match the manifest's claim | Refused before the signature is even checked | 409 BACKUP_CHECKSUM_MISMATCH |
| Signing not configured on this deployment | No current key in the key file | Refused — fails closed rather than accepting an unauthenticated archive | 503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED |
| The key store is unreadable | The key file exists and cannot be parsed | Refused. A corrupt file is not an absent one, and skipping verification because the file is broken is the failure the branch exists to prevent | 422 BACKUP_KEY_STORE_UNREADABLE |
| Wrong key, or archive from an unrelated deployment | HMAC does not match | Refused | 409 BACKUP_UPLOAD_SIGNATURE_INVALID |
| Archive contains a refused SQL construct | e.g. an unexpected SECURITY DEFINER function | Refused before cataloguing | 409 BACKUP_UPLOAD_TOC_REJECTED |
| Retained cap reached | BACKUP_UPLOAD_MAX_RETAINED completed uploads already exist | Refused | 409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHED |
| Re-uploading an already-catalogued archive | Operator downloads a previously-uploaded row and re-uploads it | Succeeds — the row is re-signed over its own fresh manifest each time, so a round trip does not break the signature | New backup_run row, subject to the retained cap |
| Retention never touches an uploaded row | GFS sweep runs | kind:'uploaded' rows are excluded from RetentionBucket entirely, on top of being pinned:true | Only an explicit admin DELETE removes one |
5.7 Check maintenance status (customer)
Summary
A customer's browser (via the storefront's own request handling) needs to know, on every page it serves, whether the store is currently refusing traffic — uncached and without authentication. This is the one capability in the module a guest can use directly.
Preconditions
None. No auth, no permission, no feature flag.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Storefront | Calls GET /api/system/maintenance on (effectively) every request it serves, uncached | — | MaintenanceCustomerController.getStatus |
| 2 | Backend | Reads the same 3-layer maintenance state (in-process cache → Redis → file) every admin-facing check uses | {active, reason} | MaintenanceCustomerService.getStatus → MaintenanceService.getState |
| 3 | Backend | Maps onto the narrower public shape — engagedBy/engagedAt never leave this boundary | 200 MaintenanceStatusDto | same |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Maintenance not engaged | active: false in shared state | reason forced to null regardless of any stale stored value | {active:false, reason:null} |
| Maintenance engaged by a restore | restorePublicId set in shared state | Not exposed here — only active/reason cross the boundary | {active:true, reason:"..."} (or null reason if the operator/restore did not set one) |
| Rate limit exceeded | More than 300 requests/min from one IP | Refused | 429 |
| This route itself during maintenance | The store is refusing every other route | Stays answerable — it is named literally in MAINTENANCE_EXEMPT_PATHS | 200, same as always |
5.7a Getting the uploaded files back
A backup taken with "Also archive uploaded files" produces two artifacts:
database.dump + manifest.json + artifact.sig in one tar, and uploads.tar.gz beside them.
Download hands out the first three only. That is deliberate — they are exactly what the upload endpoint consumes, and folding a multi-gigabyte archive into that tar would break the round trip and make every routine dump download enormous.
What was missing is that nothing said so. The operator ticked the box, the panel reported the backup complete and showed the archive's size, and the download returned a file without it.
So there are two controls, and neither is labelled just "Download":
| Control | Gives | Shown when |
|---|---|---|
| Download database | database.dump, manifest.json, artifact.sig | always, with BackupDownload_READ |
| Download uploaded files | uploads.tar.gz | the run archived uploads, it completed, its artifacts are still on this host, and the operator holds the permission |
That third condition is not padding. The retention sweep reclaims a backup to a remote copy by
deleting the local files and setting local_artifact_present false — while leaving status as
completed. Without checking it, the link renders on a swept run and answers 410; because the
control is a plain link, the tab navigates away and shows raw JSON. A race can still reach that (the
sweep can land between render and click), so the conditions make a refusal unlikely rather than
impossible.
Verifying what you downloaded
The detail page shows Uploads archive sha256. Check the file you received:
sha256sum backup-<publicId>-uploads.tar.gzThe server deliberately does not hash the archive before sending it — see API 8.23. Hashing it first would read the whole file before the first header and turn every large download into a timeout, and nothing restores this archive, so there is no automated consumer to protect. The published digest is the control, and it verifies the bytes that actually arrived rather than the bytes that were on disk.
What the archive contains, and one thing to know about it
Everything under the upload root at dump time, as a gzipped tar. Two caveats:
- The artifact HMAC covers the database dump and manifest only, never
uploads.tar.gz. The signature badge says "Signed (database dump)" for exactly this reason. - The archive is taken from the local upload root regardless of the configured storage driver. On a deployment whose files live in object storage that directory may be empty or stale, and the run still records a size and a checksum. The rendered byte count next to the link is the operator's warning.
5.8 Custody of the artifact-signing key
The key that decides whether a backup archive can be verified — and therefore whether it can be
restored — used to live inline in .env. It now lives in a file the application owns, with a
lifecycle an operator can drive from the panel.
Rotation keeps the old key, and that is the whole design. A retired key is not a dead key: it is what still verifies every artifact signed before the rotation. Without a retired set, "rotate the signing key" would mean "make every existing backup unrestorable" — and an operator would discover that at the worst possible moment.
Reveal-once. generate and rotate return the key material in their response and nothing can
retrieve it afterwards. The panel shows it in a dialog whose confirm button is disabled until the
operator ticks "I have stored this key somewhere outside this server". If the value is lost, the
recovery is to rotate again — not to read it back.
The migration never generates a fresh key. It copies the existing .env value byte for byte,
because every artifact already on disk is signed with it. A fresh key would make them all
unverifiable, and the symptom — BACKUP_UPLOAD_SIGNATURE_INVALID — reads like corruption rather
than like a key change, which is the worst possible way for that to present.
Pruning a retired key, and the two refusals
Deleting a retired key is irreversible: the only way back is to import the same material again, if
the operator still has it. Two independent checks refuse it, and force overrides both.
| Refusal | Reads | Why it is not the same check twice |
|---|---|---|
| Completed backups still carry this fingerprint | backup_run.signing_key_fingerprint | Those artifacts would become unverifiable. |
| Some completed run is still unresolved | signature_state IS NULL OR = 'signed_unknown_key' | An unresolved run might be one this key alone can verify, and the count above cannot see it. Run "Resolve backup signatures" first. |
Neither count can see an archive kept off this host — it has no row here. So a 0 never means
"nothing depends on this key", and the panel says so in the same sentence as the number. That is
also exactly the population import exists for.
Resolving signatures
resolve-signatures walks completed runs, reads each artifact's artifact.sig, and records which
held key verifies it. It is batched: call again while remaining is above zero.
It re-examines rows already resolved as signed_unknown_key, because importing a key changes that
answer. signed_unknown_key is a cache, not a fact — which is why every predicate that selects
work reads signature_state IS NULL OR signature_state = 'signed_unknown_key', never IS NULL
alone. A predicate that checked only NULL would resolve a row once, cache "no key held", and never
look again, so importing the very key that verifies it would change nothing.
5.9 Who did this, from where
The admin panel is a BFF: the browser calls the Next server, and that server calls the API. So the
API's req.ip was the panel process, and every admin action in the activity log read
127.0.0.1 — literally accurate, and useless for a log whose entire purpose is telling operators
apart.
Three decisions worth stating, because each has an obvious wrong answer:
- Read from the right, never
split(",")[0]. Under nginx's standard$proxy_add_x_forwarded_forthe leftmost entry is whatever the client sent — fully attacker-controlled. Counting in from the right, past the hops actually operated, is the only reading a caller cannot forge. TRUSTED_PROXY_HOPS=0means "derive nothing", and it is the default. With one trusted hop and a single-entry inbound header — every local run, and any deployment where nothing in front rewritesx-forwarded-for— the entry counted from the right is the browser's own value. Forwarding that under a valid token would hand the caller the address the audit log records, inverting the whole exercise. A deployment must say it has an address-rewriting proxy.- The address is not a rate-limit input. It reaches the activity log and nothing else. Feeding a
client-influenced address into
IpThrottlerGuardwould let an unauthenticated attacker choose their own bucket on the admin credential path.
Until a deployment configures both halves, the log records the loopback address — which is merely useless, rather than wrong. That is the deliberate failure mode: an audit entry that says nothing beats one that says something false.
6. Admin Flows
6.1 Create
See 5.1 — POST admin/system/backups. Route: POST admin/system/backups. Permission: Backup_CREATE. Mutation side effects: backup_run insert, outbox enqueue. Cache invalidation: none (this module has no cached list reads). Audit: backup.create. Failure branches: 503 BACKUP_DISABLED, 409 BACKUP_ALREADY_RUNNING, 503 BACKUP_INSUFFICIENT_DISK_SPACE.
6.2 List / Read Detail
GET admin/system/backups and GET admin/system/backups/{publicId}. Permission: Backup_READ. No side effects. Failure: 404 BACKUP_NOT_FOUND on detail.
6.3 Update (pin/unpin)
PATCH admin/system/backups/{publicId} with {pinned: boolean}. Permission: Backup_UPDATE. Mutation: backup_run.pinned. Audit: backup.pin/backup.unpin with a changes[] diff. Failure: 404 BACKUP_NOT_FOUND.
6.4 Soft delete (prune)
DELETE admin/system/backups/{publicId} — see 5.3. Not a true soft delete in the usual sense: the row is never restorable back to completed, but it survives as a pruned audit record rather than being hard-deleted. Failure: 404, or 409 on any of the 3 floors.
6.5 Restore
See 5.4 in full. Route: POST admin/system/backups/{publicId}/restore. Permission: Backup_RESTORE, plus BACKUP_RESTORE_ENABLED. Takes an inline pre-restore safety dump, genuinely replaces the database via restore-runner.ts, and verifies row counts against the manifest afterward; ends at completed or failed (BACKUP_RESTORE_FAILED or, specifically for a row-count mismatch, BACKUP_RESTORE_VERIFICATION_FAILED), or at a manually force-cleared failed if the safety dump or the runner never completes.
6.6 Force-clear
POST admin/system/restores/{publicId}/force-clear — see 5.4's edge cases. Permission: Backup_RESTORE. Mutation: backup_restore.status='failed', disengages maintenance. Does not stop a genuinely running pg_restore subprocess.
6.7 Settings (configure)
GET/PUT admin/system/backups/settings, backing a dedicated admin panel settings page — see Section 12.3 for the field-by-field editable/environment-only split. Permission: Backup_READ (get) / BackupConfigure_UPDATE (put) — a third, separate permission module from Backup_*, because authorising dump/pin/prune/restore is not the same act as authorising a change to the off-host replication destination. PUT is a full-replace write keyed by an optimistic version — a stale read is 409 BACKUP_SETTINGS_CONFLICT, not a silent overwrite.
6.8 Maintenance (supporting flow, not its own feature)
GET/PUT admin/system/maintenance. Permission: System_READ/System_UPDATE. Exists to support the restore flow (engaged automatically at restore request) and to give operators a manual kill switch for other purposes (a risky migration, an incident). See the state diagram in Section 7.2.
6.9 Upload (catalogue)
See 5.6 in full. Route: POST admin/system/backups/upload. Permission: BackupUpload_CREATE — a fourth, separate permission module from Backup_*/BackupDownload_*/BackupConfigure_*, because introducing a database from outside is not the same act as running a dump of this one. Synchronous — 201 means the row is already catalogued, not queued.
6.10 Key custody
See 5.8 in full. Routes: GET admin/system/backup-key
plus generate / import / rotate / resolve-signatures and
DELETE .../retired/{fingerprint}. Permission: BackupKey_* — a fifth, separate permission module,
because replacing the credential every artifact is authenticated against is not the same act as
changing how often a dump runs. Audit: backup.key.generate / .import / .rotate / .prune.
Every mutating route additionally re-checks the operator's password.
Failure branches: 400 BACKUP_KEY_REAUTH_REQUIRED, 401 BACKUP_KEY_REAUTH_FAILED,
409 BACKUP_KEY_NO_PASSWORD_SET, 409 BACKUP_KEY_ALREADY_EXISTS,
409 BACKUP_KEY_FINGERPRINT_UNKNOWN, 409 BACKUP_KEY_STILL_IN_USE,
422 BACKUP_KEY_PATH_NOT_CONFIGURED, 422 BACKUP_KEY_INVALID_MATERIAL,
422 BACKUP_KEY_STORE_UNREADABLE.
7. Lifecycle and State Transitions
7.1 backup_run.status
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
backup_run | — | POST .../backups or nightly cron | queued | Enabled, no run in progress, disk space sufficient | Outbox event enqueued |
backup_run | queued | Worker claims the job | running | status IN ('queued','running') — retries can reclaim | started_at set |
backup_run | running | pg_dump (and optional tar) succeed, manifest written | completed | — | Bytes/sha256/manifest recorded; replication enqueued if configured |
backup_run | running | pg_dump/tar fail or time out | failed | — | Sanitised error excerpt recorded |
backup_run | running | No heartbeat within dumpTimeoutMinutes + 5 | failed | BackupSweepScheduler | BACKUP_DUMP_TIMED_OUT |
backup_run | completed | Retention sweep or manual delete, floors pass | pruned | Not pinned, not newest-overall, not newest-in-bucket, not most-recent-safety-dump | Artifacts deleted, artifact_dir=null, row survives |
7.1a backup_run.signature_state
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| (NULL) | Run completes with a current key held | signed | — | signing_key_fingerprint set in the same statement |
| (NULL) | Run completes with no key held | unsigned | !isStoreUnreadable() | Permanent for that artifact |
| (NULL) | Run completes while the key store is unreadable | (stays NULL) | — | Deliberate: the answer is not known yet, and unsigned would be a permanent lie |
| (NULL) | resolve-signatures finds a matching held key | signed | artifact readable on this host | Fingerprint recorded |
| (NULL) | resolve-signatures finds no artifact.sig | unsigned | artifact readable on this host | — |
| (NULL) | resolve-signatures finds a signature matching nothing held | signed_unknown_key | artifact readable on this host | Fingerprint stays NULL |
signed_unknown_key | import adds the matching key, then resolve-signatures | signed | — | This transition is why the state is a cache, not a fact |
signed | anything | signed | — | Never overwritten; a resolved row is final |
The only transition into signed_unknown_key is "no held key matched at that moment", and the
only way out is importing a key. A resolution predicate that read signature_state IS NULL alone
would resolve each row once, cache "no key held", and never revisit it — so importing the very key
that verifies an archive would change nothing, and the prune refusal built on those counts would be
built on a stale answer. Every predicate therefore reads
signature_state IS NULL OR signature_state = 'signed_unknown_key'.
The three CHECK constraints that hold the shape (signed implies a fingerprint, unsigned implies
none, any state implies status = 'completed') are written as implications with IS DISTINCT FROM,
never as a closed disjunction over the enum — a CHECK passes on NULL, and a future enum value must
not silently fall into the wrong branch.
7.2 Maintenance state (supporting)
7.3 backup_restore.status and the live-state file's stage — two different state machines
The table column only ever holds queued or a terminal value, by design — pg_restore --clean replaces the table holding its own row mid-restore, so intermediate stages cannot live there:
The state file's stage field carries the real intermediate lifecycle (accepted → queued → draining → safety_dump → restoring → verifying → reconciling → completed/failed). safety_dump is written by BackupRestoreProcessor.takeSafetyDump; draining, restoring, verifying, completed and failed are written by restore-runner.ts as it fences, runs pg_restore, and checks row counts against the manifest. Only reconciling remains declared but never reached by any current code path. See 5.4.
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Manual/scheduled backup | backup_run insert then update; outbox_events | None | backup.run (→ maybe backup.replicate) | None | None | None |
| Retention pruning | backup_run update (pruned); outbox_events (manual path) | None | backup.prune | None | None | None |
| Download | None (read-only) | None | None | None | None | MongoDB AuditLog write (not fire-and-forget) |
| Restore request | backup_restore insert then update (terminal status); outbox_events; restore-state.json; maintenance.json; plus an inline backup_run insert+update for the pre-restore safety dump (kind:'pre_restore_safety') | Maintenance Redis mirror invalidated/rewritten | backup.restore (consumed by BackupRestoreProcessor, which takes the safety dump then spawns restore-runner.ts) | None | None | None |
| Force-clear | backup_restore update (failed); maintenance.json | Maintenance Redis mirror rewritten | None | None | None | None |
| Replication | backup_run update (replication columns) | None | backup.replicate | None | None | None |
| Upload and catalogue | backup_run insert (kind:'uploaded'); filesystem (dump, fresh manifest + signature) | None | None (synchronous) | None | None | None |
| Maintenance toggle | None | maintenance.json, Redis mirror, in-process 1s cache | None | None | None | None |
| Maintenance status check (public) | None (read-only) | None | None | None | None | None |
10. Error and Recovery Flows
10a. What the operator sees when a download is refused
Both download controls are plain <a href>. They have to be: the response is a stream, and the
access token is an httpOnly cookie the browser cannot read, so the BFF route beside the page is what
carries it. The consequence was that any non-2xx navigated the operator out of the dashboard and
rendered the API's error envelope as raw JSON.
The BFF now answers a failed navigation with a 302 back to the page the operator started from,
carrying ?downloadError=<code>. An inline notice renders the mapped message and the parameter is
removed from the URL, so a refresh or a shared link cannot replay someone else's error.
| Where the operator clicked | Where a refusal returns them |
|---|---|
the list (?from=list on the link) | the list |
| a backup's detail page | that detail page |
Three things about this are deliberate:
- A non-navigation still gets JSON. Only a request carrying
Sec-Fetch-Mode: navigateis redirected, so the route keeps an API-shaped contract for a same-originfetch(). - A CSRF refusal is never redirected. A
302into the dashboard would erase the signal that the guard fired, and the guard deliberately answers before the authentication check so a cross-site probe cannot tell a signed-in operator from a signed-out one. - The message is looked up, never rendered from the URL.
downloadErroris attacker-supplied by construction — anyone can craft the link and send it. It only ever selects from a fixed map, and an unrecognised value gets a generic message. Rendering it would let whoever wrote the URL choose what the operator reads.
The obvious alternative — fetch the response in a click handler and show the error — was rejected: it means buffering the success case in the tab, and these artifacts are multi-gigabyte by design. That trades a bad error surface for an out-of-memory crash on the path that works.
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Dump fails mid-run | pg_dump/tar subprocess error | Row failed; admin sees a sanitised error message | Trigger another manual run; investigate the server log for the raw error | BackupRunHandler.markFailed |
| API crashes right after a dump finishes | Kill between manifest.json write and row completion | A completed artifact exists with no catalog row | reconcileFromDisk (boot, or on demand) rebuilds it from the manifest | BackupManifestService.reconcileFromDisk |
| API crashes mid-dump | Kill before completion | Row stuck running | BackupSweepScheduler marks it failed within ~35 minutes by default | backup-sweep.scheduler.ts |
| Replication transfer fails | Network, auth, disk-full on the remote | Backup stays completed; replicationStatus='failed'; visible via replicationFailureStreak in settings | Investigate connectivity/credentials; the next run's replication attempt is independent | BackupReplicationService.replicate |
Replication stuck pending | Worker died mid-transfer | Row never resolves | BackupSweepScheduler ages it to failed | sweepStalledReplications |
| Rate limit exceeded | Too many requests in the window | 429 | Wait for the window to reset (per-route budgets in Section 4) | IpThrottlerGuard |
Pre-restore safety dump fails, or does not reach completed | Disk full, pg_dump error, or the safety-dump insert itself throws | Restore aborts before pg_restore ever runs; maintenance stays engaged; backup_restore marked failed (BACKUP_RESTORE_FAILED) | Investigate the server log for the safety dump's own error; free disk or fix the underlying issue and resubmit the restore | BackupRestoreProcessor.takeSafetyDump |
| Restore runner never spawns, or dies mid-restore | Process fails to start, or its heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDS | Maintenance stays engaged; backup_restore marked failed | POST .../force-clear if the row is not already marked; investigate the server log for the runner's own error before retrying | See 5.4 |
pg_restore succeeds, but restored row counts do not match the manifest | Corruption, or a schema/data drift the earlier checks missed | Maintenance stays engaged; backup_restore marked failed with BACKUP_RESTORE_VERIFICATION_FAILED specifically — distinct from the generic failure code, because the database is populated but wrong | The pre-restore safety dump is already in the catalog; an operator investigates the mismatch, then either accepts the state or restores the safety dump as a new, ordinary restore | workers/restore-runner.ts (verifyRowCounts) |
| A restore that ran needs to be undone | Operator restored the wrong backup, or a restore's row-count verification failed | A pre_restore_safety dump was genuinely taken, is in the catalog, and is linked to this specific restore (backup_restore.safety_backup_run_id) | Recovery is still manual, but directly addressable: read safetyBackupPublicId off RestoreLiveStateDto (populated from stage:"safety_dump" onward) and submit it as the source of a new, ordinary restore request — no need to browse the catalog by recency | backup-restore.ts, backup-retention.service.ts |
force-clear used while pg_restore is genuinely still running | Operator force-clears an in-progress restore rather than waiting | Row/maintenance flag clear, but the subprocess keeps running unsupervised | An operator must independently confirm the process has actually stopped before treating the database as stable | workers/backup-restore.processor.ts |
| Maintenance file corrupted | Hand-edited or partially written maintenance.json | MaintenanceService.isActive() fails closed — refuses customer traffic rather than risk serving a half-restored database | Fix or remove the file; getState logs the read failure loudly | maintenance.service.ts |
11. Diagrams Required Per Module
- Actor capability diagram: Section 3.
- High-level module flow diagram: Section 5.1's sequence diagram, representative of the shared dump path.
- Sequence diagram for each major flow: 5.1–5.7, plus 6.4's activity diagram.
- State machine diagrams: Section 7, covering
backup_run.status, maintenance, and the two-track restore state. - Data side-effect diagram: Section 9.
- Error branch diagram: 6.4's prune flowchart, and the restore sequence diagram in 5.4.
12. Mandatory Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Manual backup | Explicit includeUploads:false is honoured even though the default is also false | Superadmin | POST .../backups | Run recorded with includesUploads:false deliberately, not by coincidence | ??, not ` | |
| Manual backup | Under a remote storage driver, a requested includeUploads:true is silently forced to false | Superadmin | Same | Response later shows includesUploads:false | BackupArtifactService.archiveUploads checks StorageManager.hasRemoteDriver() | backup-artifact.service.ts |
| List | sort query param accepted but not applied — only order (on createdAt) is honoured | Superadmin | GET .../backups?sort=... | No error; sort silently ignored | — | backup-admin.service.ts |
| Pin | Unpinning a backup makes it immediately eligible for the next sweep if it's also past its bucket's retain count | Superadmin | PATCH .../backups/{id} | Backup may be pruned on the next daily sweep with no further action | — | backup-retention.service.ts |
| Download | Filename is derived from publicId alone, never the operator-editable note | Superadmin | GET .../download | Prevents CRLF header injection via a crafted note | — | backup-download-admin.controller.ts |
| Download | Download itself fails if its own audit record cannot be written | Superadmin | GET .../download | 500 BACKUP_DOWNLOAD_AUDIT_FAILED rather than a silent, unaudited download | AuditLog.create is not fire-and-forget here, unlike everywhere else in the module | backup-download-admin.service.ts |
| Settings | Editing dumpTimeoutMinutes takes effect on the next dump, with no restart | Superadmin | PUT .../settings | — | Read fresh from settings per dump, not cached | backup-artifact.service.ts |
| Settings | Changing scheduleCron/scheduleTimezone reloads the cron registration live | Superadmin | PUT .../settings | Next nightly run uses the new schedule without a deploy | BackupScheduleScheduler.reload() | backup-admin.service.ts |
| Settings | storageMode of both/remote_only is rejected unless a remote target is configured and remoteReplicationEnabled is also true | Superadmin | PUT .../settings | 400 BACKUP_REMOTE_NOT_CONFIGURED | Prevents an operator believing backups are going off-site when nothing is | backup-settings.service.ts |
| Restore | Both acknowledgement checkboxes must be literally true, checked server-side, not just in the form | Superadmin | POST .../restore | 400 BACKUP_RESTORE_CONFIRMATION_MISMATCH on anything else | — | backup-restore-preflight.service.ts |
| Restore | Live-state polling never touches a database table | Superadmin (polling UI) | GET .../live | Stays answerable even while every table is locked by pg_restore --clean | Reads a file only | backup-restore-admin.service.ts |
| Restore | Runner attaches to an already-running process instead of double-spawning | Worker (BackupRestoreProcessor) | Re-delivered backup.restore job (e.g. after an API restart) | Prevents two concurrent pg_restore processes against the same database | process.kill(pid, 0) liveness check | workers/backup-restore.processor.ts |
| Restore | Connection fence is released before the state file reports completed | Runner | End of a successful restore | The supervisor's own completion query is never blocked by the fence it is about to observe | restore-runner.ts | same |
| Upload | Operator's own manifest.json is never written to disk | Superadmin | POST .../backups/upload | Only two fields are read from it in memory; a fresh, server-generated manifest is always written | Prevents reconcileFromDisk from laundering an editable "kind" claim on the next boot | backup-upload-admin.service.ts |
| Upload | Uploaded rows are pinned and excluded from GFS retention entirely | Superadmin | POST .../backups/upload | An operator who carried an archive in during an incident cannot have it swept away by the nightly sweep | BACKUP_UPLOAD_MAX_RETAINED caps the alternative growth risk | backup-retention.service.ts |
| Upload | Content scan renders schema SQL rather than trusting the TOC | Superadmin | POST .../backups/upload | pg_restore --list cannot distinguish an ordinary trigger function from a SECURITY DEFINER one; the rendered SQL can | 409 BACKUP_UPLOAD_TOC_REJECTED | backup-archive-content.util.ts |
| Restore | A pre-restore safety dump is taken inline and awaited, not enqueued | Worker (BackupRestoreProcessor) | Every restore, before the runner spawns | Restore aborts (maintenance stays engaged) if the safety dump does not reach completed | BackupRunHandler.run() called directly rather than through the outbox | workers/backup-restore.processor.ts |
| Restore | Existing database connections are drained, not killed outright | Runner (restore-runner.ts) | After fencing new connections, before pg_restore | In-flight customer requests (an order write, a payment record) get up to restoreDrainSeconds to finish on their own before being terminated | restoreDrainSeconds — admin-editable, injected into the runner's own process environment since it has no DI | workers/restore-runner.ts |
| Restore | restoreTimeoutMinutes and restoreAbandonSeconds genuinely take effect on the next restore after being saved | Superadmin | PUT .../settings, then any subsequent POST .../restore | The pg_restore timeout and the supervisor's abandon window both reflect the saved values, not the deploy-time environment default | spawnRunner injects the timeout/drain values into the runner's env; supervise takes the abandon window as a parameter from the same settings snapshot | workers/backup-restore.processor.ts |
| Restore | Post-restore row counts are checked against the source backup's own manifest, per table | Runner (restore-runner.ts) | After pg_restore succeeds | A mismatch is reported as BACKUP_RESTORE_VERIFICATION_FAILED, distinct from a generic failure | verifyRowCounts(), exported | workers/restore-runner.ts |
| Restore | A manifest table name is validated against a strict pattern before being interpolated into a query | Runner (restore-runner.ts) | Row-count verification | An unexpected identifier is reported as a mismatch, never queried | /^[a-z_][a-z0-9_]*$/ | workers/restore-runner.ts |
| Restore | The safety dump's id reaches both the response and the backup_restore row | Superadmin (polling UI) | GET .../live during/after safety_dump | safetyBackupPublicId is directly readable from the response; backup_restore.safety_backup_run_id records the same link durably | BackupRestoreProcessor.takeSafetyDump writes both | backup-restore.ts |
| Maintenance | A permission decorator on a route is honoured as a second signal even if actorType cannot be resolved | Any actor | Any request during maintenance | Exempted if either signal says "operator" | MaintenanceGuard.isOperatorRequest | maintenance.guard.ts |
| Maintenance | Five literal, hardcoded exempt paths (health probes, login, refresh, and the public maintenance-status route itself) — never a prefix | Any actor | Any request during maintenance | The operator can always get back in and disengage, even after every session was just invalidated by a restore; the storefront can always learn the state it exists to report | MAINTENANCE_EXEMPT_PATHS | maintenance.guard.ts |
| Maintenance | Public status check clears reason when not engaged, regardless of what the shared state still holds | Guest/customer | GET /api/system/maintenance | Never shows a stale reason from a previous engagement | MaintenanceCustomerService.getStatus | maintenance-customer.service.ts |
12.2 Business Process Diagram Pack
See Section 5 for a sequence diagram per flow and Section 6.4 for an activity diagram. A service blueprint for the manual-backup flow:
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| Uploads are excluded from a backup by default | A database-only dump is dramatically cheaper — measured 21.9 MB vs 623 KB for one run on this database's uploads vs. its rows — and doubling steady-state disk on a single VPS for every deployment is a cost most will never need | An operator restoring a database-only backup gets rows that reference images that may no longer exist — broken images, no error, nothing that fails loudly | BACKUP_INCLUDE_UPLOADS env default, includeUploadsByDefault setting, ?? precedence in resolveIncludesUploads | CreateBackupDto.includeUploads is optional; absence means "use the default" | BackupArtifactService.archiveUploads also force-disables under a non-local storage driver | — |
| The catalog is an index of the filesystem, not the source of truth | pg_restore --clean replaces every table including backup_run itself — a catalog living only inside the database it backs up is worthless in the exact disaster it exists for | An operator can trust the backup history to survive a restore or a database loss, because manifest.json is written to disk beside the artifacts, independent of the row | BackupManifestService.writeManifestFile (before completion), reconcileFromDisk (rebuilds missing rows) | Not directly visible in any response, but is why reconcileFromDisk runs at boot | manifest.json written before the DB row is ever marked complete | backup-manifest.service.spec.ts |
storageMode governs where artifacts live; remote retention is out of scope | The backup host is expected to run its own retention/rotation policy — this feature is a one-way push, never a remote prune | An operator choosing remote_only must separately manage disk on the remote host; this module will never delete anything there | BackupReplicationService.replicate only ever pushes | storageMode in settings is local/both/remote_only | Local artifacts are reclaimed once verified-replicated, unless the run is the overall-newest | — |
| Restore needs two independent gates | A permission can be granted by mistake; an environment variable cannot be granted from inside the running application | An operator cannot restore the database by permission alone — someone with shell access to the box must also have armed BACKUP_RESTORE_ENABLED | Backup_RESTORE (superadmin-only) + BACKUP_RESTORE_ENABLED (deploy-time) | 503 BACKUP_RESTORE_DISABLED if either is missing | BackupRestorePreflightService.assertFeatureEnabled | — |
| The application's DB role must not be a superuser to restore | rolconnlimit fencing (the mechanism that keeps live traffic from contending with pg_restore --clean's locks) is not enforced by Postgres against a superuser | An operator who deployed with an overly-privileged DATABASE_URL is blocked from restoring until it is downgraded — a deliberate friction, not a bug | assertApplicationRoleIsNotSuperuser | 409 BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER | — | — |
--single-transaction is fixed, never configurable | Probed directly: without it, a failed restore mid-way leaves the database in a worse state than before the attempt started | An operator can never accidentally run a restore without this protection | Hardcoded argv in restore-runner.ts | N/A (not exposed anywhere) | — | — |
Maintenance is gated on System_*, not Settings_* or Backup_* | An ordinary content administrator must not be able to take the storefront offline, or worse, disengage maintenance in the middle of a restore | Only superadmin can toggle maintenance | System_READ/System_UPDATE, superadmin-only | MaintenanceAdminController | — | — |
| Three retention floors bind both the scheduled sweep and the manual delete identically | An earlier design enforced the floors in only one path, letting an operator delete every backup down to zero through the other | An operator cannot delete the last restorable backup, the newest in a bucket, or the most recent restore's own safety dump, by any route | BackupRetentionService.assertPrunable, called from both sweep() and the admin remove() | 409 BACKUP_LAST_REMAINING_CANNOT_DELETE / BACKUP_PINNED_CANNOT_DELETE | — | backup-retention.service.spec.ts |
| A restore must not proceed without a completed pre-restore safety dump | Proceeding without a way back trades a few minutes of downtime for an unrecoverable mistake | A restore that cannot safely be backed up first is refused rather than attempted | BackupRestoreProcessor.takeSafetyDump — inline, awaited, checked by row status before spawnRunner is ever called | Surfaces as the restore never advancing past safety_dump in GET .../live, then failed | BACKUP_RESTORE_FAILED | — |
| A restore is verified by row count before being trusted | --single-transaction guarantees the database-level outcome is all-or-nothing, not that every table's data matches what was dumped | An operator is told explicitly, via a distinct error code, when a restore applied but does not match its source | restore-runner.ts's verifyRowCounts() at the verifying stage | 409-shaped outcome surfaced through GET .../live's errorCode | BACKUP_RESTORE_VERIFICATION_FAILED | — |
| An uploaded archive must be signed by this deployment's own key | A checksum alone authenticates nothing when the uploader supplies both the file and the hash in the same request | An operator cannot catalogue an archive from an unrelated or untrusted source, even one that is a structurally valid pg_dump | BackupSignatureService.verify (HMAC-SHA256, timingSafeEqual) | 409 BACKUP_UPLOAD_SIGNATURE_INVALID, 503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED | — | backup-signature.service.spec.ts |
| An uploaded archive's schema SQL is screened before it is trusted | pg_restore --list's table of contents records an object's type, not its properties, and cannot distinguish a SECURITY DEFINER function from an ordinary one | An operator cannot unknowingly catalogue an archive containing a privilege-escalation object | scanArchiveSql, run on pg_restore -f - --schema-only output | 409 BACKUP_UPLOAD_TOC_REJECTED | — | backup-archive-content.util.spec.ts |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Uploads opt-in, off by default | Faster, cheaper backups by default | Roughly halves steady-state disk on a single VPS | Always include uploads | Restoring a database-only backup produces broken image references silently | Mitigated only by the operator's own awareness — no warning is surfaced at restore time, since the row-count verification checks database tables against the manifest, not the presence of referenced upload files |
| Restore accept/execute split into request + async execution | The admin gets an immediate, honest response (202) rather than a held-open connection for a multi-minute operation | Matches the reasoning that forced the execution logic out of the request process entirely — pg_restore --clean's table locks would otherwise deadlock the API against its own auth | Fully synchronous restore in the HTTP handler | The client must poll GET .../live rather than trust the accept response | Includes an automatic pre-restore safety dump and post-restore row-count verification; the remaining gap is linking the safety dump back to the restore, not the execution or safety net itself |
| Verified read-back instead of trusting rsync's exit code | An operator can trust a replicated status actually means the bytes are correct at the far end | Compatible with a write-only deploy key (cannot run remote commands to self-verify) | ssh ... sha256sum on the remote | Costs a full re-download during verification, doubling transfer time for that step | Accepted — correctness over speed for a database backup |
| Remote retention explicitly out of scope | Simpler mental model: this feature ships backups off-site, the receiving host manages its own space | No need to build and secure a remote-delete code path over the write-only transport this module deliberately uses | Have this module also prune remotely | An unmanaged remote host will fill up over time if the operator does not separately configure retention there | Operator responsibility, stated here rather than assumed |
| Uploaded archives authenticated by keyed HMAC, not a checksum | An operator can trust that a catalogued upload really came from a deployment holding the right key, not just any file with a matching hash | Reuses the same BackupSignatureService primitive rather than building a second authentication mechanism | Trust an uploader-supplied checksum | Operators must provision and protect the key file, and keep the key consistent across deployments that move backups between each other | Fails closed (BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED) rather than silently accepting unauthenticated archives |
The signing key lives in an app-owned file, not in .env and not in the database | It can be generated, rotated and pruned by an operator without a redeploy, and a rotation does not orphan existing archives | Not the database — a dump would then contain the key that signs dumps. Not settings.json — that sits inside BACKUP_DIR, so the key would live in the directory it protects | Leave it inline in .env, where rotating it means editing a file on the box and restarting | One more file to back up separately, and it must never be inside BACKUP_DIR or the upload root | Boot refuses an unsafe path or mode; a corrupt file degrades to unreadable, which REFUSES verification rather than skipping it |
| Pre-restore safety dump taken inline and awaited, not enqueued | An operator's mistake is always backed up before it becomes irreversible | The ordering guarantee only holds if the dump finishes before the runner starts draining connections — enqueuing would let the two race | Enqueue the safety dump onto QueueName.BACKUP like any other dump | The restore is blocked on the safety dump's own duration before pg_restore even starts | If the safety dump is ever slow enough to threaten the restore's own timeout budget |
| Maintenance-status route exempted from the guard it reports on | The storefront can always learn a maintenance window is active, even though its own page cache would otherwise hide it | Reuses the existing 3-layer maintenance state read; no new state to maintain | Block it like every other route during maintenance | Discloses that the store is offline to any caller | Judged not sensitive — every other route already answers 503 during the same window |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Manual backup | Double-click "Run now" | Two requests in quick succession | Second is refused as soon as the first is queued — assertNoRunInProgress checks both queued and running, not only running | 409 BACKUP_ALREADY_RUNNING, or a DB constraint violation surfaced as a 500 in the rare race window where both checks land before either insert commits | backup-run.ts |
| Scheduled backup | Tick fires while a manual backup is running | Nightly cron coincides with an in-progress manual run | Same assertNoRunInProgress check applies — the scheduled tick's createRun call is refused and logged, not silently skipped | Logged error, no row created; next tick tries again | backup-schedule.scheduler.ts |
| Retention sweep | Every candidate in a bucket is protected by a floor | Small deployment with few backups | Bucket simply does not shrink that day | No error — the sweep logs 0 pruned for that bucket | backup-retention.service.ts |
| Download | Backup's artifact was reclaimed to remote (remote_only) since it completed | Operator clicks Download on an older remote_only run | Refused — the file is not on this host | 410 BACKUP_ARTIFACT_MISSING | backup-download-admin.service.ts |
| Download | Recorded checksum no longer matches the file on disk | Disk corruption, or an operator tampered with the file | Refused before streaming a single byte | 409 BACKUP_CHECKSUM_MISMATCH | same |
| Restore | Concurrent request while one is already accepted | Two admins both submit a restore | Second is refused | 409 BACKUP_RESTORE_ALREADY_ACTIVE | backup-restore-admin.service.ts |
| Restore | Force-clear called on a restore that is not actually stuck | Any restore, including one genuinely mid-pg_restore | Succeeds regardless — no age threshold is checked, and the subprocess keeps running unsupervised | {cleared:true} | same |
| Restore | API restarts mid-restore | Redeploy while pg_restore is running | The detached runner survives (setsid, unref()); maxStalledCount: 0 stops BullMQ from re-running the handler and spawning a second pg_restore | Restore completes on its own; the supervisor reattaches to the existing runnerPid on any later job delivery | workers/backup-restore.processor.ts |
| Restore | API restarts between the safety dump completing and the runner spawning | Redeploy in a narrow window | On reattach, the state file's stage is safety_dump or later, so takeSafetyDump is not called again — only the branch with no already-alive runnerPid calls it | No duplicate safety dump | workers/backup-restore.processor.ts |
| Restore | Safety dump races a concurrent retention sweep | A sweep tick runs while the safety dump is mid-flight | No conflict — assertPrunable only ever selects completed rows, so a queued/running safety dump cannot be pruned out from under the restore. The safety dump itself is not pinned (unlike an uploaded backup); once completed and linked, it is protected by the most-recent-safety-dump floor (now genuinely functional, since safety_backup_run_id is written) as well as by ordinary count-based retention | No special handling needed | backup-retention.service.ts |
| Replication | The exact same job delivered twice (BullMQ at-least-once) | Retry after a transient worker crash | No-op if already replicated | — | backup-replication.service.ts |
| Public status check | Called during an admin-toggled maintenance window (not a restore) | Operator engages maintenance manually via PUT admin/system/maintenance | Same response shape — the public endpoint cannot distinguish "restore in progress" from "operator maintenance", by design | {active:true, reason:"<operator's text>"} | maintenance-customer.service.ts |
| Upload | Uploading the same archive twice | Operator submits identical bytes again | Succeeds — no dedupe by content; a second kind:'uploaded' row is inserted, eventually capped by BACKUP_UPLOAD_MAX_RETAINED | 201 twice, then 409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHED | backup-upload-admin.service.ts |
| Upload | Archive passes pg_restore --list but is actually truncated | Format check only reads the table of contents at the front | The sha256-vs-manifest comparison catches it, not the format check | 409 BACKUP_CHECKSUM_MISMATCH | same |
isRestorable field | A completed backup whose local artifact has since been reclaimed (remote_only, verified-replicated) | Listing the backup | BackupResponseDto.isRestorable reads only status==='completed' — it does not check localArtifactPresent | The admin UI may show a backup as restorable when a restore attempt would actually fail with 410 BACKUP_ARTIFACT_MISSING at preflight | backup-admin.service.ts (toResponseDto) |
| Maintenance | Restore engages maintenance, then is force-cleared | Operator abandons a stuck restore | Maintenance is explicitly disengaged as part of force-clear, not left engaged | Customer traffic resumes immediately | backup-restore-admin.service.ts |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Take a backup | settings, disk free space | backup_run, outbox_events, manifest.json, artifact files | — | backup.run, maybe backup.replicate | status, databaseDumpBytes, tableCount, totalRows, isRestorable |
| Retention pruning | backup_run per bucket, backup_restore (safety-dump floor) | backup_run.status/prunedAt/artifactDir, filesystem delete | — | backup.prune | (list view reflects pruned status) |
| Download | backup_run, filesystem | MongoDB AuditLog | — | — | streamed bytes, not a JSON response |
| Restore | everything preflight checks; source manifest.json (row-count verification) | backup_restore (including safety_backup_run_id), restore-state.json, maintenance.json, outbox_events, inline backup_run (pre_restore_safety) | maintenance Redis mirror | backup.restore (consumed; takes and links safety dump, spawns restore-runner.ts) | RestoreLiveStateDto.stage, safetyBackupPublicId |
| Replication | backup_run, filesystem | backup_run replication columns, remote host | — | backup.replicate | remoteConfigured, replicationFailureStreak (via settings) |
| Upload | settings.json (retained cap), uploaded manifest/signature | backup_run, filesystem (dump, fresh manifest + signature) | — | — (synchronous) | BackupResponseDto fields, same shape as any other backup |
| Maintenance status (public) | shared maintenance state (cache/Redis/file) | none | — | — | active, reason only |
12.7 Experience Quality Checklist
- The doc explains what the actor is trying to accomplish (each flow's Summary).
- The doc explains what the backend does that the actor does not see (snapshot capture, manifest-before-row-completion, path recomputation for replication, connection fencing).
- The doc covers minor flows and branches (12.1, 12.5).
- The doc includes user (superadmin), worker/system, the detached restore-runner actor, and guest/customer (the one public route).
- The doc explains business logic, tradeoffs, and rationale (12.3, 12.4).
- The doc maps every flow to API routes and backend side effects (Section 9, 12.6).
- The doc includes diagrams appropriate to each flow type (Section 11).
- The doc covers edge cases and failure recovery, including what the restore-safety net now genuinely does (pre-restore dump, row-count verification) and what still is not automated (linking the dump back to its restore), stated plainly rather than glossed over (Section 10, 12.5).
13. Completion Checklist
- Every feature and minor action documented is listed: backup (manual + scheduled), retention pruning, download, upload/catalogue (SE-4), restore (request through genuine execution, safety dump, and row-count verification), replication, maintenance as a supporting flow, and the public maintenance-status check.
- Every actor has allowed and forbidden behavior (Section 3).
- Every major and minor flow includes steps, branches, and diagrams (Section 5, 12.1, 12.5).
- Every lifecycle has a transition table and state diagram (Section 7).
- Every flow links to the API and backend docs.
- Backend-doc dependencies are called out where they shape behavior — particularly the remaining safety-dump-linkage gap, cross-referenced to the backend doc's 16.8 Risk Register.
- The upload endpoint (
admin/system/backups/upload, SE-4) is fully documented in 5.6 and 6.9.
See Also
- API doc:
/docs/developer/backup/api - Backend doc:
/docs/developer/backup/backend - TDD: not present for this module at time of writing.