node-pg-migrate keeps the schema; Drizzle is for queries only. The conventions doc said this was unsettled and now says what was settled and why: drizzle-kit cannot diff expression indexes and emitted six statements for one column, our migrations are mostly prose that generated SQL does not carry, and data migrations cannot be generated at all. The reasoning in full is on the issue. This is the version a reader converting a query will actually find. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.3 KiB
Drizzle conventions
Decided in #216, landed in #217. Read this before converting a query.
What is in this directory
| File | Owner |
|---|---|
schema.ts |
Generated. drizzle-kit pull output. Do not hand-edit. |
relations.ts |
Generated. Same. |
itemFilters.drizzle.ts |
Hand-written. The #216 spike's conversion of buildItemFilterSql, kept as the worked example. |
CONVENTIONS.md |
This file. |
backend/migrations owns the schema. schema.ts is a read-only mirror of it, and refreshing that mirror is a manual step:
DRIZZLE_DATABASE_URL=postgres://user:pass@localhost:PORT/db npx drizzle-kit pull
Run it against a database with every migration applied, after writing a migration. drizzle-kit pull also emits 0000_*.sql and meta/ into this directory because out serves both purposes; both are gitignored, because this project's migration history is backend/migrations and a stray SQL file here would at best be noise and at worst be mistaken for real history. Whether that stays true is #219.
drizzleSchema.integration.test.ts fails when the mirror and the database disagree, on tables or on columns. That test exists because the drift is silent and already happened: the mirror sat missing item_drafts and upload_links from the moment #222 landed until #217, because the spike had copied it into src/ by hand and nobody had reason to look. A stale mirror is worse than none — Drizzle infers row types from it, so a converted query type-checks against a schema the database does not have and fails at run time on a column that does not exist.
The rule that will bite you hardest: columns in a sql template are unqualified
Drizzle renders a column reference inside a sql template without its table.
// WRONG. Generates: (SELECT COUNT(*)::int FROM "items" WHERE "category_id" = "id")
// Postgres resolves both sides against items, so the subquery correlates with
// itself and returns a plausible wrong number.
sql`(SELECT COUNT(*)::int FROM ${items} WHERE ${items.categoryId} = ${categories.id})`
// RIGHT. Literal text, which is honest here because the fragment binds no values.
sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`
This is worse than the array trap below, because the array trap produces invalid SQL and fails loudly. This produces valid SQL and quietly wrong data — it type-checks, reads correctly, and executes without error. It was found in #218 only because an integration test asserted the count was 2 and got 1.
So: any converted query containing a correlated subquery or a self-join needs a test asserting values, not just a status code. Write that test before converting.
The other one: a driver error code moves
Drizzle wraps driver errors. A Postgres SQLSTATE that sat on err.code sits on err.cause.code after conversion, so a catch keyed on it still compiles, never matches, and turns a handled 409 into a 500. adminCategories.ts has isUniqueViolation, which accepts both shapes; reuse that pattern. Revisit every SQLSTATE-keyed catch when converting a file.
The rule that will bite you: arrays
In a Drizzle sql template, an array interpolates as a placeholder list, not as one array parameter.
// WRONG. Emits ANY(($1, $2)::int[]), which is invalid Postgres.
sql`... WHERE id = ANY(${filters.categoryIds}::int[])`
// RIGHT. Emits ANY($1::int[]).
sql`... WHERE id = ANY(${sql.param(filters.categoryIds)}::int[])`
The wrong form type-checks, reads correctly, and fails at run time. Nothing warns. Across 187 call sites this is exactly the shape of defect that passes review and breaks in production, so sql.param() is required for every array and any converted query taking one needs a test that actually executes it.
The reason this is worth doing
${value} in a Drizzle sql template emits a bind parameter, never text. There is no way to spell "interpolate this value as SQL" by accident: the escape hatch that looks like a plain template literal does not behave like one. Passing "1); DROP TABLE items; --" as a status value puts it in the parameters and not in the SQL.
That makes the #202 invariant — only placeholder indices may be interpolated into a clause — a property of the type system rather than a comment guarded by two mutation tests, and it retires #180's three S2077 hotspots rather than leaving them reviewed and watched. It is the strongest argument for the adoption, and the spike confirmed it is real rather than relocated.
Both drivers run at once
Column names differ, and the difference is load-bearing. The mirror is camelCase (parentId, sortOrder); these APIs answer in snake_case, which the admin frontend reads. So a select must map explicitly — { parent_id: categories.parentId } — rather than selecting the table. Selecting the table directly changes the JSON contract silently, and no test asserting status codes notices. adminCategories.ts writes that mapping once as CATEGORY_COLUMNS and infers the row type from it, which is also how the hand-declared row interfaces are retired.
db and pool are exported from src/db.ts and share one pool, deliberately. Conversion is file by file across 187 sites, so most queries will be raw pg for a long time and the two must not open separate connection pools — a transaction on one would be invisible to the other, and the configured limits would silently double.
Migrations stay hand-written
Decided in #219: node-pg-migrate keeps the schema, Drizzle is for queries only. Do not start generating migrations as a side effect of converting a query.
Three reasons, all measured rather than assumed. drizzle-kit generate cannot diff expression indexes, so adding one nullable column emitted six statements — three DROP INDEX and three CREATE UNIQUE INDEX alongside the ALTER — and those rebuilds take real locks on a large table. Our migrations are mostly prose, and generated SQL carries none of it: a rule that every generated migration is annotated before merge is a rule that holds for three migrations and then quietly stops, with the failure invisible because the migration still works. And data migrations cannot be generated at all, so anything touching existing rows stays hand-written regardless.
The workflow: write the migration by hand, then run drizzle-kit pull to refresh the mirror. drizzleSchema.integration.test.ts fails if you forget.