zanith

migrate / lifecycle · 01 — The state machine

Five stages, three failure paths, one success terminator.

The main page showed the happy path. This page draws every transition — including the three places a migration can stop short. Each stage below has its own deep section with the real CLI output, the real flags, and the artifacts written.

--from-diff--max-riskshadow okper-step rowsexceeds budgetdrift detectedstep errorgenerateplanverifyapplyauditedit + regenblocked · CI exit 1rollback step
5 stages · 3 failure branchesadvisory-locked applyidempotent re-runsshadow throwawayv0.2 · shipped

02 — Generate

The diff becomes the migration.

Compare 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.

declared · what you wantusers
users
  • iduuid
  • emailtext
  • created_attimestamptz
  • last_logintimestamptz
schema diff
live · what's runningusers
users
  • iduuid
  • emailtext
  • created_attimestamptz
  • legacy_uuiduuid
migration scaffoldmigrations/20260501_113022_add_last_login.ts
addColumnusers.last_loginaddIndexusers(last_login)softDropColumnusers.legacy_uuidbackfillusers.last_login

Four operations, ordered safely. Reversal logic written automatically. Ready to plan.

03 — Plan

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.

pending plan · 4 operationsbudget · ≤ 35
step 01safe
addColumn
5
step 02low
addIndex
15
step 03destructive
dropColumn
80over budget
step 04medium
backfill
35
Step 03 (dropColumn) scores 80 · over the budget of 35
blocked · review required

Tighten the budget in CI. Loosen it for emergency hotfixes. The planner refuses to run anything that crosses the line — the alternative is finding out at apply time, with prod half-changed.

04 — Verify

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.

SHADOW · throwawayPROD · live databasemigrationpendingapply on shadowdestructive ops alloweddrift gatedeclared = shadow ?apply on prodaudited per step✓ pass✗ drift → exit 1

the shadow can be cheap

dev

Local · docker-compose

Separate Postgres on a separate port. Reset between verifies.

ci

CI · service container

GitHub or GitLab spawns a fresh container per job. No reset needed.

prod

Same cluster · ephemeral schema

Same Postgres server, isolated by search_path. Cheaper than a second cluster.

05 — Apply

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 · 4 steps✓ committed
step 01done
addColumn
safe
step 02done
addIndex
low
step 03done
softDropColumn
destructive
step 04done
backfill
medium
audit ledger · _zanith_migration_steps
  • 01 · addColumndone
  • 02 · addIndexdone
  • 03 · softDropColumndone
  • 04 · backfilldone

All four steps land. The migration row gets written. Ledger tells the whole story.

step 03 fails✗ not committed
step 01done
addColumn
safe
step 02done
addIndex
low
step 03failed
backfill
medium
step 04pending
softDropColumn
destructive
audit ledger · _zanith_migration_steps
  • 01 · addColumndone
  • 02 · addIndexdone
  • 03 · backfillfailed
  • 04 · softDropColumnpending

Step 3 errors. Step 4 never runs. The failure is recorded in the ledger; the migration is not marked applied — re-run picks up where it stopped.

06 — Audit

History as data, not log lines.

Migration history lives in three 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

Answers which migrations have run.

1 row per migration

most recent rows14 total
30 days agonow
_zanith_migration_steps

Answers what each step did.

N rows per migration

most recent rows86 total
30 days agonow
_zanith_schema_snapshots

Answers the schema before + after.

2 rows per migration

most recent rows28 total
30 days agonow

For the queries you actually run against these — risk profiles, slow ops, failed-step post-mortems — see /migrate/audit.