Shadow-DB verify
The four-step gate between plan and up. Catches the cases where the migration applies but the result doesn't match the declared schema.
The four steps
| Step | What runs |
|---|---|
| 1. Provision | Create a fresh Postgres database alongside production. Either via your shadowAdapter config or via a connection-string-derived name like . |
| 2. Replay | Apply every previously-applied migration to bring the shadow to the current production state. |
| 3. Apply pending | Run the new plan on the shadow. |
| 4. Introspect + diff | Read pg_catalog from the shadow. Diff against the declared schema graph. Report any difference. |
If the diff is non-empty, the migration is rejected. The difference is surfaced as a structured report — every mismatched column, missing index, drifted constraint — not just "something is off."
The CLI surface
# Verify the pending migrations.zanith migrate verify # Verify a single migration file (useful in CI on a new branch).zanith migrate verify --file 20260504_120000_add_orders_index.json # Skip provisioning if you've already pointed shadowAdapter at a long-lived shadow.zanith migrate verify --skip-provision| Flag | Effect |
|---|---|
--file | Verify a specific migration instead of all pending. |
--skip-provision | Use the configured shadowAdapter as-is. Useful for a long-lived shadow DB in CI. |
--keep | Don't drop the shadow at the end. Useful when debugging a verify failure. |
--json | Machine-readable output for CI. |
Configuring the shadow
// zanith.config.tsimport { defineConfig } from 'zanith';import { PgAdapter } from 'zanith'; export default defineConfig({ adapter: new PgAdapter({ connectionString: process.env.DATABASE_URL }), shadowAdapter: new PgAdapter({ connectionString: process.env.SHADOW_DATABASE_URL, }),});The shadow needs the same Postgres version, the same extensions installed, and ideally a representative dataset (so preflight checks find the same data shape as production).
What drift looks like
$ zanith migrate verify✗ shadow drift after applying 20260504_130000_add_orders_total table "orders" column "total" declared: numeric(12,2) NOT NULL actual: numeric(10,2) NOT NULL → the migration's alterColumnType emitted (10,2); declared schema says (12,2)Common causes: a hand-edited migration that doesn't match the generator's output; a previous migration that used a different declared type; a Postgres version that interprets the DDL differently.
Programmatic API
import { verifyOnShadow } from 'zanith'; const report = await verifyOnShadow(adapter, plan, { shadowAdapter, // …other ShadowVerificationOptions}); if (!report.ok) { console.error(report.diff); process.exit(1);}See programmatic API for the rest of the migration surface.