zanith

migrate / verify

01 — The shadow gate

A throwaway database that takes the hit first.

verify runs 4 internal stages against a parallel Postgres database: connect and reset, apply pending migrations, introspect the result, diff against your declared schema. Production stays untouched — no advisory lock, no persisted audit rows.

shadow verify — prod locked
production
userslocked
orderslocked
inventorylocked
shadow
1. connect + reset
2. apply on shadow
3. introspect
4. diff vs declared
Residual ops: createTable(users), dropTable(users) · Note: representation gap on introspection round-trip — see docs.
4 stages · prod untouched4 verdicts · honest drift caveatgates off on shadow
02The four stages

Reset, apply, introspect, diff. In that order.

One function does four things and returns a report. It never throws — the caller decides how to react to drift.
  • connect + resetConnect to the configured shadowAdapter and run the optional resetShadow hook (usually DROP SCHEMA public CASCADE).
  • apply on shadowRun every pending migration against the shadow DB. Destructive ops allowed here; this copy is disposable.
  • introspectRead the shadow's live structure back into a SchemaGraph — the actual result of your migrations.
  • diff vs declaredDiff the introspected graph against your declared schema. Empty diff = ok; anything else is drift.

Production stays locked while the shadow runs the full sequence. The report lands in stdout — CI scripts branch on the verdict, not on thrown exceptions.

verifyOnShadow
shadow pipeline · 4 stages
01connect + reset
02apply on shadow
03introspect
04diff vs declared
Connect to the configured shadowAdapter and run the optional resetShadow hook (usually DROP SCHEMA public CASCADE).
Verdict: drift · Residual ops: createTable(users), dropTable(users) · Note: representation gap on introspection round-trip — see docs.
03Three shapes

Pick the cheapest that gives you isolation.

The shadow doesn't need to be a separate cluster — it just needs a Postgres connection that can be reset.
dev
docker-composeTwo Postgres containers on different ports. Reset between verifies. Cheapest local setup.iso · container
ci
service containerGitHub or GitLab spawns a fresh Postgres per job. No reset hook needed — the env starts clean.iso · job
prod
ephemeral schemaSame database server, isolated by search_path. Cheaper than a second cluster, fast to wipe.iso · schema
shadow topology
pick the cheapest that gives you isolation
devdocker-compose
prod · postgres:5432
shadow · postgres:5433
ciservice container
prod · stable
shadow · job-scoped
prodephemeral schema
prod · public
shadow · _zanith_shadow

Two Postgres containers on different ports. Reset between verifies. Cheapest local setup.

04Four outcomes

Each verdict has a next step.

CI scripts can branch on the exit code. Logic failures (drift, apply-failed) and config failures (unreachable shadow) get different codes — different problems, different fixes.
ok
Shadow matches the declared schema. up is cleared to run.exit 0
drift
Migrations applied, but the result doesn't match the declared schema. Lists the offending ops.exit 1
apply-failed
A migration threw on the shadow. You see the error before production ever does.exit 1
shadow-unreachable
No shadow database configured or reachable — verify can't vouch for the plan.exit 2

One honest caveat about drift. Introspection can't always represent a type or default identically to how you wrote it — so a clean migration can occasionally surface a false-positive drift line. Proof suite finding: residual createTable + dropTable (representation gap) on matching plans; real drift still reports ok: false.

shadow verify — verdict
ok
exit 0
drift
exit 1
apply-failed
exit 1
shadow-unreachable
exit 2
Shadow verify — 4 stages
 
1. connect + reset ✓ shadow schema cleared
2. apply on shadow ✓ 2 migration(s) applied
3. introspect ✓ 14 model(s) read back
4. diff vs declared ✗ drift detected
 
Verdict: drift
Residual ops: createTable(users), dropTable(users)
Note: representation gap on introspection round-trip — see docs.
ok · Shadow matches the declared schema. up is cleared to run.