Merge pull request 'Feature/305 kysely swap' (#306) from feature/305-kysely-swap into main
Linting / lint (push) Successful in 2m40s
SonarQube Analysis / sonarqube (push) Successful in 25m49s

Reviewed-on: #306
This commit was merged in pull request #306.
This commit is contained in:
2026-09-04 16:08:47 -05:00
18 changed files with 1541 additions and 1997 deletions
-9
View File
@@ -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/
-31
View File
@@ -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 ?? ''
}
});
+6 -7
View File
@@ -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'
]
},
+198 -1094
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -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",
-78
View File
@@ -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.
@@ -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;
}
-171
View File
@@ -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]
}),
}));
-341
View File
@@ -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"}),
]);
+80
View File
@@ -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 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) {
clauses.push(sql<SqlBool>`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<SqlBool>`(
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.
+231
View File
@@ -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> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
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<Date, Date | string, Date | string>;
export interface AdminSettings {
key: string;
updated_at: Generated<Timestamp>;
value: string;
}
export interface CartItems {
added_at: Generated<Timestamp>;
cart_id: number;
expires_at: Timestamp;
id: Generated<number>;
item_id: number;
last_reminder_sent_at: Timestamp | null;
}
export interface Carts {
created_at: Generated<Timestamp>;
customer_id: number;
id: Generated<number>;
updated_at: Generated<Timestamp>;
}
export interface Categories {
created_at: Generated<Timestamp>;
id: Generated<number>;
name: string;
parent_id: number | null;
sort_order: Generated<number>;
}
export interface CheckoutItems {
checkout_id: number;
item_id: number;
price_cents: number;
}
export interface Checkouts {
amount_cents: number | null;
created_at: Generated<Timestamp>;
customer_id: number | null;
id: Generated<number>;
processor: string;
processor_order_id: string | null;
raw_event: Json | null;
shipping_address_id: number | null;
status: Generated<string>;
}
export interface Customers {
created_at: Generated<Timestamp>;
disabled_at: Timestamp | null;
email: string;
email_verified: Generated<boolean>;
favorite_alerts: Generated<boolean>;
favorite_alerts_at: Timestamp | null;
favorite_alerts_text: string | null;
first_name: string | null;
id: Generated<number>;
last_name: string | null;
marketing_consent: Generated<boolean>;
marketing_consent_at: Timestamp | null;
marketing_consent_text: string | null;
password_hash: string;
unsubscribe_token: string;
}
export interface CustomerSessions {
created_at: Generated<Timestamp>;
customer_id: number;
expires_at: Timestamp;
token: string;
}
export interface CustomerTokens {
created_at: Generated<Timestamp>;
customer_id: number;
expires_at: Timestamp;
kind: string;
token: string;
}
export interface Favorites {
created_at: Generated<Timestamp>;
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<number>;
cost_micros: number | null;
created_at: Generated<Timestamp>;
drafted_at: Timestamp | null;
id: Generated<number>;
input_tokens: number | null;
item_id: number;
model: string | null;
output_tokens: number | null;
price_source: Generated<string>;
remove_background: Generated<boolean>;
reviewed_at: Timestamp | null;
state: Generated<string>;
submitter_note: string | null;
upload_link_id: number | null;
}
export interface ItemImages {
created_at: Generated<Timestamp>;
id: Generated<number>;
image_path: string;
item_id: number;
original_image_path: string | null;
sort_order: Generated<number>;
}
export interface Items {
category_id: number | null;
created_at: Generated<Timestamp>;
description: string | null;
id: Generated<number>;
name: string;
paypal_order_id: string | null;
price_cents: Generated<number>;
reserved_until: Timestamp | null;
sold_at: Timestamp | null;
status: Generated<string>;
}
export interface ItemTags {
item_id: number;
tag_id: number;
}
export interface Orders {
amount_cents: number | null;
checkout_id: number | null;
created_at: Generated<Timestamp>;
customer_id: number | null;
id: Generated<number>;
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<string>;
created_at: Generated<Timestamp>;
customer_id: number;
full_name: string;
id: Generated<number>;
is_default: Generated<boolean>;
postal_code: string;
state: string;
usps_standardized: Json | null;
usps_validated: Generated<boolean>;
}
export interface Tags {
color: string;
created_at: Generated<Timestamp>;
id: Generated<number>;
name: string;
}
export interface UploadLinks {
contact_email: string | null;
created_at: Generated<Timestamp>;
id: Generated<number>;
label: string;
last_used_at: Timestamp | null;
max_submissions: number | null;
revoked_at: Timestamp | null;
submission_count: Generated<number>;
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;
}
+20 -16
View File
@@ -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<DB>({ dialect: new PostgresDialect({ pool }) });
/**
* The single row a query is guaranteed to have returned.
+75 -78
View File
@@ -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<boolean> {
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<number>`(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<number>`(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<number>`COUNT(*)::int` })
.from(items)
.where(inArray(items.categoryId, ids));
.selectFrom('items')
.select(sql<number>`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,
@@ -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', () => {
@@ -1,95 +0,0 @@
import { readFileSync } from 'fs';
import path from 'path';
import { pool } from '../../src/db';
import { closeDb } from './setup/testDb';
afterAll(async () => {
await pool.end();
await closeDb();
});
const SCHEMA = readFileSync(
path.join(__dirname, '..', '..', 'src', 'db-drizzle', 'schema.ts'),
'utf8'
);
/** Every table name the generated mirror declares. */
function mirroredTables(): string[] {
return [...SCHEMA.matchAll(/pgTable\("([a-z_]+)"/g)].map((m) => m[1]!).sort();
}
async function liveTables(): Promise<string[]> {
const { rows } = await pool.query<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
AND table_name <> 'pgmigrations'
ORDER BY table_name`
);
return rows.map((r) => r.table_name);
}
/**
* The guard for #217.
*
* `src/db-drizzle/schema.ts` is generated by `drizzle-kit pull` 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.
*
* 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.
*/
describe('the Drizzle schema mirror', () => {
it('declares every table the migrations create', async () => {
const live = await liveTables();
const mirrored = mirroredTables();
const missing = live.filter((name) => !mirrored.includes(name));
expect(missing).toEqual([]);
});
it('declares no table the database does not have', async () => {
const live = await liveTables();
const mirrored = mirroredTables();
const extra = mirrored.filter((name) => !live.includes(name));
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.
it('excludes node-pg-migrate bookkeeping', () => {
expect(mirroredTables()).not.toContain('pgmigrations');
});
// 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.
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`
);
// 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 missing = rows
.filter((row) => !SCHEMA.includes(`"${row.column_name}"`))
.filter((row) => !SCHEMA.includes(snakeToCamel(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());
}
@@ -0,0 +1,125 @@
import { readFileSync } from 'fs';
import path from 'path';
import { pool } from '../../src/db';
import { closeDb } from './setup/testDb';
afterAll(async () => {
await pool.end();
await closeDb();
});
const SCHEMA = readFileSync(
path.join(__dirname, '..', '..', 'src', 'db-kysely', 'schema.ts'),
'utf8'
);
/**
* Every column the mirror declares, keyed by the database's own table name.
*
* 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 mirroredColumns(): Map<string, Set<string>> {
const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? '';
const byTable = new Map<string, Set<string>>();
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<string[]> {
const { rows } = await pool.query<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
AND table_name <> 'pgmigrations'
ORDER BY table_name`
);
return rows.map((r) => r.table_name);
}
/**
* The guard for #217.
*
* `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 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. 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 () => {
const live = await liveTables();
const mirrored = mirroredTables();
const missing = live.filter((name) => !mirrored.includes(name));
expect(missing).toEqual([]);
});
it('declares no table the database does not have', async () => {
const live = await liveTables();
const mirrored = mirroredTables();
const extra = mirrored.filter((name) => !live.includes(name));
expect(extra).toEqual([]);
});
// 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');
});
// 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 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
WHERE table_schema = 'public' AND table_name <> 'pgmigrations'
ORDER BY table_name, column_name`
);
const columns = mirroredColumns();
const missing = rows
.filter((row) => !columns.get(row.table_name)?.has(row.column_name))
.map((row) => `${row.table_name}.${row.column_name}`);
expect(missing).toEqual([]);
});
});
@@ -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 <noreply@anthropic.com>`.
- **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<DB>` 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<DB>` 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<number>`, `name: string`, `parent_id: number | null`, `sort_order: Generated<number>`. 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<DB>({ 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<boolean> {
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<number>`(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<ParentResolution> {
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<number>`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 <noreply@anthropic.com>
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<SqlBool>`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<SqlBool>`(
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 <noreply@anthropic.com>
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.
@@ -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<DB>({ 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.