zanith

migrate / lifecycle

01 — The spine

Five stages, three failure paths, one audit terminator.

Every migration walks the same spine: generate from drift, plan with risk scores, verify on shadow, apply with per-step audit. When something fails — budget exceeded, shadow drift, mid-flight error — the engine branches to a documented recovery path instead of leaving you in limbo.

zanith migrate lifecycle
1generate
2plan
3verify
4applybranch → recover
5audit
5 stages · 3 failure paths31 op kindsdown --steps · status · history --verbose
02Generate

The diff becomes the migration.

migrate generate --from-diff compares the declared schema against the live database. The differences become operations in a migration file — type-safe, auto-named, ready to review. No hand-written DDL. One caveat: a rename reads as a drop + add, so confirm the op is renameColumn, not a destructive dropColumn, before you apply.
  • addColumn, addIndex, softDropColumn, and backfill — ordered safely with reversal logic written automatically.
  • Four operations from one diff. Reversal logic written automatically. Ready to plan.
Next: risk-scored plan →
zanith migrate generate --from-diff
declared
id · uuid
email · text
created_at · timestamptz
last_login · timestamptz
live
id · uuid
email · text
created_at · timestamptz
legacy_uuid · uuid
migrations/20260501_113022_add_last_login.ts
addColumnusers.last_login
addIndexusers(last_login)
softDropColumnusers.legacy_uuid
backfillusers.last_login
03Plan

A verdict before anything writes.

The planner classifies every operation, scores it, and checks the worst score against your budget. Read-only — nothing in the database changes. Either you get a green verdict, or you find out exactly which step would have broken.

Step 03 (dropColumn) scores 80 — over a budget of 35. The planner refuses to run anything that crosses the line.

Tighten the budget in CI. Loosen it for emergency hotfixes. The alternative is finding out at apply time, with prod half-changed.

Next: shadow verify →
zanith migrate plan
Migration plan — 2 pending migration(s):
 
Worst risk: destructive
Total risk score: 115
By level: safe=1, medium=1, destructive=1
 
04Verify

A throwaway database takes the hit first.

The migration applies on a shadow Postgres. The result gets introspected and compared to the declared schema. Drift — anything missing — blocks the apply on production. The shadow is throwaway by design: gates are off, nothing persists, you can rebuild it any time.
  • 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.
okdriftapply-failedshadow-unreachable

Local docker-compose, CI service container, or same-cluster ephemeral schema — the shadow can be cheap.

Next: audited apply →
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.
05Apply

Every step writes its own row.

Apply walks the plan one operation at a time. An advisory lock keeps two CI runners from colliding. Each step writes its outcome — done, failed, skipped — to a real audit table before, during, and after it executes. Mid-flight failure leaves a paper trail without marking the migration applied.

Happy path: all four steps land, the migration row gets written, ledger tells the whole story. Failure path: step 3 errors, step 4 never runs — the failure is recorded in _zanith_migration_steps; the migration is not marked applied. Re-run picks up where it stopped.

7 gates before prod write
Risk budgetDestructive opt-inPreflight probesAdvisory lock
Next: audit tables →
zanith migrate apply
01addColumnpending
02addIndexpending
03backfillpending
04softDropColumnpending
_zanith_migration_steps · advisory lock held
06Audit

History as data, not log lines.

Migration history lives in five real Postgres tables in the same database your app uses. Wire monitoring against them, replay any past schema state, build post-mortems. No log scraper required.
  • _zanith_migrations

    Applied migration IDs and applied_at. The ledger of what ran. · 2 columns

  • _zanith_migration_steps

    Per-op audit: status, SQL, risk level + score, error. The black box. · 10 columns

  • _zanith_schema_snapshots

    Full SchemaGraph JSON after each migration (`after` phase). Replay the shape at any point. · 4 columns

  • _zanith_migration_artifacts

    Recovery artifacts: type, source, physical name, checksum, recovery SQL, expiry. · 13 columns

  • _zanith_migration_checkpoints

    Resumable backfill cursor + processed rows + status. Crash-safe progress. · 7 columns

Written inside the same transaction as the migration step.

Post-mortem queries, risk profiles →
audit query — history --verbose
Applied migrations (3):
replay · query with plain SQL · diff across releases
07Operate

The commands for after it's shipped.

statustells you what's applied, pending, and drifted. down --steps N walks back by N. history --verbose reads the full per-step ledger. And the hard changes — renames and type swaps — ship as expand-contract with a backfill that survives a crash.

expandContractRename() — add the new column, backfill, dual-write window, then soft-drop the old one. No client ever reads a name that isn't there.

expandContractTypeChange() — new typed column, batched backfill with a cast, swap reads, retire the original. Two generators, zero downtime.

Batched backfills write a cursor to _zanith_migration_checkpoints after every batch (default batch 500). Kill the process mid-run and the next up resumes exactly where it left off — proven in the suite, not promised.

batched backfill · checkpointed
backfill p5_inventory.normalized_email
batch 1/20500 / 10,000 rows