From c340b74aac5bc5fe604d0a52d6ff6d1c16685944 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 14:23:22 -0500 Subject: [PATCH 1/8] docs(db): weigh Kysely against the Drizzle decision (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question is not whether Kysely is good, it is whether it is enough better for this codebase to reverse a decision already made in #216 and partly built in #217. That is a higher bar than being the nicer library, so this answers it against the same target #216 used: buildItemFilterSql, with six optional clauses composed at run time, a recursive CTE, an ANY(...::int[]) tag match with a count equality, and array parameters. Kysely compiles without a connection, so the document quotes the SQL it actually emitted rather than a reading of its documentation. Three of the four hazards that src/db-drizzle/CONVENTIONS.md exists to warn about turn out to be properties of Drizzle rather than of type-safe query building, and two of them are the silent kind. An array interpolates as one bind parameter with no sql.param() ceremony, so the trap that document calls "the rule that will bite you" does not exist. A column reference inside a raw fragment is the text you wrote, so the correlated-subquery rewrite that returned a quietly wrong count in #218 cannot happen. And the generated types carry the database's own snake_case names, so the explicit column mapping that exists to stop a select silently changing the JSON contract is not needed at all. The property that motivated the whole exercise is unchanged: a hostile value lands in the parameters either way, so #202's invariant becomes a type-system property and #180's hotspots retire either way. What decides it is how little is actually built. One file is converted — adminCategories.ts, three calls — against 238 raw query sites, and the generated mirror and its drift test are things any builder needs an equivalent of. The recommendation is to switch now, while the cost is reconverting one file and rewriting a conventions document that gets substantially shorter. The counter-argument is recorded rather than hidden: Drizzle is more widely used, and #219's migration reasoning was measured against drizzle-kit specifically. That reasoning survives, because losing the prose and being unable to express data migrations are true of any generator, and Kysely simply has nothing to refuse. Closes #297 Co-Authored-By: Claude Opus 5 --- .../specs/2026-09-04-kysely-spike.ts | 132 ++++++++++++++++++ .../specs/2026-09-04-kysely-vs-drizzle.md | 121 ++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-kysely-spike.ts create mode 100644 docs/superpowers/specs/2026-09-04-kysely-vs-drizzle.md diff --git a/docs/superpowers/specs/2026-09-04-kysely-spike.ts b/docs/superpowers/specs/2026-09-04-kysely-spike.ts new file mode 100644 index 0000000..699da2f --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-kysely-spike.ts @@ -0,0 +1,132 @@ +// Kysely spike for #297. Deliberately the same target #216 set for Drizzle: +// buildItemFilterSql — six optional clauses composed at run time, a recursive +// CTE for the category subtree, an ANY(...::int[]) tag match with a count +// equality, and array parameters. +// +// Nothing here connects to a database. Kysely compiles to { sql, parameters } +// without a connection, which is the whole point: the questions being asked are +// about what SQL comes out and where the values end up. + +import { Kysely, PostgresDialect, sql, SqlBool, expressionBuilder } from 'kysely'; + +// What kysely-codegen would generate, hand-written for the four tables this +// query touches. Note the names: snake_case, exactly as the database spells +// them and exactly as these APIs answer. +interface DB { + items: { + id: number; + category_id: number | null; + price_cents: number; + status: string; + }; + item_tags: { item_id: number; tag_id: number }; + categories: { id: number; parent_id: number | null }; + favorites: { customer_id: number; item_id: number }; +} + +const db = new Kysely({ + dialect: new PostgresDialect({ pool: {} as never }) +}); + +interface SpikeFilters { + categoryIds: number[]; + tagIds: number[]; + minPriceCents: number | null; + maxPriceCents: number | null; + status: string[] | null; + favoritesOnly: boolean; +} + +function buildItemFilterKysely(filters: SpikeFilters, favoritesCustomerId: number | null) { + const eb = expressionBuilder(); + const clauses = []; + + // The recursive CTE, inside an IN (...) subquery. + // + // ${filters.categoryIds} emits a BIND PARAMETER, and — the question this + // spike exists to answer — it emits ONE parameter for the whole array, not a + // placeholder list. No sql.param() equivalent is needed. + if (filters.categoryIds.length) { + clauses.push(sql`items.category_id IN ( + WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = ANY(${filters.categoryIds}::int[]) + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + ) + SELECT id FROM subtree + )`); + } + + // AND, not OR: the item must carry every selected tag. + if (filters.tagIds.length) { + clauses.push(sql`( + SELECT COUNT(*) FROM item_tags it + WHERE it.item_id = items.id AND it.tag_id = ANY(${filters.tagIds}::int[]) + ) = ${filters.tagIds.length}`); + } + + if (filters.minPriceCents !== null) clauses.push(eb('items.price_cents', '>=', filters.minPriceCents)); + if (filters.maxPriceCents !== null) clauses.push(eb('items.price_cents', '<=', filters.maxPriceCents)); + if (filters.status !== null) clauses.push(eb('items.status', 'in', filters.status)); + + if (filters.favoritesOnly && favoritesCustomerId !== null) { + clauses.push( + eb.exists( + eb + .selectFrom('favorites') + .select('favorites.item_id') + .whereRef('favorites.item_id', '=', 'items.id') + .where('favorites.customer_id', '=', favoritesCustomerId) + ) + ); + } + + return clauses; +} + +function show(label: string, compiled: { sql: string; parameters: readonly unknown[] }) { + console.log(`\n=== ${label} ===`); + console.log(compiled.sql.replace(/\s+/g, ' ').trim()); + console.log('parameters:', JSON.stringify(compiled.parameters)); +} + +const filters: SpikeFilters = { + categoryIds: [3, 7], + tagIds: [11, 12], + minPriceCents: 1000, + maxPriceCents: 50000, + status: ['available', 'reserved'], + favoritesOnly: true +}; + +const query = db + .selectFrom('items') + .select(['items.id', 'items.status', 'items.price_cents']) + .where((eb) => eb.and(buildItemFilterKysely(filters, 42))); + +show('every clause at once', query.compile()); + +// Question 2: what does a hostile value do? #202's invariant is that only +// placeholder indices may reach the SQL text. +const hostile = db + .selectFrom('items') + .select('items.id') + .where('items.status', '=', "1); DROP TABLE items; --"); +show('hostile value in a status filter', hostile.compile()); + +// Question 3: the trap that cost #218 a silently wrong count in Drizzle — a +// correlated subquery referencing a column of the outer table. +const correlated = db + .selectFrom('categories') + .select([ + 'categories.id', + sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`.as('item_count') + ]); +show('correlated subquery', correlated.compile()); + +// Question 4: does an empty array still produce one parameter? +const emptyish = db + .selectFrom('items') + .select('items.id') + .where(sql`items.id = ANY(${[] as number[]}::int[])`); +show('empty array', emptyish.compile()); diff --git a/docs/superpowers/specs/2026-09-04-kysely-vs-drizzle.md b/docs/superpowers/specs/2026-09-04-kysely-vs-drizzle.md new file mode 100644 index 0000000..18b31a2 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-kysely-vs-drizzle.md @@ -0,0 +1,121 @@ +# Kysely against the Drizzle decision + +**Issue:** #297. Revisits #216, which chose Drizzle, and #217/#219, which landed it. + +The question is not "is Kysely good". It is whether Kysely is enough better, for this codebase specifically, to reverse a decision that is already made and partly built. That is a higher bar than being the nicer library, and this document answers it against the same target #216 used. + +## What is actually built today + +Worth stating precisely, because the answer turns on it. + +| Piece | State | +|---|---| +| `drizzle-orm` 0.45, `drizzle-kit` 0.31 | Installed | +| `src/db-drizzle/schema.ts`, `relations.ts` | Generated mirror of the migrations | +| `src/db-drizzle/itemFilters.drizzle.ts` | The #216 spike. Never imported by anything. | +| `src/db-drizzle/CONVENTIONS.md` | Written, and mostly a list of traps | +| `drizzleSchema.integration.test.ts` | Guards the mirror against drift | +| `src/routes/adminCategories.ts` | **The only converted file.** Three `db.` calls. | + +Against **238** `pool.query` / `client.query` call sites in `src/`. + +So the sunk cost is one converted file, a generated mirror that any query builder needs an equivalent of, and a drift test whose rationale is library-independent. That is a materially smaller commitment than "we have adopted Drizzle" suggests, and it is why this question is worth asking now rather than never. + +## The spike + +Kysely 0.28.17, in a scratch directory, against the same query #216 used to judge Drizzle. The spike is kept beside this document as `2026-09-04-kysely-spike.ts`; it is not part of any build and needs `kysely` installed to run, which is why it lives here rather than in `backend/src`. The query it expresses is: `buildItemFilterSql` — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. Kysely compiles without a connection, so what follows is the SQL it actually emitted, not a reading of its documentation. + +Every clause at once: + +```sql +select "items"."id", "items"."status", "items"."price_cents" from "items" where ( + items.category_id IN ( WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = ANY($1::int[]) + UNION ALL SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + ) SELECT id FROM subtree ) + and ( SELECT COUNT(*) FROM item_tags it + WHERE it.item_id = items.id AND it.tag_id = ANY($2::int[]) ) = $3 + and "items"."price_cents" >= $4 and "items"."price_cents" <= $5 + and "items"."status" in ($6, $7) + and exists (select "favorites"."item_id" from "favorites" + where "favorites"."item_id" = "items"."id" and "favorites"."customer_id" = $8)) +``` +``` +parameters: [[3,7],[11,12],2,1000,50000,"available","reserved",42] +``` + +The query is expressible, and it reads about as well as the Drizzle version. That was expected. What matters is the three things underneath it. + +### 1. An array is one parameter, with no ceremony + +`ANY($1::int[])`, parameter `[3,7]`. Written as a plain `${filters.categoryIds}` interpolation. + +This is the trap CONVENTIONS.md calls "the rule that will bite you", and in Drizzle it is real: `${array}` emits a **placeholder list**, producing `ANY(($1, $2)::int[])`, which is invalid Postgres. The remedy is to remember `sql.param()` at every array site, and the wrong form type-checks and reads correctly. The document's own assessment is that "across 187 call sites this is exactly the shape of defect that passes review and breaks in production". + +In Kysely the trap does not exist. An empty array behaves too — `ANY($1::int[])` with `[[]]`. + +### 2. The silently-wrong-data trap does not exist either + +CONVENTIONS.md's worst entry, because it produces valid SQL and quiet corruption: Drizzle renders a column reference inside a `sql` template **without its table**, so a correlated subquery silently correlates with itself. It cost #218 a count that returned 1 where 2 was correct, caught only because an integration test asserted a value. + +Kysely emitted the correlated subquery exactly as written: + +```sql +(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id) as "item_count" +``` + +There is no rewriting to be surprised by, because column references in a raw fragment are text you wrote and qualified yourself. + +### 3. The snake_case mapping layer disappears + +CONVENTIONS.md requires every converted select to map columns explicitly — `{ parent_id: categories.parentId }` — because Drizzle's mirror is camelCase while these APIs answer snake_case, and selecting the table directly "changes the JSON contract silently, and no test asserting status codes notices". + +Kysely's generated types use the database's own names. `items.price_cents` is `items.price_cents` in the type, in the query, and in the response. The mapping step, and the class of silent contract break it exists to prevent, are both gone. + +### What is unchanged + +The property that motivated the whole exercise holds identically. A hostile status value: + +```sql +select "items"."id" from "items" where "items"."status" = $1 +``` +``` +parameters: ["1); DROP TABLE items; --"] +``` + +`${value}` is a bind parameter, never text, and the escape hatch that looks like a plain template literal does not behave like one. #202's invariant becomes a property of the type system either way, and #180's S2077 hotspots retire either way. Kysely is not better here; it is equal, which is the point — the strongest argument for the original decision is not weakened by changing library. + +## The comparison that matters + +| | Drizzle | Kysely | +|---|---|---| +| Values parameterized by default | Yes | Yes | +| Arrays | `sql.param()` required; wrong form is invalid SQL at run time | One parameter, no ceremony | +| Columns in a raw fragment | Silently unqualified — valid SQL, wrong data | Text as written | +| Generated types' naming | camelCase; explicit mapping required at every select | The database's own names | +| Schema mirror | `drizzle-kit pull`, manual refresh, drift test needed | `kysely-codegen`, manual refresh, drift test needed | +| Migrations | Generation exists and had to be refused in #219 | No generation to refuse | +| Coexists with raw `pg` on one pool | Yes | Yes — takes a `pg` Pool directly | +| Shape | ORM with a query-builder mode | Query builder only | + +Three of the four hazards CONVENTIONS.md exists to warn about are properties of Drizzle, not of type-safe query building. Two of them are the silent kind. + +There is also a smaller thing worth naming because it is what prompted the question: Kysely reads more like LINQ-to-SQL — `.selectFrom().select().where()` chaining over the database's own column names. That is a preference, not an argument, and it does not carry weight on its own. It happens to point the same way as the evidence. + +## Recommendation + +**Switch to Kysely, now, while one file is converted.** + +The decision in #216 was right about the thing it was deciding — that a type-safe builder should replace hand-assembled SQL, and that the safety property is real rather than relocated. Nothing here disturbs that. What #216 could not know is that Drizzle's own conventions document would end up being mostly a list of ways to be quietly wrong, two of which produce working code and bad data. + +The cost of switching is small and knowable: reconvert `adminCategories.ts` (three calls), replace the generated mirror and repoint the drift test, rewrite CONVENTIONS.md — which gets substantially shorter, since three of its four warnings stop applying. The cost of not switching is paid 237 more times, in a codebase where the failure mode is a review that passes. + +The honest counter-argument, recorded rather than hidden: Drizzle is more widely used, and #219's reasoning about hand-written migrations was measured against `drizzle-kit` specifically. That second point survives the change — the reasoning was that generated migrations lose the prose and cannot express data migrations, which is true of any generator, and Kysely simply has nothing to refuse. + +## Out of scope + +**Converting anything.** This is the decision; the conversion is separate work with its own issue, and it stays file-by-file with both drivers on one pool either way. + +**Revisiting #219.** Migrations stay hand-written in `node-pg-migrate`. Nothing here touches that. + +**Removing Drizzle before Kysely replaces it.** If this is accepted, the mirror, the drift test and `adminCategories.ts` move together in one change, so `main` is never half-converted between two builders. -- 2.54.0 From e58f446853e731b3cf35f4fa2a060f69787d4c0b Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 14:55:32 -0500 Subject: [PATCH 2/8] docs(db): design the Kysely swap (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries out what #297 decided. Migrations are untouched — #219 stands, they are hand-written node-pg-migrate files, and Drizzle was never doing them. Both builders must not coexist at any commit. A main that carries Drizzle and Kysely together, even briefly, is one where the next person converting a query has to guess which to reach for and where two generated mirrors of one database can disagree. There is nothing to stage anyway: one file uses the builder. Three things the swap gets for free, recorded so they are not mistaken for scope creep. The worked example stops being a source file — itemFilters.drizzle.ts was never imported by anything, so it was dead code in src/ that only documentation justified, and its replacement belongs inside CONVENTIONS.md where a worked example goes. The drift test loses its library name, becoming schemaMirror.integration.test.ts, so the next such change renames nothing. And that test gets stricter rather than merely ported: the Drizzle version had to check every column two ways and its own comment calls that deliberately loose, where generated Kysely types emit the database's names verbatim and the check becomes one exact match. CATEGORY_COLUMNS disappears rather than being translated. It exists only because Drizzle's mirror is camelCase while the API answers snake_case, and its comment says selecting the table directly would silently change the JSON contract with no test noticing. With the generated types carrying parent_id and sort_order the mapping object has nothing left to do, which is the clearest single illustration of what the swap buys. isUniqueViolation keeps tolerating both error shapes and gains a test that proves which one actually arrives. Kysely uses the pg driver directly and is expected to leave the SQLSTATE on err.code, but "expected" is the word that turned two 409s into 500s last time. Closes #305 is deliberately not claimed here — this is the design, and the implementation follows on the same branch. Co-Authored-By: Claude Opus 5 --- .../specs/2026-09-04-kysely-swap-design.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-kysely-swap-design.md diff --git a/docs/superpowers/specs/2026-09-04-kysely-swap-design.md b/docs/superpowers/specs/2026-09-04-kysely-swap-design.md new file mode 100644 index 0000000..ecba55a --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-kysely-swap-design.md @@ -0,0 +1,84 @@ +# Swapping the query builder to Kysely + +**Issue:** #305. Carries out the decision recorded in #297, which re-opened the choice made in #216 and landed in #217/#218. + +Nothing here reopens #219. Migrations stay hand-written in `node-pg-migrate`, which is what they already are — Drizzle was never doing them. + +## What is actually changing + +One converted file, three calls, against 238 raw `pool.query` sites. That is the whole reason this is worth doing now rather than never: the commitment to Drizzle is far smaller than "we adopted Drizzle" suggests, and every month it grows. + +The safety property that motivated the adoption is unchanged and is not the thing being traded. In both libraries a value interpolated into a `sql` template becomes a bind parameter, never text, so #202's invariant stays a property of the type system and #180's S2077 hotspots retire either way. What changes is the three ways Drizzle makes it easy to be quietly wrong, verified in #297 against emitted SQL: an array needing `sql.param()` or producing invalid Postgres, a column reference inside a raw fragment silently losing its table, and a camelCase mirror that forces an explicit column map at every select or the JSON contract changes without a test noticing. + +## Decisions, and what each one rests on + +**Both builders must not coexist at any commit.** The swap lands as one change. A `main` that carries Drizzle and Kysely together, even briefly, is a `main` where the next person converting a query has to guess which one to reach for, and where two generated mirrors of one database can disagree. There is nothing to stage here — one file uses the builder. + +**Kysely takes the existing `pg` Pool.** Exactly as Drizzle does today, and for the same reason, which has not weakened: the conversion stays file by file, so most queries will be raw `pg` for a long time and the two must share one set of connections. A separate pool would make a transaction on one invisible to the other and would silently double the configured limits. + +**`src/db-drizzle/` becomes `src/db-kysely/`.** A rename rather than a new directory beside it, because the old one has no reason to survive the commit that empties it. + +**The worked example does not come across as a source file.** `itemFilters.drizzle.ts` was kept from #216 as the worked example and was never imported by anything — dead code in `src/` that only documentation justified. Its replacement lives inside `CONVENTIONS.md` as a fenced block, which is where a worked example belongs, and #297's spike stays in `docs/` as the record of how the decision was reached. This is a small improvement the swap makes free; it is not a change of intent. + +**The drift test survives the swap and loses its rename.** `drizzleSchema.integration.test.ts` becomes `schemaMirror.integration.test.ts` — named for what it guards rather than for the library that happens to generate the mirror, so the next such change renames nothing. The drift it exists for is library-independent and already happened once: the mirror sat missing `item_drafts` and `upload_links` from #222 until #217 and nothing noticed for a week. + +It also gets **stricter for free**. The Drizzle version had to check each column two ways — the bare camelCase key or an explicit string argument — and its own comment calls that "deliberately loose". kysely-codegen emits the database's names verbatim as bare keys, so the check becomes one exact match and the `snakeToCamel` helper goes away. + +**`CATEGORY_COLUMNS` stops being a mapping.** It exists as a mapping solely because Drizzle's mirror is camelCase while the API answers snake_case; its comment says selecting the table directly "would silently change the JSON contract, and no test that checks status codes would catch it". With generated types carrying `parent_id` and `sort_order`, there is nothing left to translate. What remains is a plain list of column names, shared by the four selects that want the same four columns — worth keeping for the ordinary reason any repeated literal is, but no longer a translation layer with a silent failure mode behind it. That is the clearest single illustration of what the swap buys, so the reconverted file should show it. + +**`isUniqueViolation` keeps accepting both error shapes, and gains a test that proves which one arrives.** Drizzle wraps driver errors, moving the SQLSTATE from `err.code` to `err.cause.code`; the old check compiled, never matched, and turned two 409s into 500s — a hazard with no type error behind it. Kysely uses the `pg` driver directly and is expected to leave the code where it was, but "expected" is exactly the word that made this a bug last time. The tolerant check stays, and an integration test asserts a duplicate sibling name still answers 409 rather than 500. + +## Architecture + +``` +backend/migrations node-pg-migrate, hand-written. Owns the schema. + │ + ▼ npm run db:types (manual, after every migration) +src/db-kysely/schema.ts Generated. kysely-codegen. Read-only mirror. + │ + ├─ schemaMirror.integration.test.ts fails when mirror and database disagree + │ + ▼ +src/db.ts export const db = new Kysely({ dialect: new PostgresDialect({ pool }) }) + │ ▲ + │ the same pool ┘ + ▼ `pool` still exports +src/routes/adminCategories.ts the one converted file +``` + +### Codegen + +``` +KYSELY_DATABASE_URL=postgres://user:pass@localhost:PORT/db npm run db:types +``` + +The env-var name mirrors `DRIZZLE_DATABASE_URL`'s reasoning: credentials come from the environment, and the name says which tool wants it so it is not mistaken for something the application reads. `kysely-codegen` accepts `--url env(KYSELY_DATABASE_URL)`, so the variable name is fixed in the script rather than interpolated by a shell, which keeps the command identical on Windows and Linux. + +`--exclude-pattern pgmigrations` replaces `tablesFilter: ['!pgmigrations']`. It is node-pg-migrate's bookkeeping and has no business in a generated model of the application's schema; the drift test asserts it stays out, because a regeneration that dropped the flag would quietly put it back. + +Run it against a database with every migration applied, **after** writing a migration. The drift test is what catches forgetting. + +## Failure handling + +| What happens | Result | +|---|---| +| A migration adds a table, nobody regenerates | `schemaMirror.integration.test.ts` fails naming the table. | +| A migration adds a column, nobody regenerates | Same test fails naming `table.column`. The likelier drift, and the one a table-level check waves through. | +| Someone regenerates without the exclude flag | The test fails on `pgmigrations`. | +| The mirror names something the database does not have | The test fails. A migration was rolled back without regenerating. | +| Kysely surfaces the unique violation on `err.cause.code` after all | `isUniqueViolation` already accepts it, and the new integration test proves the 409 rather than assuming it. | + +## Testing + +- **Integration:** the existing category suite must pass unchanged — it is the contract this file answers, and the whole point is that the JSON is byte-identical afterwards. Plus the duplicate-name 409 assertion described above. +- **Schema mirror:** the four drift cases, ported to the new generated shape and tightened to an exact column match. +- **Unit:** none needed. There is no new pure logic; `requireRow` is untouched. +- **Whole suite:** backend unit and integration both green, because a builder swap that changes a shared `db.ts` can break something nowhere near the diff. + +## Out of scope + +**Converting anything beyond `adminCategories.ts`.** The remaining 238 sites stay raw `pg`, file by file, under their own issue. This change makes the next conversion possible; it does not perform it. + +**Migrations.** #219 stands untouched, and Kysely has no migration generator to refuse. + +**`CamelCasePlugin`.** kysely-codegen offers `--camel-case` and using it would reintroduce precisely the mapping problem this swap removes. -- 2.54.0 From b4d51febac82e466114077929b52fc3be5fe031d Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 14:59:00 -0500 Subject: [PATCH 3/8] docs(db): plan the Kysely swap (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tasks. The first is the whole swap in one commit — dependencies, generated types, db.ts, the reconverted file, the ported drift test and the new 409 assertion — because splitting it would put a commit on the branch where the build is broken or both builders are present, and neither is a state worth being able to bisect to. The second is the conventions document, which touches no code and is much shorter than the one it replaces. The plan carries the converted adminCategories.ts in full rather than describing it, and names the two places the conversion could silently change behaviour: the four selects must keep answering id, name, parent_id, sort_order and item_count, and the unique-violation catch must keep producing a 409. The existing category integration suite is the gate on the first, and a new test is the gate on the second. Three expected outputs are written down so a wrong one is caught at the step rather than three steps later. Codegen must report 18 tables, not 19 — 19 means pgmigrations leaked past the exclude flag. The generated Categories interface must spell parent_id and sort_order, because camelCase there means --camel-case got turned on and the mapping layer this swap removes has come straight back. And the integration count should rise by exactly one. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-04-kysely-swap.md | 704 ++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-kysely-swap.md diff --git a/docs/superpowers/plans/2026-09-04-kysely-swap.md b/docs/superpowers/plans/2026-09-04-kysely-swap.md new file mode 100644 index 0000000..9c52291 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-kysely-swap.md @@ -0,0 +1,704 @@ +# Kysely Swap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Drizzle with Kysely as the backend's query builder, in one commit that leaves no trace of Drizzle behind. + +**Architecture:** `node-pg-migrate` keeps owning the schema. `kysely-codegen` replaces `drizzle-kit pull` as the source of generated types, `src/db-kysely/` replaces `src/db-drizzle/`, `db` in `src/db.ts` becomes a `Kysely` instance over the same `pg` Pool, and `adminCategories.ts` — the one converted file — is reconverted. + +**Tech Stack:** Express 4 + TypeScript, `pg`, Kysely 0.28, kysely-codegen 0.20, Jest + supertest. + +**Spec:** `docs/superpowers/specs/2026-09-04-kysely-swap-design.md` + +## Global Constraints + +- **Drizzle and Kysely must not both be present at the end of Task 1.** `drizzle-orm`, `drizzle-kit`, `drizzle.config.ts` and `src/db-drizzle/` are all gone by then. `grep -ri drizzle backend/src backend/package.json` returns nothing. +- **Kysely takes the existing `pg` Pool.** `new PostgresDialect({ pool })` using the `pool` already exported from `src/db.ts`. Never let Kysely create its own — a transaction on one pool is invisible to the other and the configured limits would silently double. +- **`node-pg-migrate` is untouched.** No migration is written, generated, or altered. #219 stands. +- **The API contract does not change.** `adminCategories.ts` must answer byte-identical JSON: `id`, `name`, `parent_id`, `sort_order`, `item_count`. The existing integration suite is the gate. +- **Do not use `--camel-case`.** kysely-codegen offers it; using it would reintroduce the mapping layer this swap removes. +- **All SQL is parameterized.** Never interpolate a value into a query string. +- **Every Express route handler stays wrapped in `asyncRoute`.** +- Commit subjects end with `(#305)`. **Commit bodies are never hard-wrapped** — one long line per paragraph. End every body with `Co-Authored-By: Claude Opus 5 `. +- **Do not push.** Commit locally only. +- **Do not run `scripts/start-local.ps1` or `scripts/run-tests.ps1`** — they prompt for UAC and hang. +- **Node 20 is required.** The shell default is 18.x and Jest fails on it. Prepend `export PATH="/c/Users/tlamb/AppData/Local/nvm/v20.20.2:$PATH"` to every command. +- Integration tests need the test database: `npm run db:test:up` from `backend`, reachable at `postgres://redefined_test:redefined_test@localhost:55432/redefined_test`. + +--- + +## File Structure + +| File | Change | +|---|---| +| `backend/package.json` | Remove `drizzle-orm`, `drizzle-kit`. Add `kysely`, `kysely-codegen`. Add the `db:types` script. | +| `backend/drizzle.config.ts` | Delete. | +| `backend/src/db-drizzle/` | Delete the whole directory. | +| `backend/src/db-kysely/schema.ts` | Create — generated by `npm run db:types`, never hand-edited. | +| `backend/src/db.ts` | `db` becomes a `Kysely` over the same pool. | +| `backend/src/routes/adminCategories.ts` | Reconverted. | +| `backend/tests/integration/drizzleSchema.integration.test.ts` | Rename to `schemaMirror.integration.test.ts` and port. | +| `backend/tests/integration/categoriesTags.integration.test.ts` | Add the duplicate-name 409 assertion. | +| `backend/src/db-kysely/CONVENTIONS.md` | Task 2. Replaces `db-drizzle/CONVENTIONS.md`. | + +--- + +## Task 1: The swap + +**Files:** +- Modify: `backend/package.json`, `backend/src/db.ts`, `backend/src/routes/adminCategories.ts`, `backend/tests/integration/categoriesTags.integration.test.ts` +- Create: `backend/src/db-kysely/schema.ts` (generated) +- Delete: `backend/drizzle.config.ts`, `backend/src/db-drizzle/` (all four files) +- Rename: `backend/tests/integration/drizzleSchema.integration.test.ts` → `backend/tests/integration/schemaMirror.integration.test.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `export const db: Kysely` from `backend/src/db.ts`, and `export interface DB` from `backend/src/db-kysely/schema.ts`. Task 2 documents both. + +- [ ] **Step 1: Swap the dependencies** + +From `backend`: + +```bash +npm uninstall drizzle-orm drizzle-kit +npm install kysely +npm install --save-dev kysely-codegen +``` + +- [ ] **Step 2: Add the codegen script** + +In `backend/package.json`, add to `scripts`, immediately after `"migrate:create"`: + +```json +"db:types": "kysely-codegen --dialect postgres --exclude-pattern pgmigrations --url \"env(KYSELY_DATABASE_URL)\" --out-file src/db-kysely/schema.ts" +``` + +`env(...)` is kysely-codegen's own syntax for naming an environment variable, so the variable name lives in the script rather than being interpolated by a shell — which keeps the command identical on Windows and Linux. `--exclude-pattern pgmigrations` replaces `tablesFilter: ['!pgmigrations']` from the deleted `drizzle.config.ts`. + +- [ ] **Step 3: Generate the schema** + +Bring the test database up if it is not already, then generate: + +```bash +npm run db:test:up +KYSELY_DATABASE_URL=postgres://redefined_test:redefined_test@localhost:55432/redefined_test npm run db:types +``` + +Expected: `✓ Introspected 18 tables and generated src/db-kysely/schema.ts`. **18, not 19** — if it says 19, `pgmigrations` leaked in and the `--exclude-pattern` flag is wrong. + +Confirm the shape is what the rest of this task assumes: + +```bash +grep -A20 "export interface DB" src/db-kysely/schema.ts +grep -A6 "export interface Categories" src/db-kysely/schema.ts +``` + +Expected: `DB` maps snake_case table names to interfaces, and `Categories` declares `id: Generated`, `name: string`, `parent_id: number | null`, `sort_order: Generated`. Column names are snake_case — that is the point of the swap, and `--camel-case` must not be used. + +- [ ] **Step 4: Delete Drizzle** + +```bash +rm backend/drizzle.config.ts +rm -r backend/src/db-drizzle +``` + +- [ ] **Step 5: Point `db.ts` at Kysely** + +In `backend/src/db.ts`, replace the two Drizzle imports and the `db` export. The `pool` export and `requireRow` are unchanged. + +Imports become: + +```ts +import { Pool } from 'pg'; +import { Kysely, PostgresDialect } from 'kysely'; +import type { DB } from './db-kysely/schema'; +``` + +And the `db` export, replacing the existing one and its comment: + +```ts +/** + * Kysely over the same pool, alongside `pool` rather than instead of it. + * + * Both have to work at once: the conversion is file by file across 238 call + * sites, so for a long time most queries will still be raw `pg` and the two + * must share one set of connections. Handing Kysely the existing pool rather + * than letting it open its own is what makes that true — otherwise a + * transaction started on one would be invisible to the other, and the pool + * limits would silently double. + * + * The value of this over raw `pg` is not brevity. In a Kysely `sql` template + * `${value}` emits a bind parameter, never text, so there is no way to spell + * "interpolate this as SQL" by accident. That makes the #202 invariant + * structural instead of a comment plus two mutation tests, and it is the main + * reason a builder is here at all. + * + * Kysely rather than Drizzle since #305. The safety property above was true of + * both; what decided it is that three of the four hazards in the old + * CONVENTIONS.md — an array needing sql.param(), a column reference silently + * losing its table inside a raw fragment, and a camelCase mirror that had to be + * mapped back at every select — were properties of Drizzle rather than of + * type-safe query building. See #297 for the SQL each one actually emitted. + */ +export const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); +``` + +- [ ] **Step 6: Reconvert `adminCategories.ts`** + +Replace the whole file with: + +```ts +import { Router, Request, Response } from 'express'; +import { sql } from 'kysely'; +import { db, requireRow } from '../db'; +import { asyncRoute } from '../asyncRoute'; + +/** + * The one file using the builder (#218, reconverted for Kysely in #305), chosen + * because it is awkward rather than because it is easy — a recursive CTE, a + * correlated count, and an array match. + * + * The pool is still available and most of the application still uses it. This + * is one file converted, not a cutover. See src/db-kysely/CONVENTIONS.md. + */ + +/** + * The four columns this API answers with, named once. + * + * Under Drizzle this was a mapping — `{ parent_id: categories.parentId }` — and + * it existed because the generated mirror was camelCase while this API answers + * snake_case, so selecting the table directly changed the JSON contract with no + * test noticing. The generated types now carry the database's own names, so + * there is nothing left to translate and this is just a list of columns four + * selects happen to share. + */ +const CATEGORY_COLUMNS = ['id', 'name', 'parent_id', 'sort_order'] as const; + +const router = Router(); + +// Postgres unique-violation SQLSTATE — raised by the two partial indexes that +// stop siblings sharing a name. +const UNIQUE_VIOLATION = '23505'; + +/** + * Whether a thrown error is that unique violation. + * + * Both shapes are accepted deliberately. Drizzle wrapped driver errors, moving + * this SQLSTATE from `err.code` to `err.cause.code`, and the check that only + * looked at `err.code` still compiled, never matched, and turned two 409s into + * 500s — a conversion hazard with no type error behind it. Kysely uses the `pg` + * driver directly and is expected to leave it on `err.code`, but "expected" is + * the word that caused the bug last time, so the tolerant check stays and an + * integration test proves the 409 rather than assuming it. See #218, #305. + */ +function isUniqueViolation(err: unknown): boolean { + const direct = (err as { code?: string }).code; + const wrapped = (err as { cause?: { code?: string } }).cause?.code; + return direct === UNIQUE_VIOLATION || wrapped === UNIQUE_VIOLATION; +} + +/** + * Walks down from a node, collecting it and every descendant. Used both for + * cycle detection on reparent and for reporting the blast radius of a delete. + * + * Still a `sql` template: the CTE is recursive and is consumed in two different + * shapes, and expressing it through the builder buys nothing over SQL that is + * already correct and reviewed. The important part is that `${id}` is a bind + * parameter, not text — there is no way to spell string interpolation in this + * template by accident, which is the property the whole adoption is for. + */ +const subtreeOf = (id: number) => sql` + WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = ${id} + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + )`; + +function readName(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + +// Distinguishes "not supplied" from "explicitly cleared to root". +function readParentId(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined; + return parsed; +} + +async function parentExists(id: number): Promise { + const row = await db + .selectFrom('categories') + .select('id') + .where('id', '=', id) + .executeTakeFirst(); + return row !== undefined; +} + +router.get('/', asyncRoute(async (_req: Request, res: Response) => { + const rows = await db + .selectFrom('categories') + .select(CATEGORY_COLUMNS) + // Literal text rather than interpolated column references, and here that is + // a free choice rather than a workaround: the fragment binds no values, so + // there is nothing to parameterize. Under Drizzle this had to be literal, + // because interpolating the columns rendered them unqualified and Postgres + // resolved both sides against items, answering with a plausible wrong + // number rather than an error (#218). + .select( + sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`.as( + 'item_count' + ) + ) + .orderBy('sort_order') + .orderBy(sql`lower(categories.name)`) + .execute(); + + res.json(rows); +})); + +router.post('/', asyncRoute(async (req: Request, res: Response) => { + const name = readName(req.body.name); + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const parentId = readParentId(req.body.parent_id); + if (parentId === undefined && req.body.parent_id !== undefined) { + return res.status(400).json({ error: 'invalid parent_id' }); + } + const parent = parentId ?? null; + if (parent !== null && !(await parentExists(parent))) { + return res.status(400).json({ error: 'parent category does not exist' }); + } + + const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0; + + try { + const rows = await db + .insertInto('categories') + .values({ name, parent_id: parent, sort_order: sortOrder }) + .returning(CATEGORY_COLUMNS) + .execute(); + + res.status(201).json({ ...requireRow(rows, 'the category INSERT'), item_count: 0 }); + } catch (err) { + if (isUniqueViolation(err)) { + return res.status(409).json({ error: 'a category with that name already exists here' }); + } + throw err; + } +})); + +// Works out what parent_id an update should land on. Absent means "leave it +// alone", so the current value is echoed back rather than treated as a clear. +// Returns the refusal instead of sending it, keeping the response the +// handler's business and the two ways a parent can be invalid out of its body. +type ParentResolution = { error: string } | { parent: number | null }; + +async function resolveParentId( + submitted: unknown, + id: number, + current: number | null +): Promise { + if (submitted === undefined) { + return { parent: current }; + } + + const parsed = readParentId(submitted); + if (parsed === undefined) { + return { error: 'invalid parent_id' }; + } + if (parsed === null) { + return { parent: null }; + } + if (!(await parentExists(parsed))) { + return { error: 'parent category does not exist' }; + } + + // Moving a node beneath itself or one of its own descendants would detach + // that whole branch from the tree into an unreachable cycle. + const cycle = await sql<{ found: number }>` + ${subtreeOf(id)} SELECT 1 AS found FROM subtree WHERE id = ${parsed} + `.execute(db); + if (cycle.rows.length) { + return { error: 'a category cannot be moved beneath itself' }; + } + + return { parent: parsed }; +} + +router.put('/:id', asyncRoute(async (req: Request, res: Response) => { + const id = Number(req.params.id); + const current = await db + .selectFrom('categories') + .select(CATEGORY_COLUMNS) + .where('id', '=', id) + .executeTakeFirst(); + + if (!current) { + return res.status(404).json({ error: 'not found' }); + } + + let name = current.name; + if (req.body.name !== undefined) { + const parsed = readName(req.body.name); + if (!parsed) { + return res.status(400).json({ error: 'name is required' }); + } + name = parsed; + } + + const resolved = await resolveParentId(req.body.parent_id, id, current.parent_id); + if ('error' in resolved) { + return res.status(400).json({ error: resolved.error }); + } + const parent = resolved.parent; + + const sortOrder = Number.isSafeInteger(req.body.sort_order) + ? req.body.sort_order + : current.sort_order; + + try { + const rows = await db + .updateTable('categories') + .set({ name, parent_id: parent, sort_order: sortOrder }) + .where('id', '=', id) + .returning(CATEGORY_COLUMNS) + .execute(); + + res.json(requireRow(rows, 'the category UPDATE')); + } catch (err) { + if (isUniqueViolation(err)) { + return res.status(409).json({ error: 'a category with that name already exists here' }); + } + throw err; + } +})); + +router.delete('/:id', asyncRoute(async (req: Request, res: Response) => { + const id = Number(req.params.id); + + const subtree = await sql<{ id: number }>` + ${subtreeOf(id)} SELECT id FROM subtree + `.execute(db); + if (!subtree.rows.length) { + return res.status(404).json({ error: 'not found' }); + } + + const ids = subtree.rows.map((row) => row.id); + + // `in` rather than the ANY(...::int[]) this replaced. Kysely emits the + // placeholder list itself, so it is correct by construction and there is no + // template to forget anything in. `ids` is never empty — the length check + // above returned already if it were. + const affected = await db + .selectFrom('items') + .select(sql`COUNT(*)::int`.as('n')) + .where('category_id', 'in', ids) + .execute(); + + // The FK cascade takes the descendants; items fall back to NULL rather than + // being deleted along with their category. + await db.deleteFrom('categories').where('id', '=', id).execute(); + + res.json({ + deleted_categories: ids.length, + uncategorized_items: requireRow(affected, 'the affected-items COUNT').n + }); +})); + +export default router; +``` + +- [ ] **Step 7: Port the drift test** + +Rename the file and replace its two parsing helpers. Keep every doc comment that explains *why* the test exists — the history in it is the reason it is trusted. + +```bash +git mv backend/tests/integration/drizzleSchema.integration.test.ts backend/tests/integration/schemaMirror.integration.test.ts +``` + +In the renamed file: change the `readFileSync` path from `'db-drizzle', 'schema.ts'` to `'db-kysely', 'schema.ts'`, rename the describe block from `'the Drizzle schema mirror'` to `'the generated schema mirror'`, and make these three replacements. + +`mirroredTables` — the table names now live in the `DB` interface rather than in `pgTable(...)` calls: + +```ts +/** + * Every table name the generated mirror declares. + * + * Read from the `DB` interface, which is the one place kysely-codegen lists + * them all, mapping the database's own snake_case name to the interface for + * that table. Parsing the file as text rather than importing it is deliberate + * and unchanged from the Drizzle version: these are TypeScript types, erased at + * run time, so there is nothing to import and inspect. + */ +function mirroredTables(): string[] { + const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? ''; + return [...block.matchAll(/^\s*([a-z_]+):/gm)].map((m) => m[1]!).sort(); +} +``` + +The `pgmigrations` test's comment changes to name the new flag: + +```ts + // pgmigrations is node-pg-migrate's bookkeeping, excluded by + // --exclude-pattern in the db:types script. A regeneration without that flag + // would quietly put it back. +``` + +And the column check gets stricter, losing the two-way match and the `snakeToCamel` helper entirely — **delete that function**: + +```ts + // Columns, not just tables: a migration that adds a column to a table the + // mirror already knows about is the likelier drift, and the one a table-level + // check would wave through. + // + // One exact match, where the Drizzle version needed two and called itself + // "deliberately loose" for it. kysely-codegen emits the database's own name + // as a bare interface key, so ` column_name:` at the start of a line is the + // whole of the naming rule and there is nothing to re-implement. + it('declares every column of every table it mirrors', async () => { + const { rows } = await pool.query<{ table_name: string; column_name: string }>( + `SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name <> 'pgmigrations' + ORDER BY table_name, column_name` + ); + + const declared = new Set( + [...SCHEMA.matchAll(/^\s{2}([a-z_]+):/gm)].map((m) => m[1]!) + ); + const missing = rows + .filter((row) => !declared.has(row.column_name)) + .map((row) => `${row.table_name}.${row.column_name}`); + + expect(missing).toEqual([]); + }); +``` + +- [ ] **Step 8: Prove the 409 still happens** + +In `backend/tests/integration/categoriesTags.integration.test.ts`, add this test inside the existing describe block that covers category creation. If the file's existing tests use a helper to create a category, use it rather than the raw request below; otherwise this shape is correct as written. + +```ts + // The conversion hazard from #218, asserted rather than assumed. Drizzle + // wrapped driver errors and moved the unique-violation SQLSTATE from + // err.code to err.cause.code, which turned this 409 into a 500 with nothing + // failing to compile. #305 changed builder again, so this proves where the + // code actually lands rather than trusting that Kysely leaves it alone. + it('refuses a duplicate sibling name with 409, not 500', async () => { + const first = await request(app).post('/api/admin/categories').send({ name: 'Duplicate me' }); + expect(first.status).toBe(201); + + const second = await request(app).post('/api/admin/categories').send({ name: 'Duplicate me' }); + + expect(second.status).toBe(409); + expect(second.body.error).toMatch(/already exists/); + }); +``` + +- [ ] **Step 9: Verify no Drizzle survives** + +```bash +grep -ri drizzle backend/src backend/tests backend/package.json backend/package-lock.json --include='*.ts' --include='*.json' -l +``` + +Expected: no output from `backend/src` or `backend/tests`. `package-lock.json` may still list transitive entries removed by npm — if `drizzle` appears there, run `npm install` once more from `backend` and re-check. `backend/drizzle.config.ts` and `backend/src/db-drizzle/` must not exist. + +- [ ] **Step 10: Run everything** + +```bash +cd backend +npm run build +npm run lint +npx jest -c jest.unit.config.js +npx jest -c jest.integration.config.js --runInBand +``` + +Expected: build clean, lint 0 errors, 466 unit tests passing, 445 integration tests passing (444 before, plus the new 409 assertion). If the category suite fails, the JSON contract changed — that is the failure this task exists to prevent, so fix the query rather than the test. + +- [ ] **Step 11: Commit** + +```bash +git add -A backend +git commit -F- <<'EOF' +refactor(db): swap the query builder from Drizzle to Kysely (#305) + +One commit, because a main that carries both builders is one where the next person converting a query has to guess which to reach for, and where two generated mirrors of one database can disagree. There was nothing to stage anyway: one file used the builder. + +The safety property that motivated adopting a builder at all is untouched, and was never the thing being traded. A value interpolated into a sql template becomes a bind parameter in either library, so #202's invariant stays a property of the type system and #180's hotspots retire either way. What changes is the three ways the old library made it easy to be quietly wrong, each verified in #297 against the SQL actually emitted: an array interpolating as a placeholder list unless every site remembered sql.param(), a column reference inside a raw fragment silently losing its table so a correlated subquery correlated with itself, and a camelCase mirror that had to be mapped back at every select or the JSON contract changed with no test noticing. + +CATEGORY_COLUMNS stops being a translation layer and becomes what it looks like — four column names four selects share. The generated types carry parent_id and sort_order because kysely-codegen emits the database's own names, so there is nothing left to map and nothing left to get wrong by forgetting to. + +The drift guard survives the swap rather than being rewritten, and loses its library name in the process: it is schemaMirror.integration.test.ts now, so the next such change renames nothing. It also got stricter for free. The Drizzle version had to match each column two ways and its own comment called that deliberately loose; a generated Kysely interface spells the database's name verbatim as a bare key, so one exact match is the whole rule and snakeToCamel is gone. + +isUniqueViolation keeps accepting both error shapes and now has a test behind it. Kysely uses the pg driver directly and should leave the SQLSTATE on err.code, but "should" is the word that turned two 409s into 500s when the last conversion moved it to err.cause.code with nothing failing to compile. + +Migrations are untouched. #219 stands, they remain hand-written node-pg-migrate files, and Kysely has no generator to refuse. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 2: The conventions document + +**Files:** +- Create: `backend/src/db-kysely/CONVENTIONS.md` + +**Interfaces:** +- Consumes: `db` from `backend/src/db.ts` and `DB` from `backend/src/db-kysely/schema.ts` (Task 1). This task writes no code. +- Produces: nothing code depends on. + +The old document was mostly a list of ways to be quietly wrong, and three of its four warnings no longer apply. The replacement is shorter for that reason — do not pad it back out, and do not carry across a warning that is no longer true. + +- [ ] **Step 1: Write the document** + +Create `backend/src/db-kysely/CONVENTIONS.md`: + +````markdown +# Kysely conventions + +Decided in #216, rebuilt on Kysely in #305 for the reasons in #297. Read this before converting a query. + +## What is in this directory + +| File | Owner | +|---|---| +| `schema.ts` | **Generated.** `kysely-codegen` output. Do not hand-edit. | +| `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 +KYSELY_DATABASE_URL=postgres://user:pass@localhost:PORT/db npm run db:types +``` + +Run it against a database with every migration applied, after writing a migration. `schemaMirror.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, and nobody had reason to look. A stale mirror is worse than none — row types are inferred 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 reason this is worth doing + +`${value}` in a Kysely `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, 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. + +## Both drivers run at once + +`db` and `pool` are exported from `src/db.ts` and share one pool, deliberately. Conversion is file by file across 238 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. + +Column names need no translation. The generated types carry the database's own snake_case, which is also what these APIs answer with, so a select names the columns it wants and the JSON comes out right. Do not turn on kysely-codegen's `--camel-case`: it would reintroduce a mapping layer whose failure mode is a silently changed response that no status-code test catches. + +## Driver errors are not wrapped + +Kysely uses the `pg` driver directly, so a Postgres SQLSTATE stays on `err.code`. This is worth stating only because it was not true before: Drizzle wrapped driver errors and moved the code to `err.cause.code`, so a `catch` keyed on it still compiled, never matched, and turned a handled 409 into a 500. `adminCategories.ts` has `isUniqueViolation`, which accepts both shapes and has an integration test behind it. Reuse that pattern, and keep the test. + +## The worked example + +`buildItemFilterSql` is the hardest query in the codebase — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. It was the #216 spike's target and #297's, and it is here rather than in a source file because nothing imports it: + +```ts +if (filters.categoryIds.length) { + clauses.push(sql`items.category_id IN ( + WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = ANY(${filters.categoryIds}::int[]) + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + ) + SELECT id FROM subtree + )`); +} + +if (filters.tagIds.length) { + clauses.push(sql`( + SELECT COUNT(*) FROM item_tags it + WHERE it.item_id = items.id AND it.tag_id = ANY(${filters.tagIds}::int[]) + ) = ${filters.tagIds.length}`); +} + +if (filters.status !== null) clauses.push(eb('items.status', 'in', filters.status)); +``` + +Two things in there are worth pointing at, because both were traps in the previous library and are not traps here. + +`${filters.categoryIds}` emits **one** bind parameter holding the whole array — `ANY($1::int[])` — rather than a placeholder list. Drizzle emitted `ANY(($1, $2)::int[])`, which is invalid Postgres, unless every array site remembered `sql.param()`. + +The column references inside those templates are text you wrote and qualified yourself, so `it.item_id = items.id` means what it says. Drizzle rendered an interpolated column reference without its table, so a correlated subquery silently correlated with itself — valid SQL, quietly wrong data, and the reason #218 got a count of 1 where 2 was correct. + +That second one is why a converted query containing a correlated subquery or a self-join still deserves a test asserting **values** rather than a status code. The library no longer makes the mistake for you; writing the wrong column name in a raw fragment is still your own to make. + +## Migrations stay hand-written + +Decided in **#219** and unchanged by #305: `node-pg-migrate` keeps the schema, the builder is for queries only. + +Three reasons, all measured rather than assumed. `drizzle-kit generate` could not 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 first of those was specific to `drizzle-kit`. The other two are true of any generator, which is why the decision survives the change of library — and Kysely, which ships no generator anyone was asking us to use, has nothing to refuse. + +The workflow: write the migration by hand, then run `npm run db:types` to refresh the mirror. `schemaMirror.integration.test.ts` fails if you forget. +```` + +- [ ] **Step 2: Check the links it makes are real** + +```bash +cd backend +test -f src/db-kysely/schema.ts && echo "schema present" +grep -n "db:types" package.json +grep -n "isUniqueViolation" src/routes/adminCategories.ts +test -f tests/integration/schemaMirror.integration.test.ts && echo "drift test present" +``` + +Expected: all four confirm. Every path and script name this document names must exist, because a conventions file that points at something gone is worse than none. + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/db-kysely/CONVENTIONS.md +git commit -F- <<'EOF' +docs(db): write the Kysely conventions (#305) + +Replaces the Drizzle conventions, and is much shorter, because three of that document's four warnings described the library rather than the practice and stopped being true when the library changed. An array is one bind parameter with no ceremony, a column reference in a raw fragment is the text you wrote, and the generated names are the database's own so nothing needs mapping back. + +What survives is what was never about Drizzle. The mirror is generated and refreshing it is manual, so the drift test is the thing that catches forgetting — and it exists because the drift already happened once and nobody noticed for a week. Both drivers share one pool, because a transaction on a second pool would be invisible to the first and the limits would silently double. Migrations stay hand-written, and the reasoning survives the change of library: only the expression-index complaint was specific to drizzle-kit, while losing the prose and being unable to express data migrations are true of any generator. + +One warning is genuinely new, and it is the inverse of an old one: driver errors are no longer wrapped, so a SQLSTATE sits on err.code again. That is worth stating precisely because it was not true before, and the last time it moved it turned a handled 409 into a 500 with nothing failing to compile. + +The worked example moved into this file rather than staying a source file nothing imports. It is documentation, and it was only ever documentation. + +Closes #305 + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec requirement | Task | +|---|---| +| Both builders must not coexist | 1 (steps 1, 4, 9) | +| Kysely takes the existing `pg` Pool | 1 (step 5) | +| `src/db-drizzle/` becomes `src/db-kysely/` | 1 (steps 3, 4) | +| The worked example is not a source file | 2 (inside CONVENTIONS.md) | +| Drift test survives, renamed and stricter | 1 (step 7) | +| `CATEGORY_COLUMNS` stops being a mapping | 1 (step 6) | +| `isUniqueViolation` keeps both shapes, gains a test | 1 (steps 6, 8) | +| Codegen command and `--exclude-pattern` | 1 (steps 2, 3) | +| `--camel-case` must not be used | Global constraints; 1 (step 3); 2 | +| Migrations untouched | Global constraints; 2 | +| Existing category suite passes unchanged | 1 (step 10) | +| Whole backend suite green | 1 (step 10) | + +No gaps. + +**Placeholder scan:** none. Every code step carries literal code; every command step carries the literal command and its expected output. + +**Type consistency:** `DB` is generated in step 3 and imported in step 5 under that exact name. `db` is exported from `src/db.ts` (step 5) and imported by `adminCategories.ts` (step 6) and used by the `sql` fragments' `.execute(db)`. `CATEGORY_COLUMNS` is a `readonly ['id','name','parent_id','sort_order']` throughout step 6, passed to `.select()` and `.returning()` in all four places. `requireRow` keeps its existing `(rows: T[], what: string) => T` signature and is only ever handed `.execute()` results, which are arrays — never `executeTakeFirst()`, which returns `T | undefined` and is branched on directly instead. -- 2.54.0 From 119f75369e4a506da335751eed936a9887af4a20 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 15:08:02 -0500 Subject: [PATCH 4/8] refactor(db): swap the query builder from Drizzle to Kysely (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One commit, because a main that carries both builders is one where the next person converting a query has to guess which to reach for, and where two generated mirrors of one database can disagree. There was nothing to stage anyway: one file used the builder. The safety property that motivated adopting a builder at all is untouched, and was never the thing being traded. A value interpolated into a sql template becomes a bind parameter in either library, so #202's invariant stays a property of the type system and #180's hotspots retire either way. What changes is the three ways the old library made it easy to be quietly wrong, each verified in #297 against the SQL actually emitted: an array interpolating as a placeholder list unless every site remembered sql.param(), a column reference inside a raw fragment silently losing its table so a correlated subquery correlated with itself, and a camelCase mirror that had to be mapped back at every select or the JSON contract changed with no test noticing. CATEGORY_COLUMNS stops being a translation layer and becomes what it looks like — four column names four selects share. The generated types carry parent_id and sort_order because kysely-codegen emits the database's own names, so there is nothing left to map and nothing left to get wrong by forgetting to. The drift guard survives the swap rather than being rewritten, and loses its library name in the process: it is schemaMirror.integration.test.ts now, so the next such change renames nothing. It also got stricter for free. The Drizzle version had to match each column two ways and its own comment called that deliberately loose; a generated Kysely interface spells the database's name verbatim as a bare key, so one exact match is the whole rule and snakeToCamel is gone. isUniqueViolation keeps accepting both error shapes and now has a test behind it. Kysely uses the pg driver directly and should leave the SQLSTATE on err.code, but "should" is the word that turned two 409s into 500s when the last conversion moved it to err.cause.code with nothing failing to compile. Migrations are untouched. #219 stands, they remain hand-written node-pg-migrate files, and Kysely has no generator to refuse. Co-Authored-By: Claude Opus 5 --- backend/drizzle.config.ts | 31 - backend/package-lock.json | 1292 +++-------------- backend/package.json | 7 +- backend/src/db-drizzle/CONVENTIONS.md | 78 - backend/src/db-drizzle/itemFilters.drizzle.ts | 74 - backend/src/db-drizzle/relations.ts | 171 --- backend/src/db-drizzle/schema.ts | 341 ----- backend/src/db-kysely/schema.ts | 231 +++ backend/src/db.ts | 36 +- backend/src/routes/adminCategories.ts | 153 +- .../categoriesTags.integration.test.ts | 14 + ...st.ts => schemaMirror.integration.test.ts} | 42 +- 12 files changed, 567 insertions(+), 1903 deletions(-) delete mode 100644 backend/drizzle.config.ts delete mode 100644 backend/src/db-drizzle/CONVENTIONS.md delete mode 100644 backend/src/db-drizzle/itemFilters.drizzle.ts delete mode 100644 backend/src/db-drizzle/relations.ts delete mode 100644 backend/src/db-drizzle/schema.ts create mode 100644 backend/src/db-kysely/schema.ts rename backend/tests/integration/{drizzleSchema.integration.test.ts => schemaMirror.integration.test.ts} (66%) diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts deleted file mode 100644 index d637073..0000000 --- a/backend/drizzle.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineConfig } from 'drizzle-kit'; - -// Spike configuration (#216). Credentials come from the environment rather than -// this file — the same rule the rest of the repo follows, and this one points at -// a developer's local database, not a deployed one. -// -// DRIZZLE_DATABASE_URL=postgres://redefined_local:redefined_local@localhost:55500/redefined_local -export default defineConfig({ - dialect: 'postgresql', - schema: './src/db-drizzle/schema.ts', - - // `drizzle-kit pull` writes its output here, so this points at the directory - // the application actually imports from. The spike pulled into ./drizzle and - // copied the file into src/ by hand, and that copy drifted exactly as - // predicted — not because anyone re-pulled, but because #222 added - // item_drafts and upload_links and the mirror was never refreshed. Nothing - // noticed for a week. Pulling in place removes the copy step that made that - // possible. See #217. - // - // If #219 ever chooses generated migrations, they also land in `out`, and - // this will need splitting then. It is a queries-only mirror today. - out: './src/db-drizzle', - - // pgmigrations is node-pg-migrate's own bookkeeping. It is not part of the - // application's schema and has no business in a generated model of it. - tablesFilter: ['!pgmigrations'], - - dbCredentials: { - url: process.env.DRIZZLE_DATABASE_URL ?? '' - } -}); diff --git a/backend/package-lock.json b/backend/package-lock.json index 77e41bf..1fd1d56 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,9 +12,9 @@ "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", - "drizzle-orm": "^0.45.2", "express": "^4.19.2", "express-rate-limit": "^8.6.2", + "kysely": "^0.28.17", "markdown-it": "^15.0.0", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", @@ -36,11 +36,11 @@ "@types/nodemailer": "^6.4.15", "@types/pg": "^8.11.6", "@types/supertest": "^6.0.2", - "drizzle-kit": "^0.31.10", "eslint": "^9.39.5", "eslint-plugin-sonarjs": "^4.2.0", "globals": "^17.11.0", "jest": "^29.7.0", + "kysely-codegen": "^0.20.0", "supertest": "^7.0.0", "ts-jest": "^29.2.4", "tsx": "^4.16.5", @@ -625,460 +625,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -4238,6 +3784,63 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4374,6 +3977,16 @@ "wrappy": "1" } }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -4384,629 +3997,46 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/drizzle-kit": { - "version": "0.31.10", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", - "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.4", - "tsx": "^4.21.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dotenv": "^16.4.5" }, "engines": { - "node": ">=18" + "node": ">=12" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/drizzle-orm": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", - "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dunder-proto": { @@ -5076,6 +4106,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5996,19 +5036,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", - "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7236,6 +6263,83 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/kysely-codegen": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/kysely-codegen/-/kysely-codegen-0.20.0.tgz", + "integrity": "sha512-LSi2KBG7uDmNCZ+XurLSA9LH7XFyyoQ6xb5DLJSInPTSYLVApjOP2KwO8mSaREWTtoX+C2AG2GTlYR0DLjTbcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "cosmiconfig": "^9.0.0", + "diff": "^8.0.3", + "dotenv": "^17.2.4", + "dotenv-expand": "^12.0.3", + "micromatch": "^4.0.8", + "minimist": "^1.2.8", + "pluralize": "^8.0.0", + "zod": "^4.3.6" + }, + "bin": { + "kysely-codegen": "dist/cli/bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@libsql/kysely-libsql": ">=0.3.0 <0.5.0", + "@tediousjs/connection-string": "^1.0.0", + "better-sqlite3": ">=7.6.2 <13.0.0", + "kysely": ">=0.27.0 <1.0.0", + "kysely-bun-sqlite": ">=0.3.2 <1.0.0", + "kysely-bun-worker": ">=1.2.0 <2.0.0", + "mysql2": ">=2.3.3 <4.0.0", + "pg": ">=8.8.0 <9.0.0", + "tarn": ">=3.0.0 <4.0.0", + "tedious": ">=18.0.0 <20.0.0" + }, + "peerDependenciesMeta": { + "@libsql/kysely-libsql": { + "optional": true + }, + "@tediousjs/connection-string": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "kysely": { + "optional": false + }, + "kysely-bun-sqlite": { + "optional": true + }, + "kysely-bun-worker": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "tarn": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8125,6 +7229,16 @@ "node": ">=8" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -8420,16 +7534,6 @@ "node": ">=8" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/resolve.exports": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index c55db81..9f7654a 100755 --- a/backend/package.json +++ b/backend/package.json @@ -26,16 +26,17 @@ "db:test:down": "docker compose -f docker-compose.test.yml down -v", "migrate:up": "node migrate.js up", "migrate:down": "node migrate.js down", - "migrate:create": "node-pg-migrate create --migration-file-language js" + "migrate:create": "node-pg-migrate create --migration-file-language js", + "db:types": "kysely-codegen --dialect postgres --exclude-pattern pgmigrations --url \"env(KYSELY_DATABASE_URL)\" --out-file src/db-kysely/schema.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.122.0", "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", - "drizzle-orm": "^0.45.2", "express": "^4.19.2", "express-rate-limit": "^8.6.2", + "kysely": "^0.28.17", "markdown-it": "^15.0.0", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", @@ -57,11 +58,11 @@ "@types/nodemailer": "^6.4.15", "@types/pg": "^8.11.6", "@types/supertest": "^6.0.2", - "drizzle-kit": "^0.31.10", "eslint": "^9.39.5", "eslint-plugin-sonarjs": "^4.2.0", "globals": "^17.11.0", "jest": "^29.7.0", + "kysely-codegen": "^0.20.0", "supertest": "^7.0.0", "ts-jest": "^29.2.4", "tsx": "^4.16.5", diff --git a/backend/src/db-drizzle/CONVENTIONS.md b/backend/src/db-drizzle/CONVENTIONS.md deleted file mode 100644 index 294d86f..0000000 --- a/backend/src/db-drizzle/CONVENTIONS.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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. diff --git a/backend/src/db-drizzle/itemFilters.drizzle.ts b/backend/src/db-drizzle/itemFilters.drizzle.ts deleted file mode 100644 index 0f4b7b5..0000000 --- a/backend/src/db-drizzle/itemFilters.drizzle.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Spike (#216): buildItemFilterSql expressed with Drizzle. -// -// Deliberately the hardest thing in the codebase — six optional clauses composed -// at run time, a recursive CTE for the category subtree, an ANY(...::int[]) tag -// match with a count equality, and array parameters. If this cannot be said -// cleanly, nothing else in the conversion matters. - -import { SQL, and, gte, lte, sql, inArray, exists } from 'drizzle-orm'; -import { items, itemTags, favorites, categories } from './schema'; - -export interface SpikeFilters { - categoryIds: number[]; - tagIds: number[]; - minPriceCents: number | null; - maxPriceCents: number | null; - status: string[] | null; - favoritesOnly: boolean; -} - -export function buildItemFilterDrizzle( - filters: SpikeFilters, - favoritesCustomerId: number | null -): SQL[] { - const clauses: SQL[] = []; - - // The recursive CTE. Drizzle's $with() builds statement-level CTEs; this one - // has to sit inside an IN (...) subquery, so it stays a sql`` template. - // - // Note what that template does with ${filters.categoryIds}: it emits a BIND - // PARAMETER, not text. That is the difference from a plain JS template - // literal, and it is the whole of the #202 invariant expressed by the type - // system rather than by a comment — there is no way to spell "interpolate - // this value as SQL text" by accident. - if (filters.categoryIds.length) { - clauses.push(sql`${items.categoryId} IN ( - WITH RECURSIVE subtree AS ( - SELECT id FROM ${categories} WHERE id = ANY(${sql.param(filters.categoryIds)}::int[]) - UNION ALL - SELECT c.id FROM ${categories} c JOIN subtree s ON c.parent_id = s.id - ) - SELECT id FROM subtree - )`); - } - - // AND, not OR: the item must carry every selected tag, so the count of - // matched rows has to equal the number requested. - if (filters.tagIds.length) { - clauses.push(sql`( - SELECT COUNT(*) FROM ${itemTags} it - WHERE it.item_id = ${items.id} AND it.tag_id = ANY(${sql.param(filters.tagIds)}::int[]) - ) = ${filters.tagIds.length}`); - } - - if (filters.minPriceCents !== null) clauses.push(gte(items.priceCents, filters.minPriceCents)); - if (filters.maxPriceCents !== null) clauses.push(lte(items.priceCents, filters.maxPriceCents)); - - // inArray replaces `= ANY($n::text[])`. Drizzle emits an IN list of binds. - if (filters.status !== null) clauses.push(inArray(items.status, filters.status)); - - if (filters.favoritesOnly) { - if (favoritesCustomerId === null) throw new Error('favorites filter requires a customer id'); - clauses.push( - exists( - sql`(SELECT 1 FROM ${favorites} f WHERE f.item_id = ${items.id} AND f.customer_id = ${favoritesCustomerId})` - ) - ); - } - - return clauses; -} - -export function combine(clauses: SQL[]): SQL | undefined { - return clauses.length ? and(...clauses) : undefined; -} diff --git a/backend/src/db-drizzle/relations.ts b/backend/src/db-drizzle/relations.ts deleted file mode 100644 index 171897b..0000000 --- a/backend/src/db-drizzle/relations.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { relations } from "drizzle-orm/relations"; -import { categories, items, itemImages, customers, customerSessions, customerTokens, carts, cartItems, shippingAddresses, checkouts, orders, itemDrafts, uploadLinks, itemTags, tags, checkoutItems, favorites } from "./schema"; - -export const itemsRelations = relations(items, ({one, many}) => ({ - category: one(categories, { - fields: [items.categoryId], - references: [categories.id] - }), - itemImages: many(itemImages), - cartItems: many(cartItems), - orders: many(orders), - itemDrafts: many(itemDrafts), - itemTags: many(itemTags), - checkoutItems: many(checkoutItems), - favorites: many(favorites), -})); - -export const categoriesRelations = relations(categories, ({one, many}) => ({ - items: many(items), - category: one(categories, { - fields: [categories.parentId], - references: [categories.id], - relationName: "categories_parentId_categories_id" - }), - categories: many(categories, { - relationName: "categories_parentId_categories_id" - }), - itemDrafts: many(itemDrafts), -})); - -export const itemImagesRelations = relations(itemImages, ({one}) => ({ - item: one(items, { - fields: [itemImages.itemId], - references: [items.id] - }), -})); - -export const customerSessionsRelations = relations(customerSessions, ({one}) => ({ - customer: one(customers, { - fields: [customerSessions.customerId], - references: [customers.id] - }), -})); - -export const customersRelations = relations(customers, ({many}) => ({ - customerSessions: many(customerSessions), - customerTokens: many(customerTokens), - carts: many(carts), - shippingAddresses: many(shippingAddresses), - checkouts: many(checkouts), - orders: many(orders), - favorites: many(favorites), -})); - -export const customerTokensRelations = relations(customerTokens, ({one}) => ({ - customer: one(customers, { - fields: [customerTokens.customerId], - references: [customers.id] - }), -})); - -export const cartsRelations = relations(carts, ({one, many}) => ({ - customer: one(customers, { - fields: [carts.customerId], - references: [customers.id] - }), - cartItems: many(cartItems), -})); - -export const cartItemsRelations = relations(cartItems, ({one}) => ({ - cart: one(carts, { - fields: [cartItems.cartId], - references: [carts.id] - }), - item: one(items, { - fields: [cartItems.itemId], - references: [items.id] - }), -})); - -export const shippingAddressesRelations = relations(shippingAddresses, ({one, many}) => ({ - customer: one(customers, { - fields: [shippingAddresses.customerId], - references: [customers.id] - }), - checkouts: many(checkouts), -})); - -export const checkoutsRelations = relations(checkouts, ({one, many}) => ({ - customer: one(customers, { - fields: [checkouts.customerId], - references: [customers.id] - }), - shippingAddress: one(shippingAddresses, { - fields: [checkouts.shippingAddressId], - references: [shippingAddresses.id] - }), - orders: many(orders), - checkoutItems: many(checkoutItems), -})); - -export const ordersRelations = relations(orders, ({one}) => ({ - item: one(items, { - fields: [orders.itemId], - references: [items.id] - }), - customer: one(customers, { - fields: [orders.customerId], - references: [customers.id] - }), - checkout: one(checkouts, { - fields: [orders.checkoutId], - references: [checkouts.id] - }), -})); - -export const itemDraftsRelations = relations(itemDrafts, ({one}) => ({ - item: one(items, { - fields: [itemDrafts.itemId], - references: [items.id] - }), - uploadLink: one(uploadLinks, { - fields: [itemDrafts.uploadLinkId], - references: [uploadLinks.id] - }), - category: one(categories, { - fields: [itemDrafts.aiCategoryId], - references: [categories.id] - }), -})); - -export const uploadLinksRelations = relations(uploadLinks, ({many}) => ({ - itemDrafts: many(itemDrafts), -})); - -export const itemTagsRelations = relations(itemTags, ({one}) => ({ - item: one(items, { - fields: [itemTags.itemId], - references: [items.id] - }), - tag: one(tags, { - fields: [itemTags.tagId], - references: [tags.id] - }), -})); - -export const tagsRelations = relations(tags, ({many}) => ({ - itemTags: many(itemTags), -})); - -export const checkoutItemsRelations = relations(checkoutItems, ({one}) => ({ - checkout: one(checkouts, { - fields: [checkoutItems.checkoutId], - references: [checkouts.id] - }), - item: one(items, { - fields: [checkoutItems.itemId], - references: [items.id] - }), -})); - -export const favoritesRelations = relations(favorites, ({one}) => ({ - customer: one(customers, { - fields: [favorites.customerId], - references: [customers.id] - }), - item: one(items, { - fields: [favorites.itemId], - references: [items.id] - }), -})); \ No newline at end of file diff --git a/backend/src/db-drizzle/schema.ts b/backend/src/db-drizzle/schema.ts deleted file mode 100644 index a7bf63f..0000000 --- a/backend/src/db-drizzle/schema.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { pgTable, index, foreignKey, serial, text, integer, timestamp, unique, boolean, jsonb, uniqueIndex, primaryKey, pgSequence } from "drizzle-orm/pg-core" -import { sql } from "drizzle-orm" - - -export const pgmigrationsIdSeq = pgSequence("pgmigrations_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) - -export const items = pgTable("items", { - id: serial().primaryKey().notNull(), - name: text().notNull(), - description: text(), - priceCents: integer("price_cents").default(8000).notNull(), - status: text().default('pending').notNull(), - reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }), - soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }), - paypalOrderId: text("paypal_order_id"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - categoryId: integer("category_id"), -}, (table) => [ - index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")), - foreignKey({ - columns: [table.categoryId], - foreignColumns: [categories.id], - name: "items_category_id_fkey" - }).onDelete("set null"), -]); - -export const itemImages = pgTable("item_images", { - id: serial().primaryKey().notNull(), - itemId: integer("item_id").notNull(), - imagePath: text("image_path").notNull(), - sortOrder: integer("sort_order").default(0).notNull(), - originalImagePath: text("original_image_path"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "item_images_item_id_fkey" - }).onDelete("cascade"), -]); - -export const customerSessions = pgTable("customer_sessions", { - token: text().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "customer_sessions_customer_id_fkey" - }).onDelete("cascade"), -]); - -export const customerTokens = pgTable("customer_tokens", { - token: text().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - kind: text().notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "customer_tokens_customer_id_fkey" - }).onDelete("cascade"), -]); - -export const adminSettings = pgTable("admin_settings", { - key: text().primaryKey().notNull(), - value: text().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}); - -export const customers = pgTable("customers", { - id: serial().primaryKey().notNull(), - email: text().notNull(), - passwordHash: text("password_hash").notNull(), - emailVerified: boolean("email_verified").default(false).notNull(), - marketingConsent: boolean("marketing_consent").default(false).notNull(), - marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }), - marketingConsentText: text("marketing_consent_text"), - unsubscribeToken: text("unsubscribe_token").notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - disabledAt: timestamp("disabled_at", { withTimezone: true, mode: 'string' }), - favoriteAlerts: boolean("favorite_alerts").default(false).notNull(), - favoriteAlertsAt: timestamp("favorite_alerts_at", { withTimezone: true, mode: 'string' }), - favoriteAlertsText: text("favorite_alerts_text"), - firstName: text("first_name"), - lastName: text("last_name"), -}, (table) => [ - index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`), - unique("customers_email_key").on(table.email), - unique("customers_unsubscribe_token_key").on(table.unsubscribeToken), -]); - -export const carts = pgTable("carts", { - id: serial().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "carts_customer_id_fkey" - }).onDelete("cascade"), - unique("carts_customer_id_key").on(table.customerId), -]); - -export const cartItems = pgTable("cart_items", { - id: serial().primaryKey().notNull(), - cartId: integer("cart_id").notNull(), - itemId: integer("item_id").notNull(), - addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), - lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }), -}, (table) => [ - foreignKey({ - columns: [table.cartId], - foreignColumns: [carts.id], - name: "cart_items_cart_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "cart_items_item_id_fkey" - }).onDelete("cascade"), - unique("cart_items_item_id_key").on(table.itemId), -]); - -export const shippingAddresses = pgTable("shipping_addresses", { - id: serial().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - fullName: text("full_name").notNull(), - addressLine1: text("address_line1").notNull(), - addressLine2: text("address_line2"), - city: text().notNull(), - state: text().notNull(), - postalCode: text("postal_code").notNull(), - country: text().default('US').notNull(), - isDefault: boolean("is_default").default(false).notNull(), - uspsValidated: boolean("usps_validated").default(false).notNull(), - uspsStandardized: jsonb("usps_standardized"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "shipping_addresses_customer_id_fkey" - }).onDelete("cascade"), -]); - -export const checkouts = pgTable("checkouts", { - id: serial().primaryKey().notNull(), - customerId: integer("customer_id"), - shippingAddressId: integer("shipping_address_id"), - processor: text().notNull(), - processorOrderId: text("processor_order_id"), - amountCents: integer("amount_cents"), - status: text().default('pending').notNull(), - rawEvent: jsonb("raw_event"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "checkouts_customer_id_fkey" - }).onDelete("set null"), - foreignKey({ - columns: [table.shippingAddressId], - foreignColumns: [shippingAddresses.id], - name: "checkouts_shipping_address_id_fkey" - }).onDelete("set null"), -]); - -export const orders = pgTable("orders", { - id: serial().primaryKey().notNull(), - itemId: integer("item_id"), - customerId: integer("customer_id"), - checkoutId: integer("checkout_id"), - processor: text().notNull(), - processorOrderId: text("processor_order_id"), - amountCents: integer("amount_cents"), - status: text(), - rawEvent: jsonb("raw_event"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "orders_item_id_fkey" - }), - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "orders_customer_id_fkey" - }).onDelete("set null"), - foreignKey({ - columns: [table.checkoutId], - foreignColumns: [checkouts.id], - name: "orders_checkout_id_fkey" - }).onDelete("set null"), -]); - -export const categories = pgTable("categories", { - id: serial().primaryKey().notNull(), - name: text().notNull(), - parentId: integer("parent_id"), - sortOrder: integer("sort_order").default(0).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`), - index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")), - uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`), - foreignKey({ - columns: [table.parentId], - foreignColumns: [table.id], - name: "categories_parent_id_fkey" - }).onDelete("cascade"), -]); - -export const tags = pgTable("tags", { - id: serial().primaryKey().notNull(), - name: text().notNull(), - color: text().notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`), -]); - -export const itemDrafts = pgTable("item_drafts", { - id: serial().primaryKey().notNull(), - itemId: integer("item_id").notNull(), - uploadLinkId: integer("upload_link_id"), - submitterNote: text("submitter_note"), - removeBackground: boolean("remove_background").default(true).notNull(), - state: text().default('queued').notNull(), - attempts: integer().default(0).notNull(), - model: text(), - aiName: text("ai_name"), - aiDescription: text("ai_description"), - aiCategoryId: integer("ai_category_id"), - aiTagNames: text("ai_tag_names").array(), - aiSuggestedPriceCents: integer("ai_suggested_price_cents"), - priceSource: text("price_source").default('default').notNull(), - aiError: text("ai_error"), - inputTokens: integer("input_tokens"), - outputTokens: integer("output_tokens"), - costMicros: integer("cost_micros"), - draftedAt: timestamp("drafted_at", { withTimezone: true, mode: 'string' }), - reviewedAt: timestamp("reviewed_at", { withTimezone: true, mode: 'string' }), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - index("item_drafts_state_idx").using("btree", table.state.asc().nullsLast().op("text_ops")), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "item_drafts_item_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.uploadLinkId], - foreignColumns: [uploadLinks.id], - name: "item_drafts_upload_link_id_fkey" - }).onDelete("set null"), - foreignKey({ - columns: [table.aiCategoryId], - foreignColumns: [categories.id], - name: "item_drafts_ai_category_id_fkey" - }).onDelete("set null"), - unique("item_drafts_item_id_key").on(table.itemId), -]); - -export const uploadLinks = pgTable("upload_links", { - id: serial().primaryKey().notNull(), - label: text().notNull(), - tokenHash: text("token_hash").notNull(), - revokedAt: timestamp("revoked_at", { withTimezone: true, mode: 'string' }), - submissionCount: integer("submission_count").default(0).notNull(), - maxSubmissions: integer("max_submissions"), - lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: 'string' }), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - contactEmail: text("contact_email"), -}, (table) => [ - unique("upload_links_token_hash_key").on(table.tokenHash), -]); - -export const itemTags = pgTable("item_tags", { - itemId: integer("item_id").notNull(), - tagId: integer("tag_id").notNull(), -}, (table) => [ - index("item_tags_tag_id_idx").using("btree", table.tagId.asc().nullsLast().op("int4_ops")), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "item_tags_item_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.tagId], - foreignColumns: [tags.id], - name: "item_tags_tag_id_fkey" - }).onDelete("cascade"), - primaryKey({ columns: [table.itemId, table.tagId], name: "item_tags_pkey"}), -]); - -export const checkoutItems = pgTable("checkout_items", { - checkoutId: integer("checkout_id").notNull(), - itemId: integer("item_id").notNull(), - priceCents: integer("price_cents").notNull(), -}, (table) => [ - foreignKey({ - columns: [table.checkoutId], - foreignColumns: [checkouts.id], - name: "checkout_items_checkout_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "checkout_items_item_id_fkey" - }).onDelete("cascade"), - primaryKey({ columns: [table.checkoutId, table.itemId], name: "checkout_items_pkey"}), -]); - -export const favorites = pgTable("favorites", { - customerId: integer("customer_id").notNull(), - itemId: integer("item_id").notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - index("favorites_item_id_idx").using("btree", table.itemId.asc().nullsLast().op("int4_ops")), - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "favorites_customer_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "favorites_item_id_fkey" - }).onDelete("cascade"), - primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}), -]); diff --git a/backend/src/db-kysely/schema.ts b/backend/src/db-kysely/schema.ts new file mode 100644 index 0000000..0b290fd --- /dev/null +++ b/backend/src/db-kysely/schema.ts @@ -0,0 +1,231 @@ +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ + +import type { ColumnType } from "kysely"; + +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; + +export type Json = JsonValue; + +export type JsonArray = JsonValue[]; + +export type JsonObject = { + [x: string]: JsonValue | undefined; +}; + +export type JsonPrimitive = boolean | number | string | null; + +export type JsonValue = JsonArray | JsonObject | JsonPrimitive; + +export type Timestamp = ColumnType; + +export interface AdminSettings { + key: string; + updated_at: Generated; + value: string; +} + +export interface CartItems { + added_at: Generated; + cart_id: number; + expires_at: Timestamp; + id: Generated; + item_id: number; + last_reminder_sent_at: Timestamp | null; +} + +export interface Carts { + created_at: Generated; + customer_id: number; + id: Generated; + updated_at: Generated; +} + +export interface Categories { + created_at: Generated; + id: Generated; + name: string; + parent_id: number | null; + sort_order: Generated; +} + +export interface CheckoutItems { + checkout_id: number; + item_id: number; + price_cents: number; +} + +export interface Checkouts { + amount_cents: number | null; + created_at: Generated; + customer_id: number | null; + id: Generated; + processor: string; + processor_order_id: string | null; + raw_event: Json | null; + shipping_address_id: number | null; + status: Generated; +} + +export interface Customers { + created_at: Generated; + disabled_at: Timestamp | null; + email: string; + email_verified: Generated; + favorite_alerts: Generated; + favorite_alerts_at: Timestamp | null; + favorite_alerts_text: string | null; + first_name: string | null; + id: Generated; + last_name: string | null; + marketing_consent: Generated; + marketing_consent_at: Timestamp | null; + marketing_consent_text: string | null; + password_hash: string; + unsubscribe_token: string; +} + +export interface CustomerSessions { + created_at: Generated; + customer_id: number; + expires_at: Timestamp; + token: string; +} + +export interface CustomerTokens { + created_at: Generated; + customer_id: number; + expires_at: Timestamp; + kind: string; + token: string; +} + +export interface Favorites { + created_at: Generated; + customer_id: number; + item_id: number; +} + +export interface ItemDrafts { + ai_category_id: number | null; + ai_description: string | null; + ai_error: string | null; + ai_name: string | null; + ai_suggested_price_cents: number | null; + ai_tag_names: string[] | null; + attempts: Generated; + cost_micros: number | null; + created_at: Generated; + drafted_at: Timestamp | null; + id: Generated; + input_tokens: number | null; + item_id: number; + model: string | null; + output_tokens: number | null; + price_source: Generated; + remove_background: Generated; + reviewed_at: Timestamp | null; + state: Generated; + submitter_note: string | null; + upload_link_id: number | null; +} + +export interface ItemImages { + created_at: Generated; + id: Generated; + image_path: string; + item_id: number; + original_image_path: string | null; + sort_order: Generated; +} + +export interface Items { + category_id: number | null; + created_at: Generated; + description: string | null; + id: Generated; + name: string; + paypal_order_id: string | null; + price_cents: Generated; + reserved_until: Timestamp | null; + sold_at: Timestamp | null; + status: Generated; +} + +export interface ItemTags { + item_id: number; + tag_id: number; +} + +export interface Orders { + amount_cents: number | null; + checkout_id: number | null; + created_at: Generated; + customer_id: number | null; + id: Generated; + item_id: number | null; + processor: string; + processor_order_id: string | null; + raw_event: Json | null; + status: string | null; +} + +export interface ShippingAddresses { + address_line1: string; + address_line2: string | null; + city: string; + country: Generated; + created_at: Generated; + customer_id: number; + full_name: string; + id: Generated; + is_default: Generated; + postal_code: string; + state: string; + usps_standardized: Json | null; + usps_validated: Generated; +} + +export interface Tags { + color: string; + created_at: Generated; + id: Generated; + name: string; +} + +export interface UploadLinks { + contact_email: string | null; + created_at: Generated; + id: Generated; + label: string; + last_used_at: Timestamp | null; + max_submissions: number | null; + revoked_at: Timestamp | null; + submission_count: Generated; + token_hash: string; +} + +export interface DB { + admin_settings: AdminSettings; + cart_items: CartItems; + carts: Carts; + categories: Categories; + checkout_items: CheckoutItems; + checkouts: Checkouts; + customer_sessions: CustomerSessions; + customer_tokens: CustomerTokens; + customers: Customers; + favorites: Favorites; + item_drafts: ItemDrafts; + item_images: ItemImages; + item_tags: ItemTags; + items: Items; + orders: Orders; + shipping_addresses: ShippingAddresses; + tags: Tags; + upload_links: UploadLinks; +} diff --git a/backend/src/db.ts b/backend/src/db.ts index 446197a..0bb90e7 100755 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -1,6 +1,6 @@ import { Pool } from 'pg'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from './db-drizzle/schema'; +import { Kysely, PostgresDialect } from 'kysely'; +import type { DB } from './db-kysely/schema'; export const pool = new Pool({ host: process.env.PGHOST, @@ -11,25 +11,29 @@ export const pool = new Pool({ }); /** - * Drizzle over the same pool, alongside `pool` rather than instead of it. + * Kysely over the same pool, alongside `pool` rather than instead of it. * - * Both have to work at once: the conversion decided in #216 is file by file - * across 187 call sites, so for a long time most queries will still be raw `pg` - * and the two must share one set of connections. Handing drizzle the existing - * pool rather than letting it open its own is what makes that true — otherwise - * a transaction started on one would be invisible to the other, and the pool + * Both have to work at once: the conversion is file by file across 238 call + * sites, so for a long time most queries will still be raw `pg` and the two + * must share one set of connections. Handing Kysely the existing pool rather + * than letting it open its own is what makes that true — otherwise a + * transaction started on one would be invisible to the other, and the pool * limits would silently double. * - * The value of this over raw `pg` is not brevity. In a Drizzle `sql` template - * `${value}` emits a **bind parameter**, never text, so there is no way to - * spell "interpolate this as SQL" by accident — the escape hatch that looks - * like a plain template literal does not behave like one. That makes the #202 - * invariant structural instead of a comment plus two mutation tests, and it is - * the main reason this adoption is worth doing. + * The value of this over raw `pg` is not brevity. In a Kysely `sql` template + * `${value}` emits a bind parameter, never text, so there is no way to spell + * "interpolate this as SQL" by accident. That makes the #202 invariant + * structural instead of a comment plus two mutation tests, and it is the main + * reason a builder is here at all. * - * The trap that goes with it is arrays. See db-drizzle/CONVENTIONS.md. + * Kysely rather than Drizzle since #305. The safety property above was true of + * both; what decided it is that three of the four hazards in the old + * CONVENTIONS.md — an array needing sql.param(), a column reference silently + * losing its table inside a raw fragment, and a camelCase mirror that had to be + * mapped back at every select — were properties of Drizzle rather than of + * type-safe query building. See #297 for the SQL each one actually emitted. */ -export const db = drizzle(pool, { schema }); +export const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); /** * The single row a query is guaranteed to have returned. diff --git a/backend/src/routes/adminCategories.ts b/backend/src/routes/adminCategories.ts index bb392d0..6bbe39f 100644 --- a/backend/src/routes/adminCategories.ts +++ b/backend/src/routes/adminCategories.ts @@ -1,38 +1,28 @@ import { Router, Request, Response } from 'express'; -import { eq, inArray, sql } from 'drizzle-orm'; +import { sql } from 'kysely'; import { db, requireRow } from '../db'; -import { categories, items } from '../db-drizzle/schema'; import { asyncRoute } from '../asyncRoute'; /** - * The first file converted to Drizzle (#218), chosen because it is awkward - * rather than because it is easy — nine sites including a recursive CTE and an - * array match. See src/db-drizzle/CONVENTIONS.md. + * The one file using the builder (#218, reconverted for Kysely in #305), chosen + * because it is awkward rather than because it is easy — a recursive CTE, a + * correlated count, and an array match. * * The pool is still available and most of the application still uses it. This - * is one file moving, not a cutover. + * is one file converted, not a cutover. See src/db-kysely/CONVENTIONS.md. */ /** - * The response shape, written once. + * The four columns this API answers with, named once. * - * The generated mirror names columns in camelCase — `parentId`, `sortOrder` — - * and this API answers in snake_case, which the admin frontend reads. So the - * mapping is explicit here rather than implicit anywhere: selecting the table - * directly would silently change the JSON contract, and no test that checks - * status codes would catch it. - * - * It also answers the question #218 asked. The row type is inferred from this - * object rather than hand-declared beside the query, so the interfaces that used - * to sit at the top of this file are gone and cannot drift from what is - * selected. + * Under Drizzle this was a mapping — `{ parent_id: categories.parentId }` — and + * it existed because the generated mirror was camelCase while this API answers + * snake_case, so selecting the table directly changed the JSON contract with no + * test noticing. The generated types now carry the database's own names, so + * there is nothing left to translate and this is just a list of columns four + * selects happen to share. */ -const CATEGORY_COLUMNS = { - id: categories.id, - name: categories.name, - parent_id: categories.parentId, - sort_order: categories.sortOrder -}; +const CATEGORY_COLUMNS = ['id', 'name', 'parent_id', 'sort_order'] as const; const router = Router(); @@ -43,11 +33,13 @@ const UNIQUE_VIOLATION = '23505'; /** * Whether a thrown error is that unique violation. * - * Drizzle wraps driver errors, so the SQLSTATE that used to sit on `err.code` - * now sits on `err.cause.code`. The old check still compiled and simply never - * matched, turning two 409s into 500s — a conversion hazard with no type error - * and no failing build behind it, only two integration tests. Both shapes are - * accepted so this keeps working either side of a conversion. See #218. + * Both shapes are accepted deliberately. Drizzle wrapped driver errors, moving + * this SQLSTATE from `err.code` to `err.cause.code`, and the check that only + * looked at `err.code` still compiled, never matched, and turned two 409s into + * 500s — a conversion hazard with no type error behind it. Kysely uses the `pg` + * driver directly and is expected to leave it on `err.code`, but "expected" is + * the word that caused the bug last time, so the tolerant check stays and an + * integration test proves the 409 rather than assuming it. See #218, #305. */ function isUniqueViolation(err: unknown): boolean { const direct = (err as { code?: string }).code; @@ -59,12 +51,11 @@ function isUniqueViolation(err: unknown): boolean { * Walks down from a node, collecting it and every descendant. Used both for * cycle detection on reparent and for reporting the blast radius of a delete. * - * Still a `sql` template. Drizzle has `$with()` for CTEs, but this one is - * recursive and is consumed in two different shapes, and expressing it through - * the builder bought nothing over the SQL that is already correct and reviewed. - * The important part is that `${id}` here is a bind parameter, not text — there - * is no way to spell string interpolation in this template by accident, which is - * the property the whole adoption is for. + * Still a `sql` template: the CTE is recursive and is consumed in two different + * shapes, and expressing it through the builder buys nothing over SQL that is + * already correct and reviewed. The important part is that `${id}` is a bind + * parameter, not text — there is no way to spell string interpolation in this + * template by accident, which is the property the whole adoption is for. */ const subtreeOf = (id: number) => sql` WITH RECURSIVE subtree AS ( @@ -89,28 +80,32 @@ function readParentId(value: unknown): number | null | undefined { } async function parentExists(id: number): Promise { - const rows = await db - .select({ id: categories.id }) - .from(categories) - .where(eq(categories.id, id)) - .limit(1); - return rows.length > 0; + const row = await db + .selectFrom('categories') + .select('id') + .where('id', '=', id) + .executeTakeFirst(); + return row !== undefined; } router.get('/', asyncRoute(async (_req: Request, res: Response) => { const rows = await db - .select({ - ...CATEGORY_COLUMNS, - // Written as literal SQL, NOT with ${items.categoryId} and - // ${categories.id}. Drizzle renders a column reference inside a sql - // template UNQUALIFIED — those two produced `WHERE "category_id" = "id"`, - // which Postgres resolved against items for both sides and answered with - // a plausible wrong number rather than an error. There are no values to - // bind in this fragment, so literal text is the honest form. See #218. - item_count: sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)` - }) - .from(categories) - .orderBy(categories.sortOrder, sql`lower(categories.name)`); + .selectFrom('categories') + .select(CATEGORY_COLUMNS) + // Literal text rather than interpolated column references, and here that is + // a free choice rather than a workaround: the fragment binds no values, so + // there is nothing to parameterize. Under Drizzle this had to be literal, + // because interpolating the columns rendered them unqualified and Postgres + // resolved both sides against items, answering with a plausible wrong + // number rather than an error (#218). + .select( + sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`.as( + 'item_count' + ) + ) + .orderBy('sort_order') + .orderBy(sql`lower(categories.name)`) + .execute(); res.json(rows); })); @@ -134,9 +129,10 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => { try { const rows = await db - .insert(categories) - .values({ name, parentId: parent, sortOrder }) - .returning(CATEGORY_COLUMNS); + .insertInto('categories') + .values({ name, parent_id: parent, sort_order: sortOrder }) + .returning(CATEGORY_COLUMNS) + .execute(); res.status(201).json({ ...requireRow(rows, 'the category INSERT'), item_count: 0 }); } catch (err) { @@ -175,9 +171,9 @@ async function resolveParentId( // Moving a node beneath itself or one of its own descendants would detach // that whole branch from the tree into an unreachable cycle. - const cycle = await db.execute( - sql`${subtreeOf(id)} SELECT 1 FROM subtree WHERE id = ${parsed}` - ); + const cycle = await sql<{ found: number }>` + ${subtreeOf(id)} SELECT 1 AS found FROM subtree WHERE id = ${parsed} + `.execute(db); if (cycle.rows.length) { return { error: 'a category cannot be moved beneath itself' }; } @@ -187,13 +183,12 @@ async function resolveParentId( router.put('/:id', asyncRoute(async (req: Request, res: Response) => { const id = Number(req.params.id); - const existing = await db + const current = await db + .selectFrom('categories') .select(CATEGORY_COLUMNS) - .from(categories) - .where(eq(categories.id, id)) - .limit(1); + .where('id', '=', id) + .executeTakeFirst(); - const current = existing[0]; if (!current) { return res.status(404).json({ error: 'not found' }); } @@ -219,10 +214,11 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => { try { const rows = await db - .update(categories) - .set({ name, parentId: parent, sortOrder }) - .where(eq(categories.id, id)) - .returning(CATEGORY_COLUMNS); + .updateTable('categories') + .set({ name, parent_id: parent, sort_order: sortOrder }) + .where('id', '=', id) + .returning(CATEGORY_COLUMNS) + .execute(); res.json(requireRow(rows, 'the category UPDATE')); } catch (err) { @@ -236,27 +232,28 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => { router.delete('/:id', asyncRoute(async (req: Request, res: Response) => { const id = Number(req.params.id); - const subtree = await db.execute<{ id: number }>( - sql`${subtreeOf(id)} SELECT id FROM subtree` - ); + const subtree = await sql<{ id: number }>` + ${subtreeOf(id)} SELECT id FROM subtree + `.execute(db); if (!subtree.rows.length) { return res.status(404).json({ error: 'not found' }); } const ids = subtree.rows.map((row) => row.id); - // inArray rather than the ANY(...::int[]) this replaced, which sidesteps the - // array trap in CONVENTIONS.md entirely: there is no template to forget - // sql.param() in. The builder emits the placeholder list itself and it is - // correct by construction. + // `in` rather than the ANY(...::int[]) this replaced. Kysely emits the + // placeholder list itself, so it is correct by construction and there is no + // template to forget anything in. `ids` is never empty — the length check + // above returned already if it were. const affected = await db - .select({ n: sql`COUNT(*)::int` }) - .from(items) - .where(inArray(items.categoryId, ids)); + .selectFrom('items') + .select(sql`COUNT(*)::int`.as('n')) + .where('category_id', 'in', ids) + .execute(); // The FK cascade takes the descendants; items fall back to NULL rather than // being deleted along with their category. - await db.delete(categories).where(eq(categories.id, id)); + await db.deleteFrom('categories').where('id', '=', id).execute(); res.json({ deleted_categories: ids.length, diff --git a/backend/tests/integration/categoriesTags.integration.test.ts b/backend/tests/integration/categoriesTags.integration.test.ts index 161d791..99a9e14 100644 --- a/backend/tests/integration/categoriesTags.integration.test.ts +++ b/backend/tests/integration/categoriesTags.integration.test.ts @@ -158,6 +158,20 @@ describe('admin categories', () => { expect(res.status).toBe(200); expect(res.body.category_id).toBeNull(); }); + + // The conversion hazard from #218, asserted rather than assumed. Drizzle + // wrapped driver errors and moved the unique-violation SQLSTATE from + // err.code to err.cause.code, which turned this 409 into a 500 with nothing + // failing to compile. #305 changed builder again, so this proves where the + // code actually lands rather than trusting that Kysely leaves it alone. + it('refuses a duplicate sibling name with 409, not 500', async () => { + await createCategory('Duplicate me'); + + const res = await request(app).post('/api/admin/categories').send({ name: 'Duplicate me' }); + + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/already exists/); + }); }); describe('admin tags', () => { diff --git a/backend/tests/integration/drizzleSchema.integration.test.ts b/backend/tests/integration/schemaMirror.integration.test.ts similarity index 66% rename from backend/tests/integration/drizzleSchema.integration.test.ts rename to backend/tests/integration/schemaMirror.integration.test.ts index 2f681a3..1d9d0ef 100644 --- a/backend/tests/integration/drizzleSchema.integration.test.ts +++ b/backend/tests/integration/schemaMirror.integration.test.ts @@ -9,13 +9,22 @@ afterAll(async () => { }); const SCHEMA = readFileSync( - path.join(__dirname, '..', '..', 'src', 'db-drizzle', 'schema.ts'), + path.join(__dirname, '..', '..', 'src', 'db-kysely', 'schema.ts'), 'utf8' ); -/** Every table name the generated mirror declares. */ +/** + * Every table name the generated mirror declares. + * + * Read from the `DB` interface, which is the one place kysely-codegen lists + * them all, mapping the database's own snake_case name to the interface for + * that table. Parsing the file as text rather than importing it is deliberate + * and unchanged from the Drizzle version: these are TypeScript types, erased at + * run time, so there is nothing to import and inspect. + */ function mirroredTables(): string[] { - return [...SCHEMA.matchAll(/pgTable\("([a-z_]+)"/g)].map((m) => m[1]!).sort(); + const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? ''; + return [...block.matchAll(/^\s*([a-z_]+):/gm)].map((m) => m[1]!).sort(); } async function liveTables(): Promise { @@ -43,7 +52,7 @@ async function liveTables(): Promise { * have and fail at run time with a column that does not exist — the exact class * of drift the adoption was meant to close. */ -describe('the Drizzle schema mirror', () => { +describe('the generated schema mirror', () => { it('declares every table the migrations create', async () => { const live = await liveTables(); const mirrored = mirroredTables(); @@ -60,8 +69,9 @@ describe('the Drizzle schema mirror', () => { expect(extra).toEqual([]); }); - // pgmigrations is node-pg-migrate's bookkeeping, excluded by tablesFilter in - // drizzle.config.ts. A re-pull without that filter would quietly put it back. + // pgmigrations is node-pg-migrate's bookkeeping, excluded by + // --exclude-pattern in the db:types script. A regeneration without that flag + // would quietly put it back. it('excludes node-pg-migrate bookkeeping', () => { expect(mirroredTables()).not.toContain('pgmigrations'); }); @@ -69,6 +79,11 @@ describe('the Drizzle schema mirror', () => { // Columns, not just tables: a migration that adds a column to a table the // mirror already knows about is the likelier drift, and the one a table-level // check would wave through. + // + // One exact match, where the Drizzle version needed two and called itself + // "deliberately loose" for it. kysely-codegen emits the database's own name + // as a bare interface key, so ` column_name:` at the start of a line is the + // whole of the naming rule and there is nothing to re-implement. it('declares every column of every table it mirrors', async () => { const { rows } = await pool.query<{ table_name: string; column_name: string }>( `SELECT table_name, column_name FROM information_schema.columns @@ -76,20 +91,13 @@ describe('the Drizzle schema mirror', () => { ORDER BY table_name, column_name` ); - // The mirror names a column either as a bare camelCase key or, when the - // database name differs, as an explicit string argument. Checking the - // snake_case name appears anywhere in the file is deliberately loose: it - // catches a column the mirror has never heard of, which is the failure that - // matters, without re-implementing drizzle-kit's naming rules. + const declared = new Set( + [...SCHEMA.matchAll(/^\s{2}([a-z0-9_]+):/gm)].map((m) => m[1]!) + ); const missing = rows - .filter((row) => !SCHEMA.includes(`"${row.column_name}"`)) - .filter((row) => !SCHEMA.includes(snakeToCamel(row.column_name))) + .filter((row) => !declared.has(row.column_name)) .map((row) => `${row.table_name}.${row.column_name}`); expect(missing).toEqual([]); }); }); - -function snakeToCamel(name: string): string { - return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); -} -- 2.54.0 From f86ebba046c32b9e0cff60885fae5880eacc5217 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 15:11:11 -0500 Subject: [PATCH 5/8] docs(db): correct the drift test's own stale references (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #217 doc comment above the describe block still named db-drizzle/schema.ts, drizzle-kit pull, and "Drizzle infers row types" — a file, a command, and a library this same commit had already removed. A comment pointing at deleted paths is worse than no comment at all on a test whose whole job is proving trust in a generated mirror, so it is corrected to name npm run db:types and src/db-kysely/schema.ts while keeping every sentence of the history intact: #217, #222, item_drafts and upload_links, the week nobody noticed. A closing note was added recording that the generator changed in #305 and the test did not, because the drift it guards is a property of generating a mirror at all rather than of any particular library. mirroredTables' regex also gets the same digit fix the column check already had. Both regexes parse the same generated file for the same kind of identifier, and a table name with a digit would otherwise be read out of the DB interface but reported missing by mirroredTables, sending someone to regenerate a file that was never wrong. Co-Authored-By: Claude Opus 5 --- .../schemaMirror.integration.test.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/tests/integration/schemaMirror.integration.test.ts b/backend/tests/integration/schemaMirror.integration.test.ts index 1d9d0ef..2ccc7d1 100644 --- a/backend/tests/integration/schemaMirror.integration.test.ts +++ b/backend/tests/integration/schemaMirror.integration.test.ts @@ -24,7 +24,7 @@ const SCHEMA = readFileSync( */ function mirroredTables(): string[] { const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? ''; - return [...block.matchAll(/^\s*([a-z_]+):/gm)].map((m) => m[1]!).sort(); + return [...block.matchAll(/^\s*([a-z0-9_]+):/gm)].map((m) => m[1]!).sort(); } async function liveTables(): Promise { @@ -40,17 +40,21 @@ async function liveTables(): Promise { /** * The guard for #217. * - * `src/db-drizzle/schema.ts` is generated by `drizzle-kit pull` and is a + * `src/db-kysely/schema.ts` is generated by `npm run db:types` and is a * read-only mirror of the real schema, which `backend/migrations` owns. Nothing - * makes anyone re-pull after writing a migration, and that is not hypothetical: - * the mirror sat missing `item_drafts` and `upload_links` from the moment #222 - * landed until #217, because it had been copied into src/ by hand and nobody - * had reason to look at it. + * makes anyone regenerate after writing a migration, and that is not + * hypothetical: the mirror sat missing `item_drafts` and `upload_links` from + * the moment #222 landed until #217, because it had been copied into src/ by + * hand and nobody had reason to look at it. * - * A stale mirror is worse than no mirror. Drizzle infers row types from it, so - * a converted query would type-check against a schema the database does not - * have and fail at run time with a column that does not exist — the exact class - * of drift the adoption was meant to close. + * A stale mirror is worse than no mirror. Row types are inferred from it, so a + * converted query would type-check against a schema the database does not have + * and fail at run time with a column that does not exist — the exact class of + * drift the adoption was meant to close. + * + * The generator changed in #305 and this test did not, because the drift it + * guards is a property of generating a mirror at all rather than of any + * library. */ describe('the generated schema mirror', () => { it('declares every table the migrations create', async () => { -- 2.54.0 From 687c7058bf7be5ec7a1e1ab9f9898110fcf23c0f Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 15:16:22 -0500 Subject: [PATCH 6/8] docs(db): write the Kysely conventions (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Drizzle conventions, and is much shorter, because three of that document's four warnings described the library rather than the practice and stopped being true when the library changed. An array is one bind parameter with no ceremony, a column reference in a raw fragment is the text you wrote, and the generated names are the database's own so nothing needs mapping back. What survives is what was never about Drizzle. The mirror is generated and refreshing it is manual, so the drift test is the thing that catches forgetting — and it exists because the drift already happened once and nobody noticed for a week. Both drivers share one pool, because a transaction on a second pool would be invisible to the first and the limits would silently double. Migrations stay hand-written, and the reasoning survives the change of library: only the expression-index complaint was specific to drizzle-kit, while losing the prose and being unable to express data migrations are true of any generator. One warning is genuinely new, and it is the inverse of an old one: driver errors are no longer wrapped, so a SQLSTATE sits on err.code again. That is worth stating precisely because it was not true before, and the last time it moved it turned a handled 409 into a 500 with nothing failing to compile. The worked example moved into this file rather than staying a source file nothing imports. It is documentation, and it was only ever documentation. Closes #305 Co-Authored-By: Claude Opus 5 --- backend/src/db-kysely/CONVENTIONS.md | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 backend/src/db-kysely/CONVENTIONS.md diff --git a/backend/src/db-kysely/CONVENTIONS.md b/backend/src/db-kysely/CONVENTIONS.md new file mode 100644 index 0000000..9e9599f --- /dev/null +++ b/backend/src/db-kysely/CONVENTIONS.md @@ -0,0 +1,80 @@ +# Kysely conventions + +Decided in #216, rebuilt on Kysely in #305 for the reasons in #297. Read this before converting a query. + +## What is in this directory + +| File | Owner | +|---|---| +| `schema.ts` | **Generated.** `kysely-codegen` output. Do not hand-edit. | +| `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 +KYSELY_DATABASE_URL=postgres://user:pass@localhost:PORT/db npm run db:types +``` + +Run it against a database with every migration applied, after writing a migration. `schemaMirror.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, and nobody had reason to look. A stale mirror is worse than none — row types are inferred 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 reason this is worth doing + +`${value}` in a Kysely `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, 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. + +## Both drivers run at once + +`db` and `pool` are exported from `src/db.ts` and share one pool, deliberately. Conversion is file by file across 238 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. + +Column names need no translation. The generated types carry the database's own snake_case, which is also what these APIs answer with, so a select names the columns it wants and the JSON comes out right. Do not turn on kysely-codegen's `--camel-case`: it would reintroduce a mapping layer whose failure mode is a silently changed response that no status-code test catches. + +## Driver errors are not wrapped + +Kysely uses the `pg` driver directly, so a Postgres SQLSTATE stays on `err.code`. This is worth stating only because it was not true before: Drizzle wrapped driver errors and moved the code to `err.cause.code`, so a `catch` keyed on it still compiled, never matched, and turned a handled 409 into a 500. `adminCategories.ts` has `isUniqueViolation`, which accepts both shapes and has an integration test behind it. Reuse that pattern, and keep the test. + +## The worked example + +`buildItemFilterSql` is the hardest query in the codebase — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. It was the #216 spike's target and #297's, and it is here rather than in a source file because nothing imports it: + +```ts +if (filters.categoryIds.length) { + clauses.push(sql`items.category_id IN ( + WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = ANY(${filters.categoryIds}::int[]) + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + ) + SELECT id FROM subtree + )`); +} + +if (filters.tagIds.length) { + clauses.push(sql`( + SELECT COUNT(*) FROM item_tags it + WHERE it.item_id = items.id AND it.tag_id = ANY(${filters.tagIds}::int[]) + ) = ${filters.tagIds.length}`); +} + +if (filters.status !== null) clauses.push(eb('items.status', 'in', filters.status)); +``` + +Two things in there are worth pointing at, because both were traps in the previous library and are not traps here. + +`${filters.categoryIds}` emits **one** bind parameter holding the whole array — `ANY($1::int[])` — rather than a placeholder list. Drizzle emitted `ANY(($1, $2)::int[])`, which is invalid Postgres, unless every array site remembered `sql.param()`. + +The column references inside those templates are text you wrote and qualified yourself, so `it.item_id = items.id` means what it says. Drizzle rendered an interpolated column reference without its table, so a correlated subquery silently correlated with itself — valid SQL, quietly wrong data, and the reason #218 got a count of 1 where 2 was correct. + +That second one is why a converted query containing a correlated subquery or a self-join still deserves a test asserting **values** rather than a status code. The library no longer makes the mistake for you; writing the wrong column name in a raw fragment is still your own to make. + +## Migrations stay hand-written + +Decided in **#219** and unchanged by #305: `node-pg-migrate` keeps the schema, the builder is for queries only. + +Three reasons, all measured rather than assumed. `drizzle-kit generate` could not 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 first of those was specific to `drizzle-kit`. The other two are true of any generator, which is why the decision survives the change of library — and Kysely, which ships no generator anyone was asking us to use, has nothing to refuse. + +The workflow: write the migration by hand, then run `npm run db:types` to refresh the mirror. `schemaMirror.integration.test.ts` fails if you forget. -- 2.54.0 From 29596e06408ce4bc8c11769d8fcb56453f03ac14 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 15:19:47 -0500 Subject: [PATCH 7/8] docs(db): stop calling a live query an unimported example (#305) The paragraph describing buildItemFilterSql claimed "nothing imports it" but it is actually a live production function defined at src/itemFilters.ts:264 and imported by both src/routes/admin.ts and src/routes/items.ts. The conversion example in the conventions file was mistakenly described as though it were the function itself rather than as an example demonstrating the query pattern. Fixed the wording to clarify that the function remains raw pg code and the shown conversion is an example of how to convert it, not a committed version in src/ waiting to be called. Co-Authored-By: Claude Opus 5 --- backend/src/db-kysely/CONVENTIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db-kysely/CONVENTIONS.md b/backend/src/db-kysely/CONVENTIONS.md index 9e9599f..891ae45 100644 --- a/backend/src/db-kysely/CONVENTIONS.md +++ b/backend/src/db-kysely/CONVENTIONS.md @@ -37,7 +37,7 @@ Kysely uses the `pg` driver directly, so a Postgres SQLSTATE stays on `err.code` ## The worked example -`buildItemFilterSql` is the hardest query in the codebase — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. It was the #216 spike's target and #297's, and it is here rather than in a source file because nothing imports it: +`buildItemFilterSql` is the hardest query in the codebase — six optional clauses composed at run time, a recursive CTE for the category subtree, an `ANY(...::int[])` tag match with a count equality, and array parameters. It was the #216 spike's target and #297's, and it is still raw `pg` in `src/itemFilters.ts`, used by both the storefront and the admin listing. The conversion below is an example rather than a source file: committing it as one would put a second copy of a live function in `src/` that nothing calls, which is precisely what the #216 spike became. ```ts if (filters.categoryIds.length) { -- 2.54.0 From 2670a09b84ea161d54be238a3c0f8ba02461eefa Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 15:31:35 -0500 Subject: [PATCH 8/8] fix(db): repoint the lint and drift guards at the new mirror (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eslint config's ignores list and comment still named the deleted src/db-drizzle/schema.ts and relations.ts and never named src/db-kysely/schema.ts, so the generated mirror was being linted for the first time and tripping sonarjs/redundant-type-aliases — exactly the trap the config's own comment already described from #261 and #217. The ignores list now names src/db-kysely/schema.ts and the comment is updated to match. The schema mirror drift test built one flat Set of every two-space-indented key in the whole generated file and asked only whether a live column name appeared anywhere in it, rather than checking it against the specific table it belongs to. Seventeen column names are declared on two or more tables and created_at is on fourteen of eighteen, so a migration adding created_at, updated_at, status, name, sort_order, token, or expires_at to a table that lacks it would pass vacuously. Replaced mirroredTables with mirroredColumns, which reads the DB interface to map each table name to its declaring interface and then reads that interface's own columns, and changed the column-mirroring test to look up columns per table. Verified the guard can actually fail: removing customer_id from the Carts interface made the test fail naming carts.customer_id exactly, and restoring the file made it pass again. The root .gitignore still carried a comment block and two patterns for drizzle-kit pull output under backend/src/db-drizzle, a directory this branch deleted along with backend/drizzle.config.ts. kysely-codegen writes only the single tracked file it's pointed at, so nothing replaces the rule — deleted the block and both patterns. Co-Authored-By: Claude Opus 5 --- .gitignore | 9 ---- backend/eslint.config.mjs | 13 +++-- .../schemaMirror.integration.test.ts | 50 +++++++++++++------ 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 8525073..603b7ba 100755 --- a/.gitignore +++ b/.gitignore @@ -21,15 +21,6 @@ backend/unit-results.json backend/integration-results.json frontend/playwright-results.json -# drizzle-kit pull writes the schema mirror into backend/src/db-drizzle (see -# backend/drizzle.config.ts), but `out` is also where it would put generated -# migrations and their journal. This project's migration history is -# backend/migrations — hand-written, and mostly prose. #219 has not chosen -# otherwise, so a stray 0000_*.sql in src/ is at best noise and at worst -# mistaken for real migration history. Keep the mirror, drop the rest. -backend/src/db-drizzle/*.sql -backend/src/db-drizzle/meta/ - # Where an end-to-end run against the throwaway database writes its uploads. # Disposable with the database it belongs to (#186). backend/.e2e-uploads/ diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index 07af853..3e488d4 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -30,18 +30,17 @@ const advisory = (config) => ({ }); export default tseslint.config( - // src/db-drizzle/schema.ts and relations.ts are `drizzle-kit pull` output, not - // written by anyone here. #261 hand-fixed an unused-parameter warning in the - // schema and #217's re-pull put it straight back, which is the whole argument: - // linting generated code buys a fix that the next regeneration undoes. The - // hand-written files in that directory are still linted. + // src/db-kysely/schema.ts is `kysely-codegen` output, not written by anyone + // here. #261 hand-fixed an unused-parameter warning in the equivalent Drizzle + // file and #217's regeneration put it straight back, which is the whole + // argument: linting generated code buys a fix that the next regeneration + // undoes. The hand-written files in that directory are still linted. { ignores: [ 'dist/**', 'coverage/**', 'eslint.config.mjs', - 'src/db-drizzle/schema.ts', - 'src/db-drizzle/relations.ts' + 'src/db-kysely/schema.ts' ] }, diff --git a/backend/tests/integration/schemaMirror.integration.test.ts b/backend/tests/integration/schemaMirror.integration.test.ts index 2ccc7d1..934a29f 100644 --- a/backend/tests/integration/schemaMirror.integration.test.ts +++ b/backend/tests/integration/schemaMirror.integration.test.ts @@ -14,17 +14,36 @@ const SCHEMA = readFileSync( ); /** - * Every table name the generated mirror declares. + * Every column the mirror declares, keyed by the database's own table name. * - * Read from the `DB` interface, which is the one place kysely-codegen lists - * them all, mapping the database's own snake_case name to the interface for - * that table. Parsing the file as text rather than importing it is deliberate - * and unchanged from the Drizzle version: these are TypeScript types, erased at - * run time, so there is nothing to import and inspect. + * The `DB` interface maps a table name to the interface that declares that + * table's columns, so the two have to be read together. A flat set of every + * column name appearing anywhere in the file is not the same assertion and is + * far weaker than it looks: seventeen column names are declared on more than + * one table and `created_at` is on fourteen of the eighteen, so a migration + * adding one of those to a table that lacks it would pass without the mirror + * knowing anything about it. + * + * Parsed as text rather than imported, because these are TypeScript types and + * are erased at run time — there is nothing to import and inspect. */ -function mirroredTables(): string[] { +function mirroredColumns(): Map> { const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? ''; - return [...block.matchAll(/^\s*([a-z0-9_]+):/gm)].map((m) => m[1]!).sort(); + const byTable = new Map>(); + for (const [, table, iface] of block.matchAll(/^\s*([a-z0-9_]+):\s*([A-Za-z0-9_]+);/gm)) { + const declaration = + new RegExp(`export interface ${iface!} \\{([^}]*)\\}`).exec(SCHEMA)?.[1] ?? ''; + byTable.set( + table!, + new Set([...declaration.matchAll(/^\s{2}([a-z0-9_]+):/gm)].map((m) => m[1]!)) + ); + } + return byTable; +} + +/** Every table name the generated mirror declares. */ +function mirroredTables(): string[] { + return [...mirroredColumns().keys()].sort(); } async function liveTables(): Promise { @@ -84,10 +103,11 @@ describe('the generated schema mirror', () => { // mirror already knows about is the likelier drift, and the one a table-level // check would wave through. // - // One exact match, where the Drizzle version needed two and called itself - // "deliberately loose" for it. kysely-codegen emits the database's own name - // as a bare interface key, so ` column_name:` at the start of a line is the - // whole of the naming rule and there is nothing to re-implement. + // One exact match per table, where the Drizzle version needed two matches and + // no table at all, and called itself "deliberately loose" for it. + // kysely-codegen emits the database's own name as a bare interface key and + // the DB interface says which interface belongs to which table, so the pair + // is the whole of the naming rule and there is nothing to re-implement. it('declares every column of every table it mirrors', async () => { const { rows } = await pool.query<{ table_name: string; column_name: string }>( `SELECT table_name, column_name FROM information_schema.columns @@ -95,11 +115,9 @@ describe('the generated schema mirror', () => { ORDER BY table_name, column_name` ); - const declared = new Set( - [...SCHEMA.matchAll(/^\s{2}([a-z0-9_]+):/gm)].map((m) => m[1]!) - ); + const columns = mirroredColumns(); const missing = rows - .filter((row) => !declared.has(row.column_name)) + .filter((row) => !columns.get(row.table_name)?.has(row.column_name)) .map((row) => `${row.table_name}.${row.column_name}`); expect(missing).toEqual([]); -- 2.54.0