# 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: ```bash 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**. ```ts // 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. ```ts // 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.