|
| 1 | +# Database migrations — never use `drizzle-kit push` against prod |
| 2 | + |
| 3 | +The Neon-backed `web/` app has hand-authored SQL migrations in |
| 4 | +`web/src/db/migrations/*.sql`, plus DB-level features (FTS-generated |
| 5 | +columns, partial unique indexes, triggers) that **Drizzle's schema |
| 6 | +DSL cannot fully represent**. Running `pnpm drizzle-kit push` against |
| 7 | +a database with those features will **drop** them, because push |
| 8 | +diffs the DB against the schema files and treats anything not in the |
| 9 | +schema as something to remove. |
| 10 | + |
| 11 | +This is not theoretical. On 2026-05-06, `drizzle-kit push` was used to |
| 12 | +apply migrations 0018–0021 against prod (because `drizzle-kit migrate` |
| 13 | +was hanging over the HTTP-only Neon driver). The push **dropped |
| 14 | +`submissions.search_vec`** (the FTS-generated column added by |
| 15 | +migration `0003_fts`) and the matching `idx_submissions_search_vec` |
| 16 | +GIN index, breaking site search until restored manually. |
| 17 | + |
| 18 | +## The rule |
| 19 | + |
| 20 | +For ANY production schema change: |
| 21 | + |
| 22 | +1. **Add a numbered migration file** under |
| 23 | + `web/src/db/migrations/<NNNN>_<slug>.sql` with the exact DDL. |
| 24 | +2. **Apply it via `psql` or via the Neon dashboard SQL editor** |
| 25 | + pointed at the prod connection string. Never push against prod. |
| 26 | +3. **Update `_journal.json`** so the migration is recorded. |
| 27 | +4. **Mirror the change** in the relevant `web/src/db/schema/*.ts` |
| 28 | + file so type inference works. Use `customType` for DB features |
| 29 | + Drizzle's DSL can't express (FTS columns, generated columns, |
| 30 | + trigger-maintained columns). The schema declaration is the |
| 31 | + "leave this alone" signal to push, not the source of truth. |
| 32 | + |
| 33 | +## Why not just `drizzle-kit migrate`? |
| 34 | + |
| 35 | +Migrate uses transactions, which require websockets. The |
| 36 | +`@neondatabase/serverless` HTTP driver doesn't support them, so |
| 37 | +`migrate` hangs indefinitely under the default config. Switching the |
| 38 | +driver for migrations alone is more work than just using `psql`. |
| 39 | + |
| 40 | +If a future contributor needs migrate to work, the path is: |
| 41 | + |
| 42 | +- Set up a websocket-capable Neon connection in a one-off node |
| 43 | + script that imports `@neondatabase/serverless` with |
| 44 | + `neonConfig.webSocketConstructor = ws` set. |
| 45 | +- Use the `migrate()` helper from `drizzle-orm/neon-serverless/migrator`. |
| 46 | + |
| 47 | +But for one-off prod migrations, `psql` is faster and more auditable. |
| 48 | + |
| 49 | +## Existing things push will want to drop |
| 50 | + |
| 51 | +These DB-level features are not represented in |
| 52 | +`web/src/db/schema/*.ts` (or are represented as opaque types push |
| 53 | +can match against): |
| 54 | + |
| 55 | +- `submissions.search_vec` — `tsvector GENERATED ALWAYS AS (…) STORED`, |
| 56 | + declared as opaque `tsvector` in `schema/content.ts` so push |
| 57 | + matches. |
| 58 | +- `idx_submissions_search_vec` — GIN index over `search_vec`. Push |
| 59 | + will recreate this with the same name once the column is in |
| 60 | + schema. |
| 61 | +- The `score_after_vote_change` trigger and any other triggers |
| 62 | + added by `0001_triggers` and friends — push doesn't manage |
| 63 | + triggers, but won't drop them either as long as they're attached |
| 64 | + to existing tables. |
| 65 | + |
| 66 | +If you add a new generated column or trigger-maintained column, |
| 67 | +update this rule and add the matching `customType` declaration in |
| 68 | +the schema. |
0 commit comments