zanith

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

StepWhat runs
1. ProvisionCreate a fresh Postgres database alongside production. Either via your shadowAdapter config or via a connection-string-derived name like _shadow_.
2. ReplayApply every previously-applied migration to bring the shadow to the current production state.
3. Apply pendingRun the new plan on the shadow.
4. Introspect + diffRead 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

SHELL
# 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
FlagEffect
--file Verify a specific migration instead of all pending.
--skip-provisionUse the configured shadowAdapter as-is. Useful for a long-lived shadow DB in CI.
--keepDon't drop the shadow at the end. Useful when debugging a verify failure.
--jsonMachine-readable output for CI.

Configuring the shadow

TS
// zanith.config.ts
import { 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

TXT
$ 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

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