End-to-end DR cycle surfaced one more PG-only landmine — and it
turned out to be a side-effect of the round-1 replay placement, not
a new bug. Round 2 fixed the comparison logic; round 3 fixes the
ordering.
Symptom on real PG install:
[install-from-backup] FAILED — Post-restore verification failed:
Table app_settings row count mismatch: expected 190, got 191.
Trigger file left in place for retry.
Root cause: the operator-meta replay (introduced in round 1) ran
INSIDE performDatabaseRestore, lined up BEFORE the post-restore
verification step in the parent restore() method. So:
1. psql restores app_settings → 190 rows (matches backup)
2. Replay upserts `restore_allow_force_auto_upgraded` (which the
fresh-install seeded but the backup didn't have) → 191 rows
3. performPostRestoreVerification counts 191, manifest says 190,
verification fails the row-count check.
Replay is doing the right thing (preserving operator policy). The
verification is doing the right thing (counts must match). They
disagree because the replay landed in the wrong sequence relative
to verification.
Cure: move the replay out of performDatabaseRestore and into
restore() AFTER `performPostRestoreVerification` passes.
Verification now sees the as-restored DB (matches the backup
exactly), replay layers on top once verification has signed off.
Mechanism: snapshot stashed on `this.preservedMetaSnapshot`
(initialised in constructor, reset per run at the top of restore()).
performDatabaseRestore writes it in the PG branch before DROP;
restore() drains it after verification. SQLite leaves it empty,
both steps no-op there.
Tests:
- Updated `restoreService.pgBranch.test.js` to pin the new shape:
* `this.preservedMetaSnapshot` is initialised in the constructor
* No stray `let preservedMeta = []` local declarations anywhere
* Replay drain (`this.preservedMetaSnapshot.length > 0`) sits in
restore() AFTER `performPostRestoreVerification(...)` and is
lexically OUTSIDE `performDatabaseRestore`.
- The bigint-as-string contract from round 2 still holds.
34/34 backup-related integration tests pass.
The previous split (separate docs/install-from-backup.md + separate
README link for "Disaster Recovery") fragmented what's conceptually
one workflow: backup → restore. DR is a specific scenario of restore
(the destination is wiped), not a separate feature.
This merge:
- Folds install-from-backup content into docs/backup-restore.md
as a "Disaster recovery (install from a backup)" section with
its own table-of-contents anchor.
- Adds an explicit ToC at the top so admins land on what they
need in one click.
- Frames the two restore paths up front: "live install" → wizard,
"fresh / wiped install" → trigger file. Admins encountering DR
in panic mode don't need to know to look under a separate link.
- Drops the duplicate "Disaster Recovery" README bullet. The
"Backup & Restore" blurb now mentions DR explicitly so it's
still findable via Ctrl+F on the README.
- Removes docs/install-from-backup.md (its content is now in
backup-restore.md's DR section).
Single source of truth = less risk of one doc going stale relative
to the other when the feature evolves. Maintainer-facing surface
on docs.picpeak.app shrinks back to one /guides/backup-restore page.