Database Migration Governance
This document defines the source of truth and process for database schema changes.
🎯 Principles
- Migrations are the source of truth - Not dev databases, not production, not schema.ts alone
- Linear history - Migrations form a single, ordered sequence
- Fail fast - CI detects drift before merge, not after deploy
- Expand-then-contract - Destructive changes happen in phases
📜 Source of Truth Hierarchy
libs/database/migrations/*.sql- Authoritative migration historylibs/database/canonical-schema.sql- Snapshot derived from migrations (auto-generated)libs/database/src/schema.ts- TypeScript schema (must match migrations)
Rule: Never trust a local dev DB as canonical. Always regenerate from migrations.
🔄 Workflow
Creating a Migration
## 1. Make changes to schema.ts
## 2. Generate migration
cd libs/database
pnpm drizzle-kit generate
## 3. Review generated SQL
cat migrations/0004_new_feature.sql
## 4. Test locally
pnpm drizzle-kit push
## 5. Run governance check
cd ../..
./scripts/migrate-check.shBefore Opening PR
- Run
./scripts/migrate-check.sh(catches 90% of issues) - Rebase on
mainif it's been >1 day - Review
MIGRATION_CHECKLIST.md
Resolving Concurrent Migrations
If CI detects concurrent migrations:
## 1. Fetch latest main
git fetch origin main
## 2. Rebase
git rebase origin/main
## 3. Renumber your migration if needed
mv migrations/0004_my_feature.sql migrations/0005_my_feature.sql
## 4. Update any references
## 5. Test again
./scripts/migrate-check.sh🚨 Destructive Changes (Expand-Then-Contract)
For column drops, table drops, or type changes:
Phase 1: Expand (Week 1)
-- Add new column, keep old
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT false;
-- Backfill in application codeDeploy code that writes to both old and new columns.
Phase 2: Switch Reads (Week 2)
-- No migration, just deploy codeDeploy code that reads from new column, still writes to both.
Phase 3: Contract (Week 3)
-- Drop old column (safe now)
ALTER TABLE users DROP COLUMN old_email_verified;Deploy code that only uses new column.
🤖 CI Automation
CI runs on every PR touching migrations:
- Create ephemeral DB (Docker Postgres)
- Apply all migrations in order
- Dump schema to
/tmp/ci_schema.sql - Compare to
canonical-schema.sql - Detect concurrent migrations on main
- FAIL PR if drift or conflicts detected
See .github/workflows/schema-check.yml for details.
📊 Canonical Schema Snapshot
After every merge to main, CI regenerates:
## Manual regeneration (if needed)
cd libs/database
docker run -d --name pg-temp postgres:15
pnpm drizzle-kit push
docker exec pg-temp pg_dump --schema-only > canonical-schema.sql
docker stop pg-temp && docker rm pg-tempThis file is committed to repo for fast comparisons.
🛑 Emergency: Fixing Drift
If production has drifted from migrations:
- Dump production schema:
pg_dump --schema-only > prod_schema.sql - Compare to canonical:
diff canonical-schema.sql prod_schema.sql - Create reconciliation migration: Write SQL to make prod match canonical
- Test on staging first
- Document in migration header why this exists
📋 Migration Naming Convention
0001_initial_schema.sql
0002_add_user_roles.sql
0003_org_scim_support.sql
0004_pkce_required.sql- 4-digit sequential number
- Underscore-separated
- Descriptive name
.sqlextension
🔍 Daily Drift Detection
Nightly job compares staging DB (built from migrations) to canonical:
## .github/workflows/nightly-drift-check.yml
schedule:
- cron: '0 2 * * *' # 2 AM UTC dailyIf drift detected → auto-create GitHub issue with diff.
✅ Pre-Merge Checklist
See MIGRATION_CHECKLIST.md for the full checklist template.
🎓 Training
New developers: Read this doc + run through examples/migration-tutorial/ before first schema change.