Schema Drift as a Deploy Gate (Not a 3 a.m. Surprise)
Cowpin
10/18/2026

Your app compiled. Tests passed. Users still get 500 because production Postgres is missing a column your Prisma client expects — classic schema drift.
Cowpin runs pnpm deploy-gate:db inside the Vercel production build, after
db:generate and env validation,
before test:guards and nuxt build. Fail here → build fails → last good deploy
keeps serving (see also staged publish).
What the gate runs
Script: scripts/deploy-gate-db.mjs (wired from root package.json).
When DATABASE_URL is set:
schema-drift-check.mjs— compare live Postgres toschema.prisma.db-read-contract.mjs— every model: can this build’s client read real rows (types, enums, missing columns — not just name matching)?- FTS sanity —
Bookmark.searchVectorcolumn exists; no NULL vectors on archived rows.
When DATABASE_URL is unset: skip with exit 0 on previews/local.
On VERCEL_ENV=production without DATABASE_URL: hard fail — we refuse a
production build that would skip DB checks silently.
Self-heal vs fail (production only)
Preview builds detect only — an unmerged branch must not mutate production DDL.
Production runs drift check with --heal:
- Additive drift (new optional column, new enum value with safe default) can be applied automatically so the new code and DB align before traffic moves.
- Destructive or ambiguous drift still fails the deploy.
That pairs with staged release: we avoid “ship code, then roll back code while the DB already migrated.”
Why a read contract on top of drift names
Name-level drift checks miss:
- Column type mismatches Prisma tolerates until runtime.
- Enum values present in DB but unknown to the client.
- Heal applied on one replica but pooler still cold (we retry; cold pooler ≠ drift).
Reading one row per table through the same Prisma client the build generated catches those failures in the build log, not in Sentry at 3 a.m.
FTS as part of deploy safety
Full-text search is a product promise for “find what you saved.” The gate asserts the
searchVector column and that backfill completed — not merely that migration SQL exists
in the repo.
Local parity
pnpm deploy-gate:db # needs DATABASE_URL in .env
pnpm run db:drift-check # drift only (see package scripts)
Apply idempotent SQL from docs/sql/ via repo scripts — not manual Supabase paste —
when adding import enums or columns (idempotent apply scripts — same pattern as pnpm run db:apply-import-enums in your deploy docs).
Pattern for your stack
- Generate client in CI/build.
- Compare schema to prod (with explicit heal policy for additive changes).
- Smoke-read through the client — cheap, high signal.
- Domain-specific invariants (search indexes, RLS, etc.) as extra asserts.
Cowpin context
We are a bookmark library with heavy import surface area — each new
ImportJobSource is a migration-trust moment. The deploy gate is how we keep
migration posts honest while shipping often.
Try an import: Reddit saved posts or Pinboard API habits.