Skoolsewa - School Management System Docs
Developer ResourcesBackup

Backup Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for backup.

Backup Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/backup/backup.module.ts, admin/backup-admin-aggregate.module.ts, shared/backup-shared.module.ts, workers/backup-worker.module.tsImports, providers, exports, boot-time reconcile.
Controllersadmin/catalog/backup-admin.controller.ts, admin/download/backup-download-admin.controller.ts, admin/restore/backup-restore-admin.controller.ts, admin/upload/backup-upload-admin.controller.ts, admin/key/backup-key-admin.controller.tsRoute ownership, guards, permissions — including the one @Public() route, the poll-token-guarded live restore read.
ServicesAll files under admin/*/, shared/*.service.tsBusiness logic, validation, writes, side effects.
DTOsadmin/catalog/dto/*.ts, admin/restore/dto/restore.dto.ts, admin/restore/dto/restore-lock.dto.ts, admin/download/dto/backup-download-params.dto.ts, admin/upload/dto/backup-upload.dto.tsRequest/response contracts.
Schemapackages/db/src/schema/backup/backup-run.ts, backup-restore.ts, enums.tsTables, enums, constraints, indexes.
Jobspackages/jobs/src/index.ts (BackupJob, BackupRestoreJob, BackupReplicateJob)Queue contracts and payloads.
Workersapps/api/src/modules/backup/workers/*.ts, including backup-restore.processor.ts and restore-runner.tsProcessors, dispatch, schedulers, and the detached restore executor.
Envapps/api/src/config/env.validation.ts (BACKUP_*, PG_*, TAR_PATH, BACKUP_SSH_PATH, BACKUP_RSYNC_PATH)Every environment-only knob.
Structure baselineapps/api/test/structure/structure.baseline.jsonAuthoritative route list — grepped for system/backups, system/restores, system/backup-key; 24 distinct route templates, all under admin/system/*.
Permissionspackages/db/src/authorization/permission-catalog.tsBackup, BackupDownload, BackupConfigure, BackupUpload modules, superadmin-only.
Rate limitsapps/api/src/common/guards/ip-throttler.config.tsADMIN_WRITE (lock release), 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, via BackupRunCreateService / BackupRunHandler.
  • An optional archive of the local upload root (tar -czf), only when the effective storage driver is local.
  • A filesystem catalog (manifest.json per 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).
  • The restore traffic lock (MaintenanceService, MaintenanceGuard). A restore engages it when accepted, marked reconciliation: "pending", and lifts it when it completes cleanly and has reconciled the restored database; while it is engaged, MaintenanceGuard, registered as an APP_GUARD, refuses non-operator traffic. An operator releases a lock a failed restore left engaged; nothing engages it by hand.
  • Admin-editable backup/retention policy stored in settings.json under the backup root (BackupSettingsService), with optimistic concurrency.
  • The request/accept surface for restoring the live database from a backup: preflight validation (BackupRestorePreflightService), a backup_restore audit row, engaging maintenance, writing a live-state file, and enqueueing a restore job (BackupRestoreAdminService).
  • Executing the restore, with a safety net on both sides. BackupRestoreProcessor takes an inline pre-restore safety dump and links it to the restore (backup_restore.safety_backup_run_id) before spawning workers/restore-runner.ts as a detached process — deliberately not an in-process handler, because pg_restore --clean takes ACCESS EXCLUSIVE on every table, including admin_sessions. The runner drains existing connections before terminating them, restores the database, then verifies restored row counts against the source manifest. BackupRestoreProcessor supervises the whole run by polling restore-state.json until 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 a kind:'uploaded' backup_run row 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

  • General MongoDB backup/restore. A run copies exactly one collection — audit_logs, via BackupAuditLogService (§6.8c) — as an opt-in, insert-only complement to the PostgreSQL dump that remains the module's actual subject; no other MongoDB collection is ever touched. RestoreBackupDto.acknowledgeMongoNotRestored is deprecated and ignored, to be removed after 2026-12-31 — the audit-log merge it used to require acknowledging now runs automatically and needs no confirmation, since it only ever inserts entries missing from the live log and never removes or overwrites one. It is still accepted on the request so an older client is not rejected with a 400 while it carries the field.
  • Storage driver implementation (local vs. bucket) — that is @skoolsewa/storage's StorageManager, which this module only reads hasRemoteDriver() from.
  • BullMQ queue registration and defaultJobOptions — QueueName.BACKUP, QueueName.BACKUP_RESTORE and QueueName.BACKUP_REPLICATE are registered in apps/api/src/services/bullmq/bull.module.ts's REGISTERED_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-backed activity log) and AuditLog (MongoDB) are separate modules this one calls into.
  • Permission grants — Backup_*, BackupDownload_*, BackupConfigure_* are declared in packages/db/src/authorization/permission-catalog.ts and synced by pnpm --filter @skoolsewa/api permissions:sync; this module only checks them via @Permissions(...).

Source of Truth

ConcernSource of TruthNotes
Backup catalog (queryable)backup_run tableRebuildable from disk; never the only copy of the truth.
Backup catalog (durable)manifest.json beside each run's artifactsWritten 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 stagerestore-state.json under the backup rootbackup_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 policysettings.json under the backup rootNot 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 flagmaintenance.json under the backup root, mirrored to Redis, cached in-process 1sThe file is the only layer that survives both an API restart and a restore replacing the database.
Environment-only configurationConfigService reading apps/api/src/config/env.validation.tsBinary paths, BACKUP_DIR, replication target/credentials, BACKUP_RESTORE_ENABLED — never admin-editable. See Section 6.6 in the API doc for the full split.
IdentityAuthUser from JwtAuthGuard/RoleGuardAdmin-only surface. MaintenanceGuard decides operator traffic from the handler's metadata — not @Public(), and a non-empty @Permissions(...) — not from the actor.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
BackupModuleAggregate + OnModuleInitapps/api/src/modules/backup/backup.module.tsNoneNone (injects BackupManifestService)BackupSharedModuleWires BackupSharedModule, BackupAdminAggregateModule and BackupWorkerModule; runs reconcileFromDisk() once at boot.
BackupSharedModule@Global() leafshared/backup-shared.module.tsNonePath, state-file, settings, maintenance, manifest, artifact, signature, key-store, signature-resolver, replication, retention, run-create, restore-preflight servicesAll of the aboveEverything the admin, worker and guard layers need; global because APP_GUARD-registered MaintenanceGuard cannot otherwise inject from it.
BackupAdminAggregateModuleAggregateadmin/backup-admin-aggregate.module.tsNoneNone5 leaf modulesComposes 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 ADMIN_MODULES (config/swagger/swagger-documents.ts) — listing the aggregate alone leaves its routes live and invisible.
BackupAdminModuleLeafadmin/catalog/backup-admin.module.tsBackupAdminControllerBackupAdminServiceServiceList/detail/create/pin/prune, settings read+write (P1).
BackupDownloadAdminModuleLeafadmin/download/backup-download-admin.module.tsBackupDownloadAdminControllerBackupDownloadAdminServiceServiceStreaming the dump (P2), separately permissioned.
BackupRestoreAdminModuleLeafadmin/restore/backup-restore-admin.module.tsBackupRestoreAdminControllerBackupRestoreAdminService, BackupRestoreLockServiceServiceRestore request/accept surface, live state and force-clear (BackupRestoreAdminService); the traffic lock read and release (BackupRestoreLockService). Imports RoleModule, ActivityModule, OutboxModule.
BackupUploadAdminModuleLeafadmin/upload/backup-upload-admin.module.tsBackupUploadAdminControllerBackupUploadAdminServiceServiceSE-4: catalogues an operator-supplied archive so restore/ can act on it, unchanged.
BackupKeyAdminModuleLeafadmin/key/backup-key-admin.module.tsBackupKeyAdminControllerBackupKeyAdminServiceServiceSigning-key custody: generate, reveal-once, rotate, import, prune, resolve. Its own BackupKey_* permission module, superadmin-only, on the admin/system/backup-key prefix.
BackupWorkerModuleLeafworkers/backup-worker.module.tsNoneBackupQueueProcessor, BackupRestoreProcessor, BackupRunHandler, BackupPruneHandler, three schedulers, BackupReplicateQueueProcessor, BackupReplicationHandlerBackupScheduleSchedulerAsync 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-lock.service.ts     (traffic lock read + release)
      backup-restore-admin.module.ts
      dto/restore.dto.ts
      dto/restore-lock.dto.ts          (RestoreLockDto, RestoreLockReleaseDto)
      restore-poll-token.guard.ts
      backup-restore-admin-lock.integration.spec.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)
  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
FilePurposeKey ExportsNotes
backup.constants.tsNames, layout, thresholdsBACKUP_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_PATTERNEverything an operator tunes is env-only; this file is layout/names only.
backup-path.service.tsOwns every filesystem path; refuses to hand out a path that escapes the rootBackupPathServiceBoot-time refuses if BACKUP_DIR is inside a statically-served root; warns if inside the git checkout.
backup-artifact.service.tsSpawns pg_dump/tar, hashes outputBackupArtifactService, BackupSubprocessError.partial + rename; process-group kill on timeout; credentials via child env, never argv.
backup-manifest.service.tsSnapshot capture (REPEATABLE READ + pg_export_snapshot()), manifest.json, disk reconciliationBackupManifestService, BackupRunManifestFilereconcileFromDisk runs at boot, and is idempotent via ON CONFLICT DO NOTHING.
backup-settings.service.tsAdmin policy surface, Zod-validated on read and writeBackupSettingsService, BackupSettingsNever throws on read — falls back to env-seeded defaults.
backup-retention.service.tsGFS count-based pruning + 3 protective floorsBackupRetentionServiceSame floors bind the scheduled sweep and the manual admin delete.
backup-run-create.service.tsThe one path that inserts a backup_run row and enqueues backup.runBackupRunCreateServiceShared by "Run now" and the nightly scheduler.
backup-replication.service.tsrsync-over-ssh push, local read-back verificationBackupReplicationServiceRefuses to boot with replication enabled unless --secluded-args is supported and known_hosts is pinned.
backup-restore-preflight.service.tsEvery check a restore must pass before a row is insertedBackupRestorePreflightServiceFeature flag, superuser check, acknowledgements, typed database name, schema tag match, artifact integrity (size then sha256).
backup-signature.service.tsKeyed HMAC sign/verify for artifactsBackupSignatureServiceHMAC-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.tsPrepares both downloadsprepareDownload, prepareUploadsDownload, buildTarStream, openUploadsStreamThe 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.tsOwns the key file and its lifecycleBackupKeyStoreServiceMigrate 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.tsRecords which held key verifies each completed runBackupSignatureResolverServiceBatched; re-examines signed_unknown_key rows, because importing a key changes that answer.
backup-archive-content.util.tsScans a dump's rendered schema SQL for constructs a restore must not executescanArchiveSqlPure; 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.tsMulter storage engine for the 3-part upload (file, manifest, signature)BackupUploadStorageEngineStreams 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.tsThe restore traffic lockMaintenanceService, MaintenanceSnapshot, MaintenanceGuard3-layer read (in-process cache → Redis → file); every write serialised in one in-process critical section, the conditional ones (engageIf, disengageIf, setReconciliation) re-reading the file inside it; deny-by-default guard. Written only by the restore path — see 7.6a.
backup-tier.util.tsPure GFS tier-boundary calendar mathcomputeBackupTierPeriodBoundaries, formatBackupTierWeekKeyNo NestJS import; hand-rolled UTC arithmetic because date-fns reads the process's own zone, not the argument.
backup-subprocess-excerpt.util.tsRedacts subprocess stderr to a storable excerptsanitizeSubprocessExcerptShared 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.ts

5.2 Tables

backup_run

ColumnTypeNullableDefaultConstraintNotes
idserialNogeneratedPKInternal only — never in a response.
public_iduuidNouuid7()uniqueThe only identifier ever exposed.
kindbackup_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; pre_restore_safety is written only by BackupRestoreProcessor.takeSafetyDump, inline and synchronously, before a restore's runner ever spawns — see 7.5.
tierbackup_tier (daily|weekly|monthly)Yes—chk_backup_run_tier_matches_kindNon-null iff kind = 'scheduled'; assigned at INSERT, not at completion — see enums.ts.
statusbackup_status (queued|running|completed|failed|pruned)Noqueuedseveral CHECKs belowpruned is terminal and distinct from delete — the row survives.
pinnedbooleanNofalse—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_uploadsbooleanNofalsechk_backup_run_uploads_artifact_when_includedEffective value is requested AND driverIsLocal; a safety dump forces it to match the run it protects.
notetextYes——Operator note, ≤200 chars at the DTO.
storage_modebackup_storage_mode (local|both|remote_only)Nolocal—Recorded per run so history stays readable after a settings change.
replication_statusbackup_replication_status (not_requested|pending|replicated|failed)Nonot_requested3 CHECKs belowfailed here is not a failed backup — the dump is still usable.
replicated_attimestamptzYes—chk_backup_run_replicated_has_timestampSet only once bytes are verified by read-back.
remote_artifact_dirtextYes—chk_backup_run_replicated_has_remote_dirSurvives a local prune.
local_artifact_presentbooleanNotruechk_backup_run_local_absent_only_when_replicatedDistinct from artifact_dir — manifest.json never leaves under remote_only.
artifact_dirtextYes—never set to non-null once prunedRelative to the backup root; never accepted from a request, never in a response.
database_dump_bytesbigintYes—chk_backup_run_bytes_non_negative—
database_dump_sha256textYes—chk_backup_run_sha256_format (^[0-9a-f]{64}$)Integrity gate for download and restore.
uploads_archive_bytes / _sha256bigint / textYes—same shape/non-negative rulesOnly set when includesUploads produced a real archive. For uploads_format = 'snapshot', _sha256 is the sha256 of uploads.index.json, not of any single archive file.
uploads_formatbackup_uploads_format (tar_gz|snapshot)Yes—chk_backup_run_uploads_format_when_includedtar_gz — one archive, the format every run wrote before incremental snapshots landed. snapshot — a file tree where files unchanged since the previous snapshot are hard links. NULL when the run has no uploads. A backfill migration set every pre-existing run with a non-null uploads_archive_sha256 to tar_gz.
uploads_file_count / uploads_stored_bytes / uploads_skipped_countinteger / bigint / integerYes—chk_backup_run_uploads_snapshot_counts (all three non-null and >= 0 when format is snapshot), chk_backup_run_uploads_snapshot_only_fields (file count and skipped count are NULL for any other format)uploads_stored_bytes is bytes this run actually wrote — copied, not hard-linked; uploads_skipped_count counts entries deliberately left out (symlinks, sockets, devices, unsafe names).
audit_logs_statebackup_audit_logs_state (included|failed)Yes—chk_backup_run_audit_logs_state_requires_artifact (non-null only once status is completed/pruned)NULL means the run was never meant to carry an audit-log copy — older runs, uploaded archives, and pre_restore_safety dumps.
audit_logs_documents / _bytes / _stored_bytes / _sha256bigint / bigint / bigint / textYes—chk_backup_run_audit_logs_included_shape (all four non-null, non-negative, and _sha256 ~ '^[0-9a-f]{64}$', only when audit_logs_state = 'included')_bytes is the logical size of every exported segment; _stored_bytes is what this run actually wrote — a sealed day reused by hard link is not counted again. _sha256 is the sha256 of audit-logs.index.json.
audit_logs_error_codetextYes—chk_backup_run_audit_logs_failed_shape (^BACKUP_[A-Z_]+$, and requires every other audit_logs_* column NULL, only when audit_logs_state = 'failed')BACKUP_AUDIT_LOGS_UNAVAILABLE for a connectivity-class Mongo error, BACKUP_AUDIT_LOGS_EXPORT_FAILED for anything else.
replication_transferred_bytesbigintYes—chk_backup_run_replication_transferred_bytes (>= 0)rsync's transferred-file total for the last successful replication; files already on the remote from an earlier run's hard links are not sent again.
postgres_versiontextYes——Captured inside the snapshot transaction.
schema_migration_tagtextYes——Latest drizzle.__drizzle_migrations hash at snapshot time; compared on restore.
manifestjsonb (BackupManifest)Yes——{ tables, postgresVersion, schemaMigrationTag, capturedAt } — table-name to row-count map from the snapshot.
error_message / error_codetextYes—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_iduuidYes—FK → admin_users, ON DELETE SET NULLRESTRICT would block admin offboarding permanently.
requested_by_emailtextYes—chk_backup_run_manual_has_actorNOT NULL is enforced only for kind = 'manual', by CHECK rather than by column nullability — cron has no actor.
started_at / finished_at / pruned_attimestamptzYes—chk_backup_run_terminal_has_finished_at, chk_backup_run_timestamps_ordered, chk_backup_run_pruned_stateBusiness timestamps, not updated_at — any later write would overwrite that.
signature_statebackup_signature_state (unsigned|signed|signed_unknown_key)Yes—4 CHECKs belowNULL means "not resolved yet", which is not the same as unsigned.
signing_key_fingerprinttextYes—chk_backup_run_signing_key_fingerprint_shape (^[0-9a-f]{8}$), chk_backup_run_signed_has_fingerprintThe first 8 hex characters of a labelled digest of the key that signed the artifact. Never the key.
created_at / updated_attimestamptzNonow()—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 worker concurrency: 1.
  • chk_backup_run_completed_has_artifact — gated on completed only; disjunction between (localArtifactPresent AND artifactDir non-null) and (NOT localArtifactPresent AND replicationStatus = 'replicated') is what makes remote_only representable.
  • idx_backup_run_stalled — partial index on started_at WHERE status = 'running', read by BackupSweepScheduler.
  • 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, and IS DISTINCT FROM is 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. An unsigned row 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. A queued row with a signature state is a row somebody wrote out of order.
  • chk_backup_run_signing_key_fingerprint_shape — 8 lowercase hex characters, matching what BackupKeyStoreService computes. Same reasoning as chk_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 on signing_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

ColumnTypeNullableDefaultConstraintNotes
idserialNogeneratedPK—
public_iduuidNouuid7()unique—
source_backup_run_idintegerNo—FK → backup_run, ON DELETE RESTRICTInert in normal operation — pruning marks rows, never deletes.
safety_backup_run_idintegerYes—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_sourceWritten 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. The UPDATE matches only while the row's status is non-terminal; a row closed meanwhile (force-cleared during the dump) skips the job before the runner spawns, and a missing row aborts the restore 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.
statusbackup_restore_status (queued|safety_dump|restoring|verifying|completed|failed)Noqueuedchk_backup_restore_terminal_has_finished_atOnly 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_engagedbooleanNofalse—Set true at submit time by BackupRestoreAdminService; disengaged by BackupRestoreProcessor.finish/fail or by force-clear.
verificationjsonb (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_codetextYes—chk_backup_restore_failed_has_errorWritten 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_OUTBOX_REKEY_FAILED if re-keying the restored pending outbox rows threw, BACKUP_RESTORE_FAILED for every other runner failure, or BACKUP_RESTORE_ABANDONED if the heartbeat goes stale past BACKUP_RESTORE_ABANDON_SECONDS. The runner itself decides which of the first three codes to write, keyed off the re-key flag, then the stage that was active when the exception was thrown.
requested_by_admin_user_iduuidYes—FK → admin_users, ON DELETE SET NULL—
requested_by_emailtextNo——Unlike backup_run, always populated — a restore always has an actor.
heartbeat_attimestamptzYes——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_pidintegerYes——Same — the live PID is in the state file (BackupRestoreProcessor/restore-runner.ts both write runnerPid there), never in this column.
started_at / finished_attimestamptzYes—chk_backup_restore_timestamps_ordered—
created_at / updated_attimestamptzNonow()——

Notable CHECK constraints:

  • uq_backup_restore_single_active — partial unique index on (true) WHERE status NOT IN ('completed','failed'). Two concurrent restores would race pg_restore against itself; POST .../force-clear is 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

MethodCalled ByReadsWritesSide EffectsErrors
onModuleInitNest lifecycleenv, filesystemcreates root dir (mode 0700)Refuses boot if BACKUP_DIR is inside a served static rootthrows (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()BackupRunCreateServicestatfs on the root's filesystem———

6.2 BackupArtifactService

MethodCalled ByReadsWritesSide EffectsErrors
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 timeoutBackupSubprocessError (timeout|authentication|connection|not_found|unknown)
archiveUploads({artifactDir})BackupRunHandlerStorageManager.hasRemoteDriver(), TAR_PATH<artifactDir>/uploads.tar.gzspawns tar; returns null under a remote storage driversame
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

MethodCalled ByReadsWritesSide EffectsErrors
captureAndRun(work)BackupRunHandlerpg_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 durationrethrows 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 demandscans <root>/<yyyy>/<mm>/<publicId>/manifest.jsoninserts missing backup_run rowsON CONFLICT (public_id) DO NOTHING — idempotentlogged 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

MethodCalled ByReadsWritesSide EffectsErrors
getEffectiveSettings()almost every backup servicesettings.json (via state-file service)—Never throws — falls back to env-seeded defaults on an unreadable or invalid file—
updateSettings(input, expectedVersion)BackupAdminService.updateSettingscurrent settingssettings.jsonOptimistic concurrency: 409 BACKUP_SETTINGS_CONFLICT on a version mismatchBadRequestException 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

MethodCalled ByReadsWritesSide EffectsErrors
sweep()BackupPruneHandler (reason: "scheduled")settings.json (once, for the whole sweep), backup_run per bucketdeletes artifact dirs, updates rows to prunedPer-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=falseNever 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=nullNo-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-dumpConflictException (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.

Pruning a run frees only the disk it does not share. pruneRun deletes exactly one run's own artifact directory (rm(artifactDir, {recursive:true, force:true})) and nothing else. A snapshot- format uploads file, or an audit-log segment, that this run hard-linked from an earlier base is a second directory entry pointing at the same inode — removing this run's directory removes only its own name for that inode, and the file's bytes are freed only once every run still holding a link to it is pruned too. So the bytes a du of one run's directory reports, and the bytes actually reclaimed by pruning that one run, are two different numbers whenever incremental hard-linking is in play; the gap is exactly the bytes a later run is still linking from it.

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 → the backup's schema_migration_tag equals the live database's latest drizzle.__drizzle_migrations.hash, read at preflight (a null or "unknown" tag on either side refuses) → 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

ConditionOutcome
The key store is unreadableREFUSE (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 signedProceed, logging that none was ever recorded for it.
No artifact.sig, but the row is recorded as signedREFUSE (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 keyProceed, 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 heldREFUSE (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." getState() throws on the same file instead; the lock read catches that and reports the lock engaged with reason: "unreadable". readSnapshot() reads the file past every cache and reports { readable: false } instead of throwing.

Writes, all serialised through one promise chain in the process:

MethodWritesCondition, checked inside the critical section against the file
engage(input)active: true, reason, restorePublicId, engagedBy, engagedAt, optional reconciliationnone
engageIf(input, refuse)the same as engagerefuse(snapshot) returns null; returns { engaged, refusal }. The submit path passes restoreEngageRefusal, which refuses an unreadable file and a lock naming another restore with reconciliation pending or reconcile_failed
disengageIf(engagedBy, refuse)active: falsethe file holds an engaged (or unreadable) lock and refuse(snapshot) returns null; returns { released, refusal }
setReconciliation(restorePublicId, value)the same state with reconciliation set, or removed for nullthe lock is engaged and still names restorePublicId; returns whether it wrote

The critical section is what makes release a compare-and-clear: a restore engaging between a release's checks and its write cannot be wiped. One process is the file's only writer — the API and the backup workers share it, and the detached runner writes only restore-state.json — so an in-process lock is sufficient; a file rename has no compare-and-swap and Redis is only a mirror.

Its callers are MaintenanceGuard, BackupRestoreAdminService (requestRestore, forceClear), BackupRestoreLockService (getLockState, releaseLock, settleClosedBeforeEngage) and BackupRestoreProcessor (finish, fail, resumeAfterRestart); there is no other writer.

6.8 BackupSignatureService

MethodCalled ByReadsWritesSide EffectsErrors
hasCurrentKey() / hasAnyKey()upload, restore preflightBackupKeyStoreService—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 themreturns { 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.sigSigns 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.

A backup is signed, not encrypted. signArtifacts writes an HMAC-SHA256 over "<dumpSha256>:<manifestSha256>" to artifact.sig — an authenticity control that proves which deployment produced an artifact and that neither the dump nor the manifest has been altered since. It does nothing to the confidentiality of database.dump, uploads/, or audit-logs/*.jsonl.gz themselves: pg_dump's output is the database's own plaintext logical dump (gzip'd where the format compresses it, never encrypted), the uploads snapshot is a plain file tree, and an audit-log segment is gzip'd EJSON. Anyone who can read BACKUP_DIR, the download response, or a replicated remote copy can read the backup's contents outright; the signature only ever answers "is this genuinely ours and unaltered," never "who is allowed to read this."

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.

MethodReadsWritesNotes
onModuleInitBACKUP_ARTIFACT_HMAC_KEY_PATH, BACKUP_ARTIFACT_HMAC_KEYthe key fileMigrates 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 fileEach 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.8b BackupUploadsSnapshotService

Snapshots the upload root into a run's artifact directory as a file tree, <artifactDir>/uploads/, alongside a uploads.index.json that records every file's relative path, size, mtimeNs, ctimeNs and sha256. A file whose size and both timestamps still match the base run's index entry is hard-linked to the base's own copy after its bytes are re-hashed and verified against the recorded sha256 — size and timestamps only prove the source looks unchanged, not that the base copy on disk is still intact (bit rot, an operator edit, a bad restore), and a link would otherwise hand back corrupted bytes under a checksum this run never actually checked. A hash mismatch falls back to an ordinary copy and counts toward one aggregate WARN logged at the end of the run. Everything else is streamed to <dest>.partial and hashed in the same pass (HashingPassThrough), then renamed into place — never a second read of the finished file.

Because a link only ever adds a second name for the same inode and this service never writes through one, every run directory stays a complete, independent copy of the upload tree on its own — pruning an old run never corrupts a newer one's files, and restoring uploads from one run never depends on another run still existing.

snapshot(...) returns null when StorageManager.hasRemoteDriver() — snapshotting the local upload root is meaningless once uploads live on a remote object store. Any defect in the base run's own index — missing, sha256 mismatch, invalid JSON, an unexpected shape, or an unsupported BACKUP_INDEX_VERSION — makes the whole base index unusable, logged once, and every file for this run is copied rather than linked; a single bad entry does not fail the run, it just costs the disk space a full copy costs. File operations run at a concurrency of 8 (CONCURRENT_FILE_OPERATIONS) through an internal gate that stops scheduling new work and rethrows the first error once anything fails, after letting already-scheduled work settle so nothing keeps writing into a directory that is about to be deleted. A symlink, socket, FIFO, device, or a file name that fails UTF-8 validation, does not round-trip through re-encoding, or contains a control character is skipped and counted in skippedCount rather than failing the run — uploadsSkippedCount on BackupResponseDto is how an operator learns the snapshot is not the whole directory. On any failure the partially-written uploads/ directory and index are removed before the error propagates, so a failed run never leaves a half-linked tree for the next run to link from.

Which earlier run is offered as a base is decided by BackupIncrementalBaseService, described next — this service only ever sees a base it is handed, and copies everything when handed null.

Restoring uploads is a manual step, not something a restore automates. restore-runner.ts and BackupRestoreProcessor (§7.5) act on database.dump alone — nothing in the restore path reads uploads/ or uploads.index.json at all. An operator who needs the uploaded files back downloads the run's uploads snapshot via GET .../download/uploads (§7.8, Download) and copies its contents onto the upload root by hand. This mirrors why uploads restore is out of scope the same way general MongoDB restore is (see Does Not Own): a database restore is the operation that must be fast, scriptable and blocking, since it holds ACCESS EXCLUSIVE locks and engages maintenance; restoring a filesystem tree has neither property and is left to the operator's judgment about whether the current upload root should be overwritten, merged, or left alone.

6.8c BackupAuditLogService

Exports the MongoDB audit_logs collection as one gzip'd, canonical-EJSON segment per UTC day — <artifactDir>/audit-logs/<YYYY-MM-DD>.jsonl.gz — alongside an audit-logs.index.json recording each day's document count, byte size, sha256, and whether it is sealed. A day is grouped by $dateToString over the ObjectId-derived timestamp; every document is streamed through gzip and a hashing pass in one pipeline, so nothing is buffered whole in memory.

A day is sealed — eligible for the next run to hard-link instead of re-exporting — once it ended at least BACKUP_AUDIT_SEAL_LAG_MINUTES (default 15) before the export runs, which absorbs clock skew between whatever process stamped a document's _id and the exporter itself. A sealed day is only actually reused when its live document count in Mongo still matches what the base run recorded for it — a late write into an already-sealed day forces a fresh export of that one day rather than silently omitting the late document — and, as with uploads, the base segment's bytes are re-hashed and confirmed against its recorded sha256 immediately before the link() call. Every BACKUP_AUDIT_FULL_EXPORT_DAYS (default 30), the run performs a full export of every day rather than reusing any sealed segment, so documents the collection's own TTL index has since expired drop out of the backup's index instead of being carried forward as hard links to files that no longer correspond to anything live.

Extending audit retention. MongoDB's TTL index removes an audit_logs document once it expires, but a segment already exported into a backup keeps that document's bytes on disk independently of the live collection — so a backup taken before expiry is how an audit trail outlives its own TTL. Restoring a run's segments merges by _id, so a chain of periodic backups is what lets an operator recover an entry the live collection has already dropped.

export() is skipped entirely — via BackupRunHandler.exportAuditLogs — for a pre_restore_safety run (the restore's own merge is additive and needs no inverse to protect against) and whenever BACKUP_INCLUDE_AUDIT_LOGS is not true. A genuine export failure — Mongo unreachable, an authentication error, or any other exception — is absorbed into audit_logs_state = 'failed' plus audit_logs_error_code, and the run still completes: the Postgres dump is what a backup exists for, and an unreachable audit store must not cost it. BACKUP_AUDIT_LOGS_UNAVAILABLE is used for connectivity-class driver errors (MongoServerSelectionError, MongoNetworkError, MongoNetworkTimeoutError, MongoNotConnectedError, MongoTopologyClosedError, and MongoServerError codes 18/13 — authentication failures); anything else is BACKUP_AUDIT_LOGS_EXPORT_FAILED. A deadline expiry (the run's overall abort signal) is not absorbed — the run is genuinely out of time and fails as a whole, the same as a database-dump timeout.

merge(...) runs during restore, described in 7.5: it verifies every segment's sha256 against the index before inserting anything from any of them, then streams each segment through gunzip and inserts by insertMany({ ordered: false }) against the driver collection directly — never the Mongoose model, whose validate hook would restamp expiresAt. A document whose _id already exists is caught as a bulk-write error (Mongo code 11000) and counted as alreadyPresent; anything else rethrows. A document whose backed-up expiresAt has already passed by the time of the merge is skipped and counted as expiredSkipped rather than inserted, so a restore never reintroduces a document the collection's own TTL would immediately remove again. The merge is insert-missing-only — it never deletes and never overwrites an existing document — so restoring a backup can only add audit history back, never make the live trail disagree with what actually happened after the backup was taken.

6.8d BackupIncrementalBaseService

Decides which earlier backup_run a new run's uploads snapshot and audit-log export may hard-link from — uploadsBase(excludeRunId) and auditLogsBase(excludeRunId) are asked independently, because the most recent completed run is often a pre_restore_safety dump with no audit copy, or a manual run with no uploads, and forcing either to be everyone's base would force a full copy of whatever it happens to lack.

A candidate must be completed, still localArtifactPresent, not kind: 'uploaded' (an operator- supplied archive was never snapshotted the same way), and — for uploads — recorded uploadsFormat: 'snapshot', or — for audit logs — auditLogsState: 'included'. Passing that filter is not enough on its own: the candidate's artifact.sig must verify against a currently-held signing key over "<recorded dump sha256>:<sha256 of manifest.json's bytes on disk>", and the base's index digest is then read only from that verified manifest.json — never from the backup_run row, which a reconcileFromDisk pass may have rebuilt from a file nobody re-verified. This matters because linking inherits the base's bytes and the digests recorded for them, and the new run signs over those inherited digests with the current key — so a base has to be authentic, not merely present, or anyone able to write inside the backup root could plant or alter a run and have the next scheduled backup unknowingly re-sign it as legitimate. The base run's database dump is never itself linked or re-verified here — only its uploads snapshot and audit segments, each of whose own files are hashed and checked individually before any link is created.

When this deployment holds no signing key at all (BackupSignatureService.hasAnyKey() is false), nothing can ever verify as a base — logged once per process — and every run copies uploads and exports audit logs in full. That is the same condition documented in BackupSignatureService: a deployment can run without a key, it just never gets the incremental hard-linking win.

6.9 BackupUploadAdminService

MethodCalled ByReadsWritesSide EffectsErrors
upload(input)BackupUploadAdminController.uploadsettings.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")

StepCode PathBehaviorFailure Case
1BackupAdminController.createAccepts CreateBackupDtoDTO validation error (400)
2BackupRunCreateService.createRunChecks backupEnabled, no run in progress, disk space503 BACKUP_DISABLED, 409 BACKUP_ALREADY_RUNNING, 503 BACKUP_INSUFFICIENT_DISK_SPACE
3Outbox → BackupQueueProcessor → BackupRunHandler.runClaims the row, dumps, archives, writes manifest, completesmarkFailed on any exception; errorMessage stores only the sanitised excerpt
4BackupRunHandler (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 BACKUP_TIMEOUT_MINUTES + 5 as failed (using idx_backup_run_stalled). That half is skipped while restore-state.json shows a restore in a non-terminal stage whose heartbeat is within BACKUP_RESTORE_ABANDON_SECONDS: pg_restore --clean brings back a catalog in which the source run reads running with an old started_at, and reaping it before the post-restore reconcile would fail that run permanently. A heartbeat older than the abandon window means the runner is dead — an operator force-clear settles the restore row without rewriting the file — so the sweep resumes. The other half marks 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:

  1. Reads restore-state.json for state.sourceArtifactDir — written by BackupRestoreAdminService.requestRestore at submit time (source.artifactDir ?? "").
  2. Fences the application's DB role with ALTER ROLE "<appRole>" CONNECTION LIMIT 0 (not ALTER 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.
  3. Drains before terminating: polls pg_stat_activity for the application role's remaining connections every second, heartbeating restore-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 is BACKUP_RESTORE_DRAIN_SECONDS — admin-editable as restoreDrainSeconds, injected into the child process's own environment by spawnRunner from the effective settings at spawn time (see below), not read from the raw environment. Whatever is still connected when the window closes is terminated with pg_terminate_backend — a courtesy with a deadline, not a negotiation, since pg_restore --clean needs ACCESS EXCLUSIVE and will not get it while a session holds a lock.
  4. Runs pg_restore --clean --if-exists --single-transaction --no-owner --no-privileges against BACKUP_ADMIN_DATABASE_URL, with a timeout from BACKUP_RESTORE_TIMEOUT_MINUTES — same admin-editable/env-injected pattern as the drain window (restoreTimeoutMinutes). --single-transaction is 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.
  5. Re-keys the restored outbox with rekeyRestoredOutbox(admin) (workers/rekey-restored-outbox.ts): UPDATE public.outbox_events SET dispatch_key = gen_random_uuid() WHERE status = 'pending' on the runner's admin connection, while the connection fence still holds. A restored pending row carries the dispatch_key it had at dump time, and the BullMQ job id is outbox-<dispatch_key>; if the live database dispatched that row after the dump, Redis may still hold the finished job, and relaying the restored row under the same id would return that job and drop the work. Only pending rows — the set the dispatcher claims — are re-keyed; a dispatched row keeps its key because report maintenance looks its live job up by it. When information_schema.columns shows no dispatch_key on public.outbox_events — a dump taken before migration 0059_outbox_dispatch_key — the function skips the UPDATE and returns 0; the restored migrations table then lacks 0059, and the next pnpm db:migrate re-applies it, whose DEFAULT gen_random_uuid() gives every row a fresh key. The count is set on the runner's state as outboxRekeyed, so every later write of restore-state.json — heartbeat, verifying, completed and failed — carries it.
  6. Moves to stage:"verifying" and calls the exported verifyRowCounts(root, sourceDir, adminUrl): reads manifest.json's manifest.tables (the source backup's own recorded per-table counts) and, for every table it names except those in RESTORE_VERIFICATION_EXCLUDED_TABLES (admin_sessions, customer_sessions, backup_run, backup_restore, outbox_events, drizzle_migrations), runs SELECT count(*)::bigint FROM public."<table>" against the just-restored database and compares. The exclusion exists because the restore itself changes those tables after pg_restore returns — it truncates the session tables, re-keys the pending outbox rows, and rebuilds its own catalog rows — and backup_run in 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 outer catch reports as BACKUP_RESTORE_VERIFICATION_FAILED specifically (see below), not the generic failure code.
  7. Heartbeats restore-state.json every 5 seconds (stage, runnerPid, heartbeatAt) throughout, so GET .../live can show real progress.
  8. 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. The finally block also unconditionally releases, on every other exit path.

The catch block distinguishes three error codes. A throw from the outbox re-key gets BACKUP_RESTORE_OUTBOX_REKEY_FAILED (the restore applied, but pending outbox rows may still share a job id with a job in Redis); otherwise the stage executing at the moment of the throw decides — 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. The re-key runs while the stage is still restoring, and BACKUP_RESTORE_FAILED tells restoreMayHaveApplied the database is untouched, so a re-key failure must not share it: the supervisor would skip the reconciliation a replaced database owes. All three are read at the throw site in restore-runner.ts's main() catch block, not guessed from behavior.

A job whose row is closed never runs. The outbox job commits with the row and arrives later, whatever happened to the row in between: force-clearing a queued restore closes the row and lifts the lock, because the database is untouched. So the first thing process() does — before it reads restore-state.json or spawns anything — is read the job's backup_restore row. When the row is missing or its status is not in RESTORE_NON_TERMINAL_STATUSES, it logs restore <id> is closed (<status>); job skipped as a warning and returns. It does not call fail() and writes nothing to the row, the state file or the lock: whoever closed the row already settled all three. It returns rather than throws, because with attempts: 1 and maxStalledCount: 0 a throw only adds a failed BullMQ job. resumeAfterRestart carries no such check, deliberately: it never spawns a runner, a restore force-cleared after it may have applied keeps its pending marker precisely so the restarted API settles it, and its row may be gone, replaced by pg_restore itself.

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> AND status IN ('queued', 'safety_dump', 'restoring', 'verifying'). It is the last row write before spawnRunner, and the dump before it can take minutes, during which force-clear is allowed (no runner pid is alive yet) and lifts the lock. The status condition makes this write and force-clear's own conditional UPDATE serialise on the row: when force-clear commits first, this update matches nothing, takeSafetyDump re-reads the row, finds it closed, logs restore <id> is closed (<status>); job skipped before spawning the runner and returns false without calling fail() — force-clear already settled the row and the lock. If the update matches nothing because the row is gone, 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.

StepCode PathBehaviorFailure Case
1assertRestoreAllowed8 sequential guards, all before any write503 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
2assertNoActiveRestore / assertNoRunInFlightRefuses fast rather than letting a partial-unique-index collision surface as a raw 23505409 BACKUP_RESTORE_ALREADY_ACTIVE, 409 BACKUP_RESTORE_RUN_IN_PROGRESS
3insert + outbox enqueue, same txRow and job commit together or neither does. The event is scheduled RESTORE_DISPATCH_DELAY_MS (5 seconds) ahead, so the dispatcher does not relay it before step 4 has written the state fileWere the job to arrive first, process() fails that restore without spawning a runner. Arriving after engageIf, the failure clears the lock's pending marker, the lock stays engaged and releaseLock lifts it. Arriving before it, the failure cannot clear a marker the lock does not yet carry, so engageIf writes pending for a closed restore; once the state file is written, BackupRestoreLockService.settleClosedBeforeEngage reads the row again and, finding it closed while the state file still reads queued, lifts the lock with disengageIf only while it names this restore and closes the state file failed with the row's error. The response is still 202, and the live state reports the failure
4maintenance engage, then state fileHappen outside the transaction, deliberately, in this order: MaintenanceService.engageIf engages the lock first, then writeRestore writes restore-state.json at stage:"queued", then settleClosedBeforeEngage re-reads the row (step 3). A refused engageIf closes the just-written row through closeRefusedRestore and writes no state fileA crash between transaction commit and maintenance engaging leaves a brief window of live customer writes — accepted cost, documented in the service
5BackupRestoreProcessor.process → takeSafetyDumpprocess() first reads the row and skips the job when it is missing or closed. Then inline createRun({kind:"pre_restore_safety", enqueue:false}) + inline BackupRunHandler.run(), waited on synchronously; then UPDATE backup_restore SET safety_backup_run_id … WHERE status is non-terminalA missing or closed row at the start, or a row closed while the dump ran: the job is skipped with a warning, nothing is written, runner never spawned. A dump that does not complete, or a linking UPDATE that matches no row because the row is gone: restore aborted, fail() called, maintenance stays engaged, runner never spawned
6BackupRestoreProcessor.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), re-keys pending outbox rows, verifies row counts excluding RESTORE_VERIFICATION_EXCLUDED_TABLES, heartbeats, unfences, writes terminal stageoutbox re-key throw → BACKUP_RESTORE_OUTBOX_REKEY_FAILED; verifying-stage throw → BACKUP_RESTORE_VERIFICATION_FAILED; any other stage → BACKUP_RESTORE_FAILED; supervisor writes BACKUP_RESTORE_ABANDONED if the heartbeat goes stale
7BackupRestoreProcessor.finish/failReads the terminal stage. When pg_restore may have committed (restoreMayHaveApplied: completed, failed with any code but BACKUP_RESTORE_FAILED, or abandoned at restoring/verifying) it first runs abandonResurrectedRestores — every other open backup_restore row becomes failed/BACKUP_RESTORE_ABANDONED with finished_at = clock_timestamp() — then reconcileIntegration, which records reconciled or reconcile_failed on the lock. A restore that never reached pg_restore has its marker removed. finish and fail update their own row only while that row is open, and lifts the lock with disengageIf only when the lock still names this restoreMaintenance is not lifted on failure or on a failed reconciliation — an operator must look before customers return, then releases the lock (7.6a)

Step 8 — post-restore audit-log merge, from an anchor captured before the database was touched. Once maintenance is lifted, BackupRestoreProcessor.finish calls mergeAuditLogs(publicId), which reads restore-state.json back and merges state.sourceAuditLogs — the RestoreAuditAnchor ({ state, indexSha256, signatureVerified }) BackupRestorePreflightService.assertRestoreAllowedWithAnchor captured at submit time, from the source run's manifest.json in the same bytes whose HMAC signature had just verified. The merge never re-reads backup_run for this: pg_restore --clean replaces that very table with the source dump's own copy, in which the source row is still running with no audit columns at all, so anything the merge needs has to already be in hand before the database is touched.

  • anchor.state !== "included" → status: "not_in_backup" — the source carried no audit-log copy; nothing to merge, not treated as a failure.
  • anchor.signatureVerified === false → status: "failed", errorCode: BACKUP_AUDIT_LOGS_UNVERIFIED — the source's manifest.json did not verify against a currently-held signing key at preflight time, so its recorded auditLogsSha256 is not trusted enough to merge blind. This is the same authenticity requirement BackupIncrementalBaseService applies before offering a run as a hard-link base — see 6.8d — applied here to a restore instead of a new run.
  • Otherwise, BackupAuditLogService.merge() runs against state.sourceArtifactDir and anchor.indexSha256 (§6.8c) and reports status: "merged" with the segment/insert counts, or status: "failed" with whichever BACKUP_AUDIT_LOGS_* code the merge threw.

The merge runs after maintenance is disengaged and never fails the restore — a try/catch around the whole call logs and records the outcome without rethrowing, because the database restore has already succeeded and an audit-log problem must not make that look like a failed restore. The outcome is not written to restore-state.json's live-poll shape (the merge runs after the state file has already reached stage:"completed") and is not surfaced through any response today — it is logged, and RestoreAuditLogsResultDto (see api.mdx) declares the shape for a future endpoint to return it, but no controller returns one yet.

Trust boundary. restore-state.json is written only by the API process itself, at submit time, before the row is inserted and before any worker runs — the same trust basis already documented for sourceArtifactDir (§7.5, step 4): it is server-written state a detached process later reads, never a value an operator or a request body supplies. sourceAuditLogs extends that same boundary rather than reopening it: signatureVerified is computed once, from a manifest whose HMAC was checked against a held key at the moment of computation, and is then carried as a plain boolean through a file only this module's own processes write — a value worth trusting exactly as far as the state file itself already is, no further.

7.6 Force-clear a stuck restore

POST admin/system/restores/{publicId}/force-clear marks a non-terminal row failed (with BACKUP_RESTORE_ABANDONED) — recovery from a runner that never spawned or was killed. It then lifts the lock through disengageIf: unconditionally when restore-state.json names this restore and restoreMayHaveApplied is false (the database was never touched), and otherwise only when the lock's reconciliation is neither pending nor reconcile_failed. A lock that still owes a reconciliation keeps it, for the supervisor — or a restarted API — to settle. It refuses with 409 BACKUP_RESTORE_ALREADY_ACTIVE while restore-state.json names this restore and its runnerPid is alive, and with 409 BACKUP_RESTORE_NOT_FOUND once the row is already terminal — so a restore that already failed is recovered by releasing the lock instead (7.6a). There is no age threshold, and a runner that has not yet recorded its pid (0) is not detected: force-clearing then leaves that subprocess to finish or fail with nothing tracking it. A job still on its way for a force-cleared restore finds the row closed and is skipped before the safety dump, and a force-clear that lands during the safety dump stops the job at the linking UPDATE, before the runner spawns (7.5).

7.6a Read and release the restore traffic lock

The traffic lock is maintenance.json (mirrored to Redis, cached in-process for 1s), read by MaintenanceGuard on every HTTP request. None of its writers is a general toggle:

WriterEffectWhen
BackupRestoreAdminService.requestRestoreengageIf(...) with reason: "Database restore in progress", restorePublicId, engagedBy, reconciliation: "pending" — refused, and the new row closed failed with BACKUP_RESTORE_ALREADY_ACTIVE, when the file is unreadable or names another restore still pending or reconcile_failedA restore is accepted, after its row and outbox event commit and before restore-state.json is written, so a refused submit leaves the blocking restore's state file as it was
BackupRestoreProcessor.reconcileIntegrationsetReconciliation(id, "reconciled" | "reconcile_failed")The integration reconciliation ran after the database may have been replaced
BackupRestoreProcessor.failsetReconciliation(id, null)The restore failed before pg_restore could commit — nothing to reconcile
BackupRestoreProcessor.finishdisengageIf — lock still names this restoreThe runner reported completed and reconciliation succeeded
BackupRestoreAdminService.forceCleardisengageIf(<caller>) — no reconciliation owedA non-terminal row was cleared
BackupRestoreLockService.releaseLockdisengageIf(<caller>) — see belowAn operator released the lock
BackupRestoreLockService.settleClosedBeforeEngagedisengageIf(<requester>) — lock still names this restoreThe submit found its own row closed while restore-state.json still reads queued: no runner started

BackupRestoreProcessor.fail never lifts the lock, so every failed or abandoned restore ends with it engaged — except one failed before its submit finished engaging the lock, which settleClosedBeforeEngage releases because no runner ever started.

reconciliation answers "has the restored database been reconciled?" — a new change-feed epoch, every integration key revoked, every webhook paused (IntegrationRestoreReconciler.reconcile). The restored rows hold keys revoked after the dump as active, so a runner having exited is not a restore having settled. pending and reconcile_failed refuse the release; reconciled and an absent marker (a lock with no restore id, a restore that failed before pg_restore committed, or a state written before the marker existed) do not.

The way out of pending and reconcile_failed is an API restart. BackupRestoreProcessor.onApplicationBootstrap starts resumeAfterRestart() in the background: reconcile_failed reconciles again and records the outcome; pending whose restore-state.json shows the runner already spawned (any stage but queued and safety_dump) is supervised again from the state file until it settles, because the job that was supervising it died with the old process and maxStalledCount: 0 does not re-run it. queued is left for its job; safety_dump never reached pg_restore and force-clear releases its lock.

GET admin/system/restores/lock (BackupRestoreLockService.getLockState, Backup_READ, ADMIN_READ) maps MaintenanceService.getState() onto RestoreLockDto. blockingRestorePublicId comes from findLockReleaseBlocker() (the open row, else the restore whose runner pid is alive) and is reported whether or not the lock is engaged. A missing state, or one with active: false, maps to active: false, releasable: false and null lock fields — the stored engagedAt/engagedBy of a disengaged state record the release, not an engagement. An unreadable file maps to active: true, reason: "unreadable", null lock fields, and releasable from the blocker alone. When the lock is engaged, releasable is: no blocker, and reconciliation neither pending nor reconcile_failed.

POST admin/system/restores/lock/release (BackupRestoreLockService.releaseLock, Backup_RESTORE, ADMIN_WRITE, @HttpCode(200)) runs, in order:

  1. MaintenanceService.readSnapshot() — the lock as the file holds it now; the write is compared against this.
  2. findLockReleaseBlocker() — the newest backup_restore row whose status is in RESTORE_NON_TERMINAL_STATUSES (queued, safety_dump, restoring, verifying) yields 409 BACKUP_RESTORE_ALREADY_ACTIVE; otherwise a runnerPid > 0 in restore-state.json that process.kill(pid, 0) finds alive yields the same 409 naming the pid. Both carry details.blockingRestorePublicId. Unlike forceClear, the pid is checked whichever restore the state file names.
  3. MaintenanceService.disengageIf(...), which re-reads the file inside the critical section: not engaged → { released: false }; engaged by a different restorePublicId than step 1 read (or readable where step 1 found it unreadable, or the reverse) → 409 BACKUP_RESTORE_ALREADY_ACTIVE naming the new restore; reconciliation: "pending" → 409 BACKUP_RESTORE_RECONCILE_PENDING; "reconcile_failed" → 409 BACKUP_RESTORE_RECONCILE_FAILED; otherwise it writes active: false, file first, then Redis. An unreadable file in both reads is overwritten.
  4. recordActivity with action backup.restore.lock_released, module system, resource backup_restore/the step-1 restorePublicId, metadata { restorePublicId, unreadableState } — written beside the ActivityAuditInterceptor entry, because the interceptor cannot see which restore's lock was lifted. Then a warn log [backup] restore lock released (<restorePublicId>) and { released: true }.

It does not touch the backup_restore row. Both routes sit under the /api/admin/system/restores/ prefix in MAINTENANCE_EXEMPT_PATH_PREFIXES, so they answer while the lock holds.

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.

GateWhenCatchesWhy the earlier design missed it
1. First bytebefore any headerspawn failure, bad argv, produced-nothingpipe does not forward a source error, so the response hung with headers set and no status
2. Timeout killbefore any headera tar that neither errors nor produceskillChild hangs off stdout's close, which a thrown timeout never triggers — every timeout leaked a process
3. Exit codeafter the stream endsa member deleted mid-transfertar 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.

StepCode PathBehaviorFailure Case
1assertRetainedCapNotReachedCounts kind='uploaded' AND status='completed' rows409 BACKUP_UPLOAD_RETAINED_LIMIT_REACHED
2parseManifestRequires schemaMigrationTag and databaseDumpSha256 as non-empty strings400 BACKUP_UPLOAD_MANIFEST_INVALID
3assertNotCorruptedLowercase-normalised sha256 comparison409 BACKUP_CHECKSUM_MISMATCH
4BackupSignatureService.verifytimingSafeEqual HMAC comparison; isConfigured() checked first503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED, 409 BACKUP_UPLOAD_SIGNATURE_INVALID
5runPgRestoreList + renderArchiveSql + scanArchiveSqlFormat check, then schema-only SQL render, then construct scan409 BACKUP_ARCHIVE_UNREADABLE (format), 409 BACKUP_UPLOAD_TOC_REJECTED (dangerous construct)
6finalizeMove dump into place, write fresh manifest + signature, insert row—

8. Caching

Cache Key PatternBuilderValueTTLInvalidationCaller
system:flag=maintenanceCacheKeyUtil.build("system:", [["flag","maintenance"]])MaintenanceState JSONNo Redis TTL set; refreshed on every engage/disengageExplicit 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

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.BACKUPbackup.runBackupRunCreateService (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.BACKUPbackup.pruneBackupAdminService.remove (outbox, manual) / BackupRetentionScheduler (direct, scheduled cron exemption)BackupQueueProcessor → BackupPruneHandler{ reason: "scheduled" | "manual", backupRunPublicId? }3 attemptsIdempotent by construction — an already-pruned row's status guard makes a repeat a no-op.
QueueName.BACKUP_RESTOREbackup.restoreBackupRestoreAdminService.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_REPLICATEbackup.replicateBackupRunHandler (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):

SchedulerCadenceResponsibility
BackupScheduleSchedulerAdmin-editable (scheduleCron/scheduleTimezone, default 0 2 * * * Asia/Kathmandu) via SchedulerRegistry, not @CronCreates the nightly backup_run row with GFS tier assigned at insert.
BackupRetentionSchedulerEVERY_DAY_AT_3AMEnqueues backup.prune {reason:"scheduled"} directly (cron exemption).
BackupSweepSchedulerEVERY_5_MINUTES (×2 cron handlers)Fails stalled running dumps and stalled pending replications.

9.1 Dead letters and retry

backup.prune and backup.replicate jobs that exhaust their attempts land as dead letters the shared JobFailureReplayService tracks, scoped in this module by BackupFailuresAdminService to QueueName.BACKUP and QueueName.BACKUP_REPLICATE. GET admin/system/backups/failures lists them, newest first, under Backup_READ. Only two of the queue's job names are ever retryable, and each gets its own route under its own permission so the pin/note permission can never trigger a deletion:

  • POST admin/system/backups/failures/{publicId}/retry-prune — Backup_DELETE — replays a failed backup.prune under a fresh job id; the worker re-checks the run against the current retention settings and floors before deleting anything, exactly as a fresh prune would.
  • POST admin/system/backups/failures/{publicId}/retry-replication — Backup_UPDATE — replays a failed backup.replicate under a fresh job id.

A failed backup.run is listed for the record but is never replayable — its row is terminal and the operator starts a new backup instead — so a retry attempt against one answers 409 BACKUP_FAILURE_NOT_RETRYABLE, the same code a retry-prune against a replication failure (or vice versa) also answers, since each route replays exactly one job name. 409 JOB_FAILURE_ALREADY_REPLAYED covers the case where the failure was already retried.

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 handler that steps outside the session guard is GET admin/system/restores/{publicId}/live: @Public() to JwtAuthGuard, because the session lookup reads tables the restore locks, and authenticated instead by RestorePollTokenGuard against the token stored in restore-state.json. No route in this module accepts a guest.
  • Releasing the traffic lock carries the restore permission: POST admin/system/restores/lock/release needs Backup_RESTORE, the same permission that starts a restore, because lifting the lock early puts users in front of a database that may be mid-replacement. It refuses while any restore row is non-terminal or any runner pid is alive, and while the restore that engaged the lock owes a reconciliation — lifting the lock over an unreconciled database lets every integration key revoked after the dump authenticate again. Nothing engages the lock by hand.
  • Permissions: Backup_READ/CREATE/UPDATE/DELETE/RESTORE, BackupDownload_READ, BackupConfigure_UPDATE, BackupUpload_CREATE, BackupKey_READ/CREATE/UPDATE/DELETE. Backup, BackupDownload, BackupConfigure, BackupUpload and BackupKey are all in SUPERADMIN_ONLY_MODULES — the admin role never holds them, only superadmin. BackupKey is split from BackupConfigure because 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) and BACKUP_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.verifyAgainstHeldKeys compares HMAC-SHA256(key, "<dumpSha256>:<manifestSha256>") with timingSafeEqual, 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: scanArchiveSql renders the archive's schema SQL (pg_restore -f - --schema-only) and refuses constructs a restore must not execute, because 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 trigger function.
  • Rate limits: ADMIN_READ (list/detail/settings-read, 30/min IP-keyed), ADMIN_WRITE (pin/prune/settings-write and the lock release, 10/min IP-keyed — a release is retried until the restore settles, so it does not spend the restore's daily budget), 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, matching AUTH_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 with ELOOP — there is no window between a check and a use, because there is no separate check. Measured: O_NOFOLLOW guards only the final component; a symlinked parent directory is followed silently and the open succeeds. So resolveExistingDirWithinRoot resolves the directory prefix through realpath and re-asserts containment on the resolved value. Neither control is redundant with the other. A path-only check would be decorative here regardless: reconcileOne inserts status, includesUploads, databaseDumpSha256 and uploadsArchiveBytes verbatim from an operator-editable manifest.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 the realpath and the open — is accepted with its reason and recorded, since closing it needs a component-wise openat walk Node does not expose.
  • An admin route cannot ship without a permission, and this is enforced twice. RoleGuard returns true when a handler declares none, which is correct for the customer surfaces that legitimately have none — and means an admin handler shipped without @Permissions was reachable by any logged-in retail customer, since customer sessions authenticate through the same JwtAuthGuard. The guard now denies that case for admin surfaces, selected from the controller's own PATH_METADATA (array declarations, leading slashes and a controller merely named administration are all handled). A structure spec enforces the same property in CI over Nest's real reflected route table, and additionally checks that RoleGuard is present in the resolved guard chain — @Permissions is inert without it, and a decoration that reads as enforcement is worse than a missing one. A @Public() handler under admin/ must be explicitly allowlisted rather than passing automatically, because @Public() removes the global JwtAuthGuard and 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 BackupPathService itself or re-validated through resolveWithinRoot before use — including values read back out of an operator-editable manifest.json or settings.json.
  • Subprocess argv discipline: every spawn call uses an argv array (never a shell string); credentials go in the child's environment (PGPASSWORD), never argv, which is world-readable via ps; every binary path is absolute and env-configured, since spawn resolves a bare command through PATH at call time.
  • rsync/ssh transport: --secluded-args (asserted at boot) plus a recomputed remote path (never artifact_dir) — without both, an operator-editable string reaches the remote login shell. StrictHostKeyChecking=yes against a boot-time-validated known_hosts file replaces trust-on-first-use on the channel carrying the whole database.
  • Stderr redaction: sanitizeSubprocessExcerpt strips paths, user@host, IPs and tab-delimited COPY data 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-Disposition filename is derived from publicId alone, never from the operator-editable note field, which would otherwise be a CRLF-injection vector into the response header.
  • Superuser fence check: assertApplicationRoleIsNotSuperuser (preflight) and restore-runner.ts's own ALTER ROLE ... CONNECTION LIMIT fence — PostgreSQL does not enforce rolconnlimit against 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 the literal-path allowlist (MAINTENANCE_EXEMPT_PATHS), the /api/admin/system/restores/ prefix (MAINTENANCE_EXEMPT_PATH_PREFIXES), or by being non-@Public() with a non-empty @Permissions(...); every other route is blocked while the lock is engaged, closing the gap an earlier prefix-based design left open for public callbacks. The lock is disengaged only by BackupRestoreProcessor.finish on success, by force-clear, or by an operator's release — never by BackupRestoreProcessor.fail, and never by the runner itself, since the runner has no access to the app's own state once the fence is in place.
  • A password step-up on every key mutation: generate, import, rotate and prune each 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 — IdempotencyInterceptor persists responseJson to 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. TrustedClientIpInterceptor accepts x-internal-client-ip only alongside a matching INTERNAL_CLIENT_IP_TOKEN, and the resulting address reaches the activity log and nothing else. Wiring it into IpThrottlerGuard was 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 numeric trust proxy because a hop count does not bind to the peer at all — measured against Express 5.2.1, req.ip becomes whatever the caller sent.
  • Two download routes, one permission, two rate-limit buckets. BackupDownload_READ covers 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 — IpThrottlerGuard keys 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 @Permissions decorator is a hole, not a default. RoleGuard returns true when no permission is declared, and customer sessions pass the same JwtAuthGuard, so an admin handler without the decorator is reachable by any logged-in customer. pnpm rules has no permission auditor and the structure snapshot is a change detector, so test/structure/backup-download-permissions.spec.ts asserts 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 in BackupResponseDto, RestoreLiveStateDto or BackupKeyStateResponseDto.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
BACKUP_NOT_FOUND404BackupRetentionService.getRowByPublicId, BackupRestorePreflightService.loadSourceNo row for that publicIdVerify the id
BACKUP_ALREADY_RUNNING409BackupRunCreateService.assertNoRunInProgressA row is already queued or runningWait and retry
BACKUP_DISABLED503BackupRunCreateService.createRunsettings.backupEnabled === falseEnable backups in settings
BACKUP_INSUFFICIENT_DISK_SPACE503BackupRunCreateService.assertSufficientDiskSpaceFree disk below minFreeDiskMb + 1.2x last dumpFree disk or raise the floor
BACKUP_ARTIFACT_MISSING410preflight, download, retentionRecorded artifact absent from disk, or reclaimed to remoteFetch it back, or accept it is gone
BACKUP_CHECKSUM_MISMATCH / BACKUP_SIZE_MISMATCH409preflight, downloadFile on disk does not match the recorded sha256/bytesInvestigate corruption; do not trust the artifact
BACKUP_NOT_RESTORABLE409preflight, downloadSource status !== 'completed'Choose a completed backup
BACKUP_DOWNLOAD_AUDIT_FAILED500BackupDownloadAdminService.recordDownloadOrRefuseThe AuditLog.create() write itself failedRetry; the download is refused rather than left unaudited
BACKUP_PINNED_CANNOT_DELETE / BACKUP_LAST_REMAINING_CANNOT_DELETE409BackupRetentionService.assertPrunableOne of the 3 retention floorsUnpin, or accept it cannot be deleted
BACKUP_DUMP_FAILED / BACKUP_DUMP_TIMED_OUT(stored on the row, not an HTTP response)BackupRunHandler.markFailedpg_dump/tar failed or exceeded dumpTimeoutMinutesInspect the server log for the raw error; the row only carries a sanitised excerpt
BACKUP_RESTORE_DISABLED503preflightBACKUP_RESTORE_ENABLED=false or no BACKUP_ADMIN_DATABASE_URLArm the deploy-time switch
BACKUP_RESTORE_CONFIRMATION_MISMATCH400preflightAcknowledgements not both true, or typed database name mismatchRe-confirm carefully
BACKUP_RESTORE_APP_ROLE_IS_SUPERUSER409preflightApplication's own DB role has rolsuperDowngrade DATABASE_URL's role
BACKUP_RESTORE_SCHEMA_VERSION_MISMATCH409preflightBackup's schemaMigrationTag differs from (or is unknown vs.) the running app'sNo override — restore a compatible backup
BACKUP_RESTORE_ALREADY_ACTIVE / BACKUP_RESTORE_RUN_IN_PROGRESS409assertNoActiveRestore, insertRestoreRow / assertNoRunInFlightAnother restore, or a backup run, is in flight. ALREADY_ACTIVE carries details.blockingRestorePublicIdWait, or force-clear the named restore if its runner has died
BACKUP_RESTORE_ALREADY_ACTIVE409forceClear, releaseLock (findLockReleaseBlocker, lockReleaseRefusal)Force-clear: this restore's runner pid is alive. Release: a restore row is non-terminal, any runner pid in the state file is alive, or a restore engaged the lock after the release read it. Always carries details.blockingRestorePublicIdWait for the runner, or force-clear the named restore if its runner has died, then release
BACKUP_RESTORE_RECONCILE_PENDING409releaseLock (lockReleaseRefusal)The lock's reconciliation is pending: the restore that engaged it has not settledWait for the restore to finish; if the API restarted during it, the restarted API resumes and settles it
BACKUP_RESTORE_RECONCILE_FAILED409releaseLock (lockReleaseRefusal)The lock's reconciliation is reconcile_failedRestart the API — resumeAfterRestart retries the reconciliation — then release
BACKUP_RESTORE_NOT_FOUND410getLiveState/forceClearNo live-state file, or it names a different restoreRestore is not in progress
BACKUP_RESTORE_ABANDONED(stored, not thrown as an HTTP response)forceClear, BackupRestoreProcessor.supervise, BackupRestoreProcessor.abandonResurrectedRestoresOperator manually cleared a stuck restore, the runner's heartbeat went stale past BACKUP_RESTORE_ABANDON_SECONDS, or a restore row the dump brought back open was closed after pg_restore—
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 and not from the outbox re-key), 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 throwsInspect the server log; the traffic lock stays engaged until released from the Backups page
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 succeededThe database is populated but wrong — more dangerous than a failed restore. The pre-restore safety dump is already in the catalog; the traffic lock stays engaged for an operator to investigate before deciding whether to release it or restore the safety dump instead
BACKUP_RESTORE_OUTBOX_REKEY_FAILED(stored, not an HTTP response)restore-runner.ts main()'s catch, when rekeyRestoredOutbox threwpg_restore succeeded, but rotating dispatch_key on the restored pending outbox rows failedThe database is replaced and reconciliation runs, but a pending row may share a BullMQ job id with a finished job in Redis and its work would not run if relayed. The traffic lock stays engaged but gates only HTTP; the runner releases the connection fence as it exits and the outbox dispatcher resumes relaying at once. Run UPDATE public.outbox_events SET dispatch_key = gen_random_uuid() WHERE status = 'pending'; against the restored database immediately, then check any row that moved to dispatched meanwhile through its job outbox-<dispatch_key> on its target_queue (Redis key bull:<target_queue>:outbox-<dispatch_key>)
BACKUP_ARCHIVE_UNREADABLE400/409BackupUploadAdminController.upload (missing file part), BackupUploadAdminService.runPgRestoreListThe uploaded dump is missing, or pg_restore --list cannot read it as a PostgreSQL archiveRe-export the dump and retry
BACKUP_UPLOAD_MANIFEST_INVALID400BackupUploadAdminService.parseManifestManifest part missing/not JSON/not an object, or missing schemaMigrationTag/databaseDumpSha256Re-upload with the manifest downloaded alongside the archive
BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED503BackupUploadAdminService.uploadThis deployment holds no current signing keyGenerate one at POST admin/system/backup-key/generate
BACKUP_UPLOAD_SIGNATURE_INVALID400/409BackupUploadAdminController.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 diskThe 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_REJECTED409BackupUploadAdminService.uploadscanArchiveSql found a construct a restore must not executeRemove the flagged object from the source database before re-dumping
BACKUP_UPLOAD_RETAINED_LIMIT_REACHED409BackupUploadAdminService.assertRetainedCapNotReachedBACKUP_UPLOAD_MAX_RETAINED completed uploads already existDelete an uploaded backup before uploading another
BACKUP_REPLICATION_FAILED / _VERIFY_FAILED / BACKUP_REMOTE_UNREACHABLE / BACKUP_REMOTE_DISK_FULL(stored on the row)BackupReplicationService.replicateTransport, read-back verification, or capacity failureInspect replicationFailureStreak in settings response
BACKUP_AUDIT_LOGS_UNAVAILABLE(stored as audit_logs_error_code, run still completes)BackupAuditLogService.exportA connectivity-class Mongo driver error — server selection, network, network timeout, not-connected, topology-closed, or authentication (MongoServerError code 18/13)Check MongoDB reachability; the backup's Postgres half is unaffected
BACKUP_AUDIT_LOGS_EXPORT_FAILED(stored as audit_logs_error_code, run still completes)BackupAuditLogService.exportAny other export failure (a non-connectivity Mongo error, or a filesystem error writing a segment)Inspect the server log
BACKUP_AUDIT_LOGS_MERGE_FAILED(logged, not an HTTP response)BackupAuditLogService.mergeThe index is unreadable/invalid, a segment's sha256 does not match the index, a segment is unreadable, an unparsable line, an insert failure that is not a duplicate-key conflict, or the processed document count does not match the index's recorded totalThe restore's database half still completed; the audit-log merge did not — logged, and recorded on RestoreAuditLogsResultDto.errorCode's declared shape (not yet returned by any route — see RestoreAuditLogsResultDto)
BACKUP_AUDIT_LOGS_UNVERIFIED(logged, not an HTTP response)BackupRestoreProcessor.mergeAuditLogsThe source run's manifest.json did not verify against a currently-held signing key at restore-preflight time, so its recorded audit-log index sha256 is not trusted enough to merge fromSame as above — the merge is skipped, the database restore stands
BACKUP_FAILURE_NOT_RETRYABLE409BackupFailuresAdminService (via JobFailureReplayService)The dead letter named is not a backup.prune job on the prune route, not a backup.replicate job on the replication route, or is a terminal backup.run failure (never replayable)Use the route matching the job's actual name, or start a new backup
BACKUP_SETTINGS_CONFLICT409BackupSettingsService.updateSettingsversion mismatchReload and retry
BACKUP_INVALID_SCHEDULE / BACKUP_SETTINGS_INVALID / BACKUP_REMOTE_NOT_CONFIGURED400updateSettingsCron/timezone invalid, generic field invalid, or storageMode selected without a configured remoteFix the field named in the message
BACKUP_KEY_PATH_NOT_CONFIGURED422BackupKeyAdminService.assertPathConfiguredBACKUP_ARTIFACT_HMAC_KEY_PATH is unsetSet it and restart before any key operation
BACKUP_KEY_STORE_UNREADABLE422 / 409BackupKeyAdminService, BackupRestorePreflightServiceThe key file exists and cannot be parsedRepair the file. Verification is refused, never skipped
BACKUP_KEY_ALREADY_EXISTS409generate, importA current key is already held, or that fingerprint is already in the setUse rotate, or nothing
BACKUP_KEY_NOT_FOUND404 / 422rotate, pruneNo current key to rotate, or no retired key with that fingerprintGenerate one, or check the fingerprint
BACKUP_KEY_FINGERPRINT_UNKNOWN409rotate, pruneconfirmFingerprint does not match the targetRe-read the panel and retry
BACKUP_KEY_STILL_IN_USE409pruneCompleted backups still carry the fingerprint, or some completed run is still unresolvedRun resolve-signatures; then decide deliberately, with force
BACKUP_KEY_INVALID_MATERIAL422importShorter than BACKUP_KEY_MIN_IMPORT_LENGTHSupply the whole key
BACKUP_KEY_REAUTH_REQUIRED400every mutating key routecurrentPassword absentDistinct from a wrong password on purpose, so the panel can say "you left it blank"
BACKUP_KEY_REAUTH_FAILED401every mutating key routePassword did not matchRe-enter it
BACKUP_KEY_NO_PASSWORD_SET409every mutating key routeThe admin account has no password (OAuth-only)Set a password before managing keys
BACKUP_UPLOADS_ARCHIVE_NOT_INCLUDED409BackupDownloadAdminService.prepareUploadsDownloadThe run archived no uploaded files, so there is no second artifactTake a new backup with uploads included. 409, not 404 — the backup exists and its database half downloads fine
SYSTEM_MAINTENANCE_ACTIVE503MaintenanceGuardMaintenance engaged and the caller is not exemptRetry 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.

VariableDefaultApplies to
AUDIT_RETENTION_DAYS90everything else
AUDIT_RETENTION_DAYS_BACKUP_DOWNLOAD730actions 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:

  1. create the expiresAt index
  2. verify it exists, and abort if not
  3. backfill expiresAt = createdAt + retention(action) in bounded batches
  4. only then drop createdAt_1, tolerating IndexNotFound for 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

SignalLocationPurpose
Logger (module-scoped, [backup]-prefixed messages throughout)Every service and workerFailure and state-change visibility; the only place raw (unredacted) subprocess stderr is ever written.
this.logger.warn on RESTORE ACCEPTEDBackupRestoreAdminService.requestRestoreDeliberately warn, not log — a restore request is the highest-consequence write this module accepts.
replicationFailureStreak / lastReplicationFailureAtBackupAdminService.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.restore.lock_released, backup.uploadAdmin-facing audit trail; fire-and-forget, never blocks the response. Every mutating route is also audited by ActivityAuditInterceptor from its @Permissions(...).
AuditLog (MongoDB, via AuditLog.create)backup.download onlyThe one write in this module that is not fire-and-forget — a failed audit write refuses the download itself.
BullMQ / Bull BoardQueueName.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 TypeFilesCoverage
Unitadmin/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.tsService methods and edge cases, colocated per the repo convention.
Coverage/dispatch specsworkers/backup-job-coverage.spec.ts, workers/backup-replicate-job-coverage.spec.ts, workers/backup-restore-wiring.spec.tsAssert every BackupJob/BackupReplicateJob member has a registered handler (the single-worker-per-queue rule), and that QueueName.BACKUP_RESTORE has exactly one @Processor.
Integrationadmin/restore/backup-restore-admin-lock.integration.spec.ts, workers/backup-restore-settle.integration.spec.ts, shared/backup-audit-log.service.int.spec.ts, __tests__/backup-incremental-pipeline.int.spec.tsThe lock against the real database, real Redis (TEST_REDIS_URL only, under a per-run key prefix) and real state files in a scratch backup root, each case in a rolled-back transaction (__tests__/restore-lock-test-support.ts): a non-terminal restore and a live runner pid refuse with blockingRestorePublicId; pending and reconcile_failed refuse; reconciled and an unmarked lock release; a restore engaged mid-release is not wiped; an unreadable file reads reason: "unreadable" and releases; the release writes its Mongo audit entry. The settle spec drives BackupRestoreProcessor.resumeAfterRestart against a runner report: a completed or verification-failed restore closes the resurrected row and records reconciled; a reconciliation blocked by a real lock timeout records reconcile_failed and a restart retries it; a pre-commit failure closes nothing and removes the marker; a restart resumes a pending restore. It drives BackupRestoreProcessor.process for a job whose row is already closed: the row is unchanged, restore-state.json and the lock file are byte-for-byte unchanged, and the safety dump never starts. 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

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
BackupAdminControllerControllerList/create/settings/detail/pin/prune HTTP surfaceBackupAdminServiceHTTPService—DTO validation (400), permission (403)
BackupAdminServiceServicePagination, response mapping, settings orchestration, replication healthBackupRunCreateService, BackupRetentionService, BackupSettingsService, BackupScheduleScheduler, OutboxService, ActivityRecordServiceControllerDB, activity logbackup_run reads/writes—
BackupDownloadAdminController / ServiceController/ServiceStreaming download with pre-stream integrity checksBackupRetentionService, BackupPathService, BackupArtifactServiceHTTPfilesystem, AuditLognone written beyond audit404/409/410/500
BackupRestoreAdminController / ServiceController/ServiceRestore request/accept, live polling, force-clearBackupRestorePreflightService, BackupStateFileService, MaintenanceService, OutboxService, ActivityRecordService, BackupRestoreLockService
BackupRestoreLockServiceServiceTraffic lock read and releaseBackupStateFileService, MaintenanceService, ActivityRecordServiceController, BackupRestoreAdminServiceDB, state files, Mongo auditmaintenance.json409 — see 7.6a
BackupUploadAdminController / ServiceController/ServiceCatalogue an operator-supplied, HMAC-verified archiveBackupPathService, BackupArtifactService, BackupManifestService, BackupSignatureService, scanArchiveSqlHTTPfilesystem, subprocess (pg_restore --list/-f -), DB, activity logbackup_run (kind:'uploaded')see 7.10
BackupPathServiceProviderPath root, containmentenv, filesystemalmost everythingfilesystem—throws at boot on unsafe BACKUP_DIR
BackupArtifactServiceProviderpg_dump/tar spawn + hashingenv, StorageManager, BackupSettingsServicerun/download/preflight/replicationchild process, filesystemwrites artifact filesBackupSubprocessError
BackupManifestServiceProviderSnapshot capture, manifest.json, reconcileDB, BackupPathServiceBackupRunHandler, BackupModule bootDB tx, filesystembackup_run (reconcile insert)logged per-file, never throws overall
BackupSettingsServiceProvidersettings.json schema/read/writeenv, BackupStateFileServicenearly everythingfilesystemsettings.jsonnever throws on read
BackupRetentionServiceProviderGFS sweep, prune, floorsDB, BackupPathService, BackupSettingsServiceBackupPruneHandler, admin service, download serviceDB, filesystembackup_runConflictException on floors
BackupRunCreateServiceProviderShared insert+enqueue for manual/scheduledDB, BackupSettingsService, BackupPathService, OutboxServiceadmin create, BackupScheduleSchedulerDB tx, outboxbackup_run, outbox_events503/409
BackupReplicationServiceProvider, OnModuleInitrsync push + verified read-backenv, BackupPathServiceBackupReplicationHandlerchild process (rsync/ssh), DBbackup_run replication columnsboot-time throw if misconfigured; row marked failed on transport error
BackupRestorePreflightServiceProviderAll pre-insert restore guardsDB, env, BackupPathService, BackupArtifactServiceBackupRestoreAdminServiceDB, filesystemnone (read-only)8 distinct exception types
BackupSignatureServiceProviderKeyed HMAC sign/verifyBackupKeyStoreServiceBackupUploadAdminService, BackupRunHandler, BackupRestorePreflightService, BackupSignatureResolverServicefilesystem (artifact.sig)writes artifact.sig on signnever throws — verifyAgainstHeldKeys returns a result object
BackupKeyStoreServiceProviderOwns the key fileenv (path), filesystemBackupSignatureService, BackupKeyAdminServicethe key file (fsynced, mode 0600)migrates a legacy env key at bootrefuses boot on an unsafe path or mode; degrades to unreadable on a corrupt file
BackupUploadAdminServiceProviderSE-4 catalogue-an-upload flowBackupPathService, BackupArtifactService, BackupManifestService, BackupSignatureService, scanArchiveSql, ActivityRecordServiceBackupUploadAdminControllerDB, filesystem, subprocess, activity logbackup_run insertServiceUnavailableException/BadRequestException/ConflictException
scanArchiveSql (backup-archive-content.util.ts)Pure utilRefuses dangerous SQL constructs in an uploaded schemanone (pure)BackupUploadAdminService——returns violations array; caller throws
BackupStateFileServiceProviderAtomic read/write for 3 state filesBackupPathServicemaintenance, restore, settings servicesfilesystem3 JSON filesthrows on unreadable/malformed (except settings, which the caller wraps)
MaintenanceServiceProvider3-layer read/write of the restore traffic lockBackupStateFileService, RedisMaintenanceGuard, BackupRestoreAdminService, BackupRestoreProcessorfilesystem, Redis, in-process cachemaintenance statefails closed on unreadable file
MaintenanceGuardAPP_GUARDDeny-by-default customer-traffic blockMaintenanceService, Reflectorevery HTTP request in the app——ServiceUnavailableException
BackupTierUtilPure utilGFS calendar boundariesnone (pure)BackupScheduleScheduler——throws on Intl failure only
BackupSubprocessExcerptUtilPure utilstderr redactionnone (pure)artifact + replication services———
BackupQueueProcessor@ProcessorSole worker on QueueName.BACKUPBackupRunHandler, BackupPruneHandlerBullMQdispatch—throws loudly on unknown job name
BackupRunHandlerDispatched (not @Processor)backup.run executionmanifest/artifact/settings/outbox servicesBackupQueueProcessorDB tx, filesystem, child processbackup_runmarkFailed
BackupPruneHandlerDispatchedbackup.prune executionBackupRetentionServiceBackupQueueProcessorDB, filesystembackup_runrethrows
BackupReplicateQueueProcessor@ProcessorSole worker on QueueName.BACKUP_REPLICATEBackupReplicationHandlerBullMQdispatch—throws on unknown job name
BackupReplicationHandlerDispatchedbackup.replicate executionBackupRetentionService, BackupReplicationServiceBackupReplicateQueueProcessorDB, filesystem, child processbackup_run replication columnsrethrows
BackupScheduleSchedulerOnModuleInit schedulerNightly cron + tier assignmentDB, BackupSettingsService, SchedulerRegistry, BackupRunCreateServiceNest lifecycle, BackupAdminService.updateSettings (reload)DB, SchedulerRegistrynone directly (delegates insert)logged and swallowed per tick
BackupRetentionSchedulerCronDaily sweep enqueueBullMQ queueNest schedulerqueue addnonelogged on enqueue failure
BackupSweepSchedulerCron (×2)Stall recoveryDB, envNest schedulerDBbackup_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 valuesBackupStateFileService, MaintenanceService, BackupSettingsService, Database, BackupRunCreateService, BackupRunHandlerBullMQinline BackupRunHandler.run() (safety dump), spawns detached process (env carries restoreTimeoutMinutes/restoreDrainSeconds), polls state file, DBbackup_run (pre_restore_safety insert, via BackupRunHandler), backup_restore (safety_backup_run_id link, then terminal status)Skips the job, writing nothing, when the row is missing or closed at the start or closed before the linking UPDATE; aborts the restore (fail()) if the safety dump does not reach completed, or if the linking UPDATE finds the row gone; 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 === moduleFences 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 unfencesBACKUP_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.spawnRunnerpg_restore subprocess, pg Client direct queries (fence, drain poll, and per-table count(*))live database (via pg_restore), restore-state.jsonWrites 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/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
uq_backup_run_single_running(true)partial uniqueAt most one running dump at a time (backstop behind worker concurrency:1)Trivial write overhead
idx_backup_run_status_created_atstatus, created_atbtreeAdmin list filtering/sortingStandard
idx_backup_run_stalledstarted_atpartial (WHERE status='running')BackupSweepScheduler's stall querySmall, since running rows are rare
idx_backup_run_retentionkind, tier, created_atpartial (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 flightRecovery requires force-clear if the row is orphaned

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
Only one backup queued or running at a timeuq_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 oneBACKUP_ALREADY_RUNNINGbackup-run-create.service.spec.ts
Scheduled rows always carry a tier; manual/safety never dochk_backup_run_tier_matches_kindTier 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 referencechk_backup_run_completed_has_artifactDisjunction makes remote_only representable without lying about completedDB constraint—
Local artifact absent only once remote is verifiedchk_backup_run_local_absent_only_when_replicatedA pending/failed transfer must never justify deleting the only copyDB constraintbackup-retention.service.spec.ts
Newest-of-bucket / last-overall / most-recent-restore's-safety-dump never prunedBackupRetentionService.assertPrunable, bound identically in sweep() and manual deleteAn earlier design let one path delete every backup down to zeroBACKUP_LAST_REMAINING_CANNOT_DELETE, BACKUP_PINNED_CANNOT_DELETEbackup-retention.service.spec.ts
A restore's schema tag must match the running schema'sassertSchemaVersionMatches → readLiveSchemaMigrationTag, assertSchemaMigrationTagsMatchpg_restore --clean replaces the migrations table; the running schema's tag is the live database's latest drizzle.__drizzle_migrations.hash, read at preflight, and a null or "unknown" tag on either side refusesBACKUP_RESTORE_SCHEMA_VERSION_MISMATCHbackup-restore-preflight.schema-tag.spec.ts, backup-restore-preflight.schema-tag.integration.spec.ts
Application DB role must not be a superuser to restoreassertApplicationRoleIsNotSuperuserrolconnlimit fencing is a no-op against a superuserBACKUP_RESTORE_APP_ROLE_IS_SUPERUSER—
Restore requires two literal-true acknowledgementsassertAcknowledgedThe confirmation dialog is a speed bump; the server check is the controlBACKUP_RESTORE_CONFIRMATION_MISMATCH—
Maintenance fails closed on an unreadable fileMaintenanceService.isActiveA 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, the restore-route prefix, or a permission-gated handlerMaintenanceGuardA prefix-based exemption previously let a payment gateway callback write during a restoreSYSTEM_MAINTENANCE_ACTIVEmaintenance.guard.spec.ts
Replicated artifact directory must match the recomputed, pattern-checked pathBackupReplicationService.replicateartifact_dir is operator-editable; trusting it would let rsync hand a crafted path to the remote shellthrown BackupReplicationError before any rsync callbackup-replication.service.spec.ts
An uploaded archive is authenticated by a keyed HMAC, never a plain checksumBackupSignatureService.verifyBoth a checksum and the file it hashes are uploader-controlled in the same request; only an environment-held key proves provenanceBACKUP_UPLOAD_SIGNATURE_INVALIDbackup-signature.service.spec.ts
Uploaded archive schema SQL must not contain a refused constructscanArchiveSqlpg_restore --list's TOC cannot distinguish an ordinary trigger function from a SECURITY DEFINER one; rendering and scanning the SQL canBACKUP_UPLOAD_TOC_REJECTEDbackup-archive-content.util.spec.ts
At most BACKUP_UPLOAD_MAX_RETAINED completed uploaded backups existBackupUploadAdminService.assertRetainedCapNotReachedUploaded rows are pinned and excluded from GFS pruning, so without a cap the feature could fill the volume and disable every future backupBACKUP_UPLOAD_RETAINED_LIMIT_REACHED—
At most one restore-runner process runs per restoreBackupRestoreProcessor.isAlive check + maxStalledCount: 0BullMQ'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 invokedBackupRestoreProcessor.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 mistakeBACKUP_RESTORE_FAILED, restore aborted, maintenance stays engaged—
A restore's post-pg_restore state must match the source manifest's row countsverifyRowCounts at the verifying stage--single-transaction only guarantees the database-level outcome is all-or-nothing, not that every table matches what was dumpedBACKUP_RESTORE_VERIFICATION_FAILED—
A table name from a manifest must match /^[a-z_][a-z0-9_]*$/ before it is interpolated into a queryverifyRowCountsThe manifest is a file on disk in the backup root, not a bound parameter — an unexpected identifier is refused rather than queriedTable reported as a mismatch (refusing to query an unexpected identifier), not queried at all—

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Catalog storageWhere does the backup index livePostgres table + independently-authoritative manifest.json per runDatabase table onlyA 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 anywayTwo sources of truth to keep consistent; reconcileFromDisk is the reconcilerIf reconcile ever needs to run more than at boot/on-demand
Retention policyTime-based expires_at vs. count-based GFSCount-based, evaluated at sweep timeWrite-time expires_at (the v1 design)expires_at never expired manual backups and conflated 3 meanings in one columnSweep must scan and order per bucket rather than a single indexed WHERE expires_at < now()—
Uploads archive defaultShip uploads with every dump, or make it opt-inOpt-in, defaulting falseAlways includeRoughly doubles steady-state disk on a single VPSAn admin restoring a database-only backup gets broken images with no error — explicit tradeoff documented in the feature docIf disk becomes cheap relative to restore-completeness risk
Settings storagesettings.json vs. a database tableFile under the backup rootDatabase tableA restore (pg_restore --clean) would silently revert every policy field, including the replication destinationRequires its own atomic-write discipline and its own Zod validation on every read—
Restore stateDatabase column vs. fileFile (restore-state.json)Database columnThe table holding the restore's own row is itself replaced by pg_restore --cleanThe live-poll endpoint reads a file rather than a table, by design—
Storage mode granularityPer-run column vs. read-time settings lookupRecorded per run (storage_mode)Look up current settings at read timeHistory stays readable after an operator later changes the settingSlight duplication between settings and each row—
Verification transport for replicationLocal read-back hash vs. remote-computed hashLocal read-backssh … sha256sum on the remoteRemote computation asks the remote to grade its own homework, and is incompatible with a write-only deploy keyCosts a full re-download during verificationIf deploy keys are ever given read access
Restore executionIn-process BullMQ handler vs. a detached, supervised subprocessDetached process (restore-runner.ts) spawned by BackupRestoreProcessorAn in-process @Processor running pg_restore directlypg_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 podTwo processes to reason about; state lives in a file, not a table, because pg_restore --clean replaces the table tooIf a future deploy model guarantees no redeploy can interrupt a long-running in-process job
Upload authenticityKeyed HMAC vs. plain checksumHMAC-SHA256 over dumpSha256:manifestSha256, keyed by a key held in the app-owned key fileComparing the file against an uploader-supplied checksumA 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 thatOperators must provision and protect one more secret, and it must match across the deployment that produced the archive and the one accepting itIf a PKI-based signing scheme is ever needed for multi-party provenance
Pre-restore safety dumpInline and synchronous vs. enqueuedInline, awaited call to BackupRunHandler.run() from inside BackupRestoreProcessor, before spawnRunnerEnqueue backup.run onto QueueName.BACKUP and wait for it to completeEnqueuing 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 databaseBlocks the restore job's own processing until the safety dump finishes; a slow safety dump delays the actual restoreIf the safety dump ever needs to run against a different, non-QueueName.BACKUP execution path
Post-restore verificationRow counts vs. full checksum comparisoncount(*) per manifest-listed tableRe-hash or diff full table contentsCounting 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 firstDoes not catch a row that changed value without changing row countIf corruption-without-count-change is ever observed in practice

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Backup dumpGET admin/system/backups, server log [backup] lines, Bull Board on QueueName.BACKUPRows reach completed with non-null databaseDumpSha256Row 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
RetentionGET .../settings for retain* counts, server log retention sweep pruned N run(s)Bucket counts hold near their configured retain* valuesA bucket never shrinking (floors blocking every candidate)Check pinned flags and the 3-floor logic in assertPrunable; unpin if appropriate
ReplicationGET .../settings → remoteConfigured, lastReplicationFailureAt, replicationFailureStreakreplicationFailureStreak: 0Non-zero streak, or remoteConfigured: true with a growing streakCheck server log for the sanitised rsync/ssh excerpt; verify known_hosts pinning and key validity out of band
Traffic lockGET admin/system/restores/lock, server log Maintenance ENGAGED / Maintenance disengaged / [backup] restore lock released linesactive: false outside a restoreactive: true with no restore running — a restore failed or was abandonedCheck the database, then POST admin/system/restores/lock/release; a 409 means a restore row is non-terminal or a runner is alive — force-clear a restore whose runner has died first
RestoreGET admin/system/restores/{publicId}/live, server log [backup] restore ... lines, Bull Board on QueueName.BACKUP_RESTOREStage advances safety_dump → draining → restoring → verifying → completed; maintenance disengagesheartbeatAt 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 for a non-terminal row whose runner died, or POST .../restores/lock/release once the row is terminal; 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)
UploadPOST .../backups/upload response, server log [backup] uploaded and catalogued ...201 with a kind:'uploaded' row, immediately status:'completed'409/400/503 — see Error HandlingRe-export a fresh dump+manifest+signature from a deployment whose key this one generated

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining 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.RestoreA 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 writtenRestoreA 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.jsonBackupRestoreProcessor's own in-memory supervision loop (BACKUP_RESTORE_ABANDON_SECONDS) already detects a stalled runner without needing the column; force-clear is the manual fallbackNo 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 codeRestoreBACKUP_RESTORE_EXTENSION_NOT_OWNED exists in the registry with no current throw siteNone needed todayReserved for extension-ownership verification that has not been implemented
A crash between the restore transaction committing and maintenance engagingRestoreA brief window of live customer writes to a database about to be replacedDocumented and accepted in requestRestore's own commentsInherent to the ordering choice; not revisited
force-clear does not stop a runner that has not yet recorded its pidRestoreForce-clear refuses once restore-state.json names this restore's live runnerPid, but before the runner writes it (runnerPid: 0) the row and the lock clear while the subprocess carries on unsupervisedThe pid check in forceClear and releaseLockAn operator must confirm the runner process is actually gone before treating the site as safely back online
A recycled pid blocks releaseRestoreprocess.kill(pid, 0) cannot tell a dead runner's pid from an unrelated process that later took the same number, so release and force-clear refuse until that process exitsGET .../restores/lock reports releasable: false, so the panel shows whyNo process-identity check beyond the pid
A restored dump can carry a non-terminal backup_restore rowRestorepg_restore --clean replaces backup_restore with the dump's copy; a pre-restore safety dump is taken while its own restore's row is queued, so restoring one brings back a row that blocks release and every new restoreabandonResurrectedRestores closes them right after pg_restore; if that write fails, the lock and every 409 name the row as blockingRestorePublicId and force-clear closes it—
No one-click "undo a completed restore"RestoreRestoring 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" actionThe safety dump exists and is a valid restore source like any other completed backupA 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

On this page

Backup Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tablesbackup_runbackup_restore5.3 Relationship Diagram6. Services and Responsibilities6.1 BackupPathService6.2 BackupArtifactService6.3 BackupManifestService6.4 BackupSettingsService6.5 BackupRetentionService6.6 BackupRestorePreflightServiceThe signature step, and why it has four outcomes rather than two6.7 MaintenanceService6.8 BackupSignatureServiceWhat the signature defends against, stated exactly6.8a BackupKeyStoreService6.8b BackupUploadsSnapshotService6.8c BackupAuditLogService6.8d BackupIncrementalBaseService6.9 BackupUploadAdminService7. Runtime Flows7.1 Manual backup ("Run now")7.2 Scheduled (nightly) backup7.3 Retention sweep7.4 Stall recovery7.5 Run a manual restore7.6 Force-clear a stuck restore7.6a Read and release the restore traffic lock7.7 Live restore polling7.8 DownloadThe three gates, and why each is necessaryTwo-phase audit7.9 Off-host replication7.10 Upload and catalogue an operator-supplied archive (SE-4)8. Caching9. BullMQ, Schedulers, and Async Work9.1 Dead letters and retry10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error Handling13a. Audit RetentionWhy the old index had to be deleted from the schema, not just droppedAuditLogRetentionMigration14. Observability15. Testing and Validation16. Mandatory Backend Deep-Dive Pack16.1 Submodule Coverage Matrix16.2 UML and Architecture Diagram Pack16.3 Code Flow Narrative16.4 Data Layer Deep Dive16.5 Business Logic and Invariant Catalog16.6 Tradeoffs, Alternatives, and ADR Notes16.7 Operational Runbook16.8 Backend Risk Register17. Zero-Omission Backend Checklist18. Backend Completion ChecklistSee Also