Summary
The deploy workflow ships the worker but never applies pending Drizzle migrations against the Neon database. Today new schema changes land in production only because someone (most recently me) ran npx tsx scripts/apply-migration.ts migrations/<latest>.sql locally against the same connection string the worker uses. That's accidental coupling, not a designed step — a fresh checkout or a forgotten manual apply will let the worker deploy against an outdated schema and silently 500 on any query against the missing tables/columns.
Requirements
Context
Workflow today is .github/workflows/deploy.yml. Order of operations: checkout → install → typecheck → build web → deploy worker → verify worker → deploy web. The migration apply needs to slot in between Install dependencies and Deploy worker so a migration failure aborts before the worker ships.
Recent example: 0007_remarkable_landau.sql (split languages out of skills, with an inline data-move CTE) was generated by drizzle-kit generate and committed to the repo. CI happily deployed the worker on top of it. The new /me/languages endpoints work in production purely because I'd already applied the migration locally against the shared Neon connection ahead of the push.
Future migrations will hit this same trap. If a contributor without access to .dev.vars opens a PR that adds a migration, the merge ships the worker change but leaves the schema unmigrated.
Implementation Notes
Two viable paths:
Option A — drizzle-kit migrate (preferred). Switch to Drizzle's first-party migration runner, which uses a drizzle.__migrations table in the database to track applied migrations. CI runs npx drizzle-kit migrate and skips anything already applied. Requires the runner to know the connection string at build time — already the case via DATABASE_URL secret. Retire scripts/apply-migration.ts for production use, keep it for ad-hoc/local one-shot statements.
Option B — extend apply-migration.ts to detect already-applied migrations (e.g., a migration_history table the script writes to) and apply pending ones in order. Less ecosystem alignment but no new tool to learn.
Option A is the standard path and avoids hand-rolling the journal. Worth converting now while the migration count is small (7 files).
Both options should run against the production DATABASE_URL in CI, fail loudly on migration error, and abort the deploy before the worker ships.
Summary
The deploy workflow ships the worker but never applies pending Drizzle migrations against the Neon database. Today new schema changes land in production only because someone (most recently me) ran
npx tsx scripts/apply-migration.ts migrations/<latest>.sqllocally against the same connection string the worker uses. That's accidental coupling, not a designed step — a fresh checkout or a forgotten manual apply will let the worker deploy against an outdated schema and silently 500 on any query against the missing tables/columns.Requirements
DATABASE_URLbefore theDeploy workerstep runs, so a failing migration blocks the deploy entirely._migrationsjournal in the database, not the localmigrations/meta/_journal.json).DATABASE_URLGitHub secret the worker already has access to — no extra credentials.npm run db:seedand the existingapply-migration.tsad-hoc path keep functioning for one-off backfills.Context
Workflow today is
.github/workflows/deploy.yml. Order of operations: checkout → install → typecheck → build web → deploy worker → verify worker → deploy web. The migration apply needs to slot in betweenInstall dependenciesandDeploy workerso a migration failure aborts before the worker ships.Recent example:
0007_remarkable_landau.sql(split languages out of skills, with an inline data-move CTE) was generated bydrizzle-kit generateand committed to the repo. CI happily deployed the worker on top of it. The new/me/languagesendpoints work in production purely because I'd already applied the migration locally against the shared Neon connection ahead of the push.Future migrations will hit this same trap. If a contributor without access to
.dev.varsopens a PR that adds a migration, the merge ships the worker change but leaves the schema unmigrated.Implementation Notes
Two viable paths:
Option A —
drizzle-kit migrate(preferred). Switch to Drizzle's first-party migration runner, which uses adrizzle.__migrationstable in the database to track applied migrations. CI runsnpx drizzle-kit migrateand skips anything already applied. Requires the runner to know the connection string at build time — already the case viaDATABASE_URLsecret. Retirescripts/apply-migration.tsfor production use, keep it for ad-hoc/local one-shot statements.Option B — extend
apply-migration.tsto detect already-applied migrations (e.g., amigration_historytable the script writes to) and apply pending ones in order. Less ecosystem alignment but no new tool to learn.Option A is the standard path and avoids hand-rolling the journal. Worth converting now while the migration count is small (7 files).
Both options should run against the production
DATABASE_URLin CI, fail loudly on migration error, and abort the deploy before the worker ships.