Skip to main content

Schema Drift as a Deploy Gate (Not a 3 a.m. Surprise)

Cowpin

Cowpin

10/18/2026

#prisma #postgres #devops #vercel #database #saas
Schema Drift as a Deploy Gate (Not a 3 a.m. Surprise)

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:

  1. schema-drift-check.mjs — compare live Postgres to schema.prisma.
  2. db-read-contract.mjs — every model: can this build’s client read real rows (types, enums, missing columns — not just name matching)?
  3. FTS sanity — Bookmark.searchVector column 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

  1. Generate client in CI/build.
  2. Compare schema to prod (with explicit heal policy for additive changes).
  3. Smoke-read through the client — cheap, high signal.
  4. 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.