bun db — Drizzle Migration CLI
Unified entrypoint for everything migration-related. Subcommands route through scripts/db/index.ts, which auto-wraps DB-touching subcommands in infisical run so DATABASE_URL is injected from the right secret environment.
Surface
bun db help # print usage
bun db generate # write a new migration .sql from schema changes
bun db migrate [--env=dev|staging|prod] # apply pending migrations to the target DB
bun db migrate:dry [--env=dev|staging|prod] # preview pending SQL + safety checks, don't apply
bun db mark-applied [--env=dev|staging|prod] # seed drizzle.__drizzle_migrations on an existing DB
bun db rebase # auto-resolve a local migration that collided with origin/dev
migrate applies pending migrations directly via pg (using drizzle's own readMigrationFiles for parsing + hashing), so the tracking table stays compatible with drizzle and mark-applied. Unlike drizzle's built-in migrate() — which wraps every migration in a single transaction — our executor runs any statement containing CONCURRENTLY in autocommit, so CREATE INDEX CONCURRENTLY migrations apply normally. Everything else still runs in a per-migration transaction.
migrate and migrate:dry also run a safety check that refuses to apply any pending migration containing CREATE INDEX, DROP INDEX, or REINDEX without CONCURRENTLY. Those DDL statements take an ACCESS EXCLUSIVE lock and can block reads/writes on busy tables. To get through the check, make the index concurrent: rewrite the SQL with CONCURRENTLY, or add .concurrently() to the index in your schema and regenerate. Concurrent index migrations then apply through bun db migrate with no manual step.
--env defaults to dev. generate and rebase never touch a DB and don't take --env.
Day-to-day workflow
# 1. Make a schema change in shared/db/**.ts or shared/models/**.ts
# 2. Generate the migration
bun db generate
# 3. Review the new shared/drizzle/{N}_*.sql and updated meta/.
# Commit both with the code change in your PR.
# 4. Apply locally
bun db migrate
If your branch's migration collides with one that landed on origin/dev first (CI will tell you via duplicate-idx errors), pull and run:
git pull origin dev
bun db rebase # auto-renumbers your migration, regenerates against the new baseline
bun db rebase is idempotent and bails out cleanly if drizzle-kit would need interactive input (e.g. column rename detection) — see the "Edge cases" section below.
Bootstrapping a new DB
bun db migrate won't run against a DB that has the tables but no drizzle.__drizzle_migrations tracking row — it'd try to CREATE TABLE on tables that already exist and fail. Seed the tracking table first:
bun db mark-applied # local dev DB
bun db mark-applied --env=staging # if/when staging is wired up — see below
bun db mark-applied --env=prod # if/when prod is wired up — see below
Idempotent. Re-running is safe.
Status: staging & prod are NOT wired into the CLI yet
Right now, staging and prod migrations are still applied manually (paste the .sql into TablePlus per env). The --env=staging|prod plumbing exists in the code and works, but we haven't:
- Run
bun db mark-applied --env=staging/--env=prodto seed the tracking table on those DBs. - Switched the deploy pipeline to call
bun db migrate --env=…automatically.
Until both happen, do not run bun db migrate --env=staging or --env=prod — without the mark-applied step, it will try to re-create existing tables and fail. (Failing safely, but failing.)
Upgrade path: moving staging / prod into the CLI
When you're ready to flip from "TablePlus paste" to automated migrations on a shared env, the sequence is:
One-time per env
-
Confirm the env's secret name in infisical. The CLI assumes:
dev→ infisical envdev,ENV_FILE=.envstaging→ infisical envstaging,ENV_FILE=.env.stagingprod→ infisical envprod,ENV_FILE=.env.prod
This mapping lives in
scripts/db/helpers/env.ts. If your infisical env names diverge, update there. -
Confirm
DATABASE_URLin that infisical env points at the right DB. A wrong URL here means migrations apply to the wrong DB. Run:bun db mark-applied --env=<env>The first log line prints the host parsed from
DATABASE_URL(e.g.host=staging-pg.foo.com:5432) — verify it before letting the script run further. Hit Ctrl-C if it's wrong; mark-applied does its writes after the log line. -
Run mark-applied. Same command as step 2. It seeds
drizzle.__drizzle_migrationswith rows for every migration currently inshared/drizzle/meta/_journal.json, sodb migratewill skip them. -
Verify migrate is a clean no-op. This proves the seeding worked:
bun db migrate --env=<env>Expected: drizzle-kit reports "applied successfully" with no DDL run, and
drizzle.__drizzle_migrationshas the same row count as before.
Ongoing — moving the apply step from TablePlus to the CLI / CI
Once the bootstrap above is done for an env, you have two ways to apply new migrations:
-
Manual command, same shape as dev:
bun db migrate --env=staging bun db migrate --env=prod -
CI-based — call the same command from a deploy step. Two things to wire up in the pipeline:
- The CI runner needs
infisicalinstalled and authenticated with credentials that can read the relevant env. Existing workflows that useinfisical run --env=…are the precedent; copy that auth setup. - Add a step like:
after the build step and before traffic is shifted to the new revision. If the migration fails, the deploy should halt —
- name: Apply DB migrations run: bun db migrate --env=prodbun db migrateexits non-zero on any DDL failure.
Recommendation: start with staging-only CI migration first, run it for a week or two, and only then enable prod. Prod migration failures during a deploy are high-blast-radius and you want to be confident the pipeline works before you bet a deploy on it.
- The CI runner needs
File layout
scripts/db/
├── README.md # this file
├── index.ts # subcommand dispatch
├── commands/
│ ├── help.ts
│ ├── generate.ts # passthrough to `bun -F @autumn/shared db:generate`
│ ├── migrate.ts # applies pending migrations (CONCURRENTLY-aware executor)
│ ├── markApplied.ts # seeds drizzle.__drizzle_migrations
│ └── rebase.ts # auto-resolves duplicate-idx conflicts
├── helpers/
│ ├── applyMigrations.ts # per-migration executor: autocommit for CONCURRENTLY, tx otherwise
│ ├── env.ts # --env parsing + infisical wrap + DATABASE_URL host extraction
│ ├── pendingMigrations.ts # computes pending set from _journal.json vs tracking table
│ ├── safetyCheck.ts # flags non-CONCURRENTLY index DDL
│ ├── paths.ts # canonical paths to shared/drizzle/ and meta/
│ └── spawn.ts # thin child_process.spawn wrapper
└── pull.ts # unrelated — customer data pull (legacy)
shared/package.json still owns db:generate (which generate shells out to). migrate no longer delegates to drizzle-kit — it reads the committed migrations with drizzle's readMigrationFiles and applies them itself so CONCURRENTLY works. The unified bun db interface lives at the repo root.
Edge cases
bun db rebase bailed with "drizzle-kit needs interactive input"
This happens when the regeneration would prompt for a rename decision (column renamed, enum value reordered, etc.). The script restored your original files from a temp backup and exited non-zero. Recovery:
bun db generatedirectly in a terminal — answer the prompts.- Drizzle will produce a new migration. If it has a duplicate idx with
origin/dev, manually rename the.sqlfile and update theidx+taginmeta/_journal.json. - Re-run
bun db rebaseto confirm the journal is now clean.
This is rare. ~95% of migrations (ADD COLUMN, DROP COLUMN, CREATE INDEX) auto-resolve cleanly.
CI says "migration idx N already exists"
Someone else's PR with the same migration idx merged first. Run bun db rebase on your branch, push, re-run CI.
"I deleted shared/drizzle/ by accident"
Pull from origin/dev to get it back. Your in-flight schema changes in TS are still intact; run bun db generate and it'll regenerate the migration.