refactor(db): build the item queries through Kysely (#308)

The two queries the builder was ever wanted for. #294 removed interpolation from seven sites by hoisting each fixed-shape query into a named constant; these two genuinely composed their WHERE at run time and could not be fixed that way, which is why they are the last S2077 hotspots. They were safe, and itemFilters.ts spent sixteen lines explaining why — that the clause fragments are literals, that the only things interpolated into them are placeholder indices, and that every value goes onto params. That argument was correct and it was still an argument, guarded by a comment and two tests, on a route reachable without signing in.

All four call sites moved rather than only the two flagged ones. The by-id constants carried no hotspot, but they were built by interpolating the same projection strings the list queries used, so converting only the list queries would have left itemSelect.ts holding a Kysely builder and a raw string that had to produce an identical projection — two spellings to keep in step by hand where the file's own header already warned about one.

The second thing this buys may matter more than the first. pool.query<T> asserts a shape TypeScript never checks against the SQL, which is why that header said the selects and their row types are kept in step by hand and the integration suite was the only thing that caught a drop. The projections are built with jsonArrayFrom now, which emits the same coalesce(json_agg(agg), '[]') they hand-wrote, so the row type follows from the projection and a dropped column is a compile error.

The row types stay hand-written and exported rather than being inferred from the query. Inference would be tidier and is refused deliberately: these are the shape the frontend reads, and a type that silently becomes whatever the query happens to return is a contract that can change without anyone deciding to change it.

The two invariant tests survive and got stronger. They used to inspect the clause strings the builder returned; they now compile the expressions and assert on the SQL Kysely actually emits, with the hostile value appearing in the parameters and nowhere else — the same claim, tested against the real artefact instead of an intermediate one.

Closes #308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 17:00:21 -05:00
co-authored by Claude Opus 5
parent 3248ac656e
commit ec1020891f
5 changed files with 344 additions and 281 deletions
+98 -69
View File
@@ -1,95 +1,124 @@
// Shared item SELECT shapes for the public and admin routes, and the row types
// Shared item query shapes for the public and admin routes, and the row types
// they return.
//
// The types live here rather than in types.ts because they describe a
// projection, not a table. ADMIN_ITEM_SELECT takes `i.*` and PUBLIC_ITEM_SELECT
// names its columns so the storefront never sees paypal_order_id or
// reserved_until — typing both as "an items row" would quietly re-admit exactly
// the columns that select was written to exclude.
// projection, not a table. adminItemQuery takes every column of items and
// publicItemQuery names its columns, so the storefront never sees
// paypal_order_id or reserved_until — typing both as "an items row" would
// quietly re-admit exactly the columns that projection was written to exclude.
//
// KEPT IN STEP BY HAND. `pool.query<T>` asserts a shape; it does not check the
// SQL, which TypeScript never reads. Dropping a column from a select below
// without dropping it from its type compiles cleanly and every read of it goes
// on type-checking while being undefined at runtime. The integration suite is
// the only thing that catches that, because it runs these queries against a
// real schema. Change a select and its type together.
// These were SQL string constants until #308. They had to be kept in step with
// their row types by hand, because `pool.query<T>` asserts a shape and never
// checks it against the SQL, so dropping a column from a select without
// dropping it from its type compiled cleanly and went undefined at run time —
// and only the integration suite ever caught it. Built through Kysely, that is
// a compile error, because the row type now follows from the projection.
//
// Images and tags are pulled as scalar subqueries rather than LEFT JOIN +
// Images and tags are pulled as aggregate subqueries rather than LEFT JOIN +
// GROUP BY. Joining two one-to-many relations in the same query multiplies
// their rows together — an item with 2 images and 3 tags would aggregate 6
// rows, silently repeating every image three times. Subqueries keep each
// aggregate independent and drop the GROUP BY entirely.
// aggregate independent and drop the GROUP BY entirely. `jsonArrayFrom` emits
// `coalesce(json_agg(agg), '[]')`, which is what these hand-wrote before.
import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { db } from './db';
import { DB } from './db-kysely/schema';
import { ItemStatus, ItemImage, ItemTag } from './types';
const IMAGES_SUBQUERY = `
COALESCE((
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
ORDER BY img.sort_order)
FROM item_images img
WHERE img.item_id = i.id
), '[]') AS images`;
/**
* The aliases every item query and every filter clause is written against.
*
* `i` and `c` are kept from the SQL these replaced. Not because short names are
* better, but because the filter clauses, the subquery correlations and the
* ORDER BY all reference them, and renaming them in the same change that moved
* the builder would have made the diff unreadable against the SQL it replaces.
*/
export type ItemContext = ExpressionBuilder<
DB & { i: DB['items']; c: DB['categories'] },
'i' | 'c'
>;
/** The public image fields. Correlated to the outer item by `whereRef`. */
function imagesFor(eb: ItemContext) {
return jsonArrayFrom(
eb
.selectFrom('item_images as img')
.select(['img.id', 'img.image_path', 'img.sort_order'])
.whereRef('img.item_id', '=', 'i.id')
.orderBy('img.sort_order')
).as('images');
}
/**
* Admin-only images, carrying `original_image_path` alongside the public
* fields — the field the inventory screen needs to know whether a photo has a
* cut-out to restore (#293).
*
* A separate subquery rather than adding the column to `IMAGES_SUBQUERY`
* itself, for the same reason `PUBLIC_ITEM_SELECT` names its columns instead
* of using `i.*`: an original filename is internal nobody's business on the
* storefront — and folding it into the one subquery both selects share would
* put it in every public item response too.
* A separate function rather than a flag on `imagesFor`, for the same reason
* `publicItemQuery` names its columns instead of taking them all: an original
* filename is internal, nobody's business on the storefront, and a boolean in
* the middle of the thing that keeps it off the public API is one edit away
* from being passed wrongly.
*/
const ADMIN_IMAGES_SUBQUERY = `
COALESCE((
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order,
'original_image_path', img.original_image_path)
ORDER BY img.sort_order)
FROM item_images img
WHERE img.item_id = i.id
), '[]') AS images`;
function adminImagesFor(eb: ItemContext) {
return jsonArrayFrom(
eb
.selectFrom('item_images as img')
.select(['img.id', 'img.image_path', 'img.sort_order', 'img.original_image_path'])
.whereRef('img.item_id', '=', 'i.id')
.orderBy('img.sort_order')
).as('images');
}
const TAGS_SUBQUERY = `
COALESCE((
SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) ORDER BY t.name)
FROM item_tags it
JOIN tags t ON t.id = it.tag_id
WHERE it.item_id = i.id
), '[]') AS tags`;
const FROM_CLAUSE = `
FROM items i
LEFT JOIN categories c ON c.id = i.category_id`;
// The storefront gets an explicit column list — it has no business seeing
// paypal_order_id or reserved_until.
export const PUBLIC_ITEM_SELECT = `
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, i.category_id,
c.name AS category_name,
${IMAGES_SUBQUERY},
${TAGS_SUBQUERY}
${FROM_CLAUSE}`;
export const ADMIN_ITEM_SELECT = `
SELECT i.*,
c.name AS category_name,
${ADMIN_IMAGES_SUBQUERY},
${TAGS_SUBQUERY}
${FROM_CLAUSE}`;
function tagsFor(eb: ItemContext) {
return jsonArrayFrom(
eb
.selectFrom('item_tags as it')
.innerJoin('tags as t', 't.id', 'it.tag_id')
.select(['t.id', 't.name', 't.color'])
.whereRef('it.item_id', '=', 'i.id')
.orderBy('t.name')
).as('tags');
}
/**
* One admin item, by id — the whole query, not a fragment.
* The storefront's projection — an explicit column list, because it has no
* business seeing paypal_order_id or reserved_until.
*
* A named constant rather than `${ADMIN_ITEM_SELECT} WHERE i.id = $1` written
* at each call, so no query call site interpolates anything at all. The id was
* always bound as $1 and never reached the query text, but S2077 fires on the
* template literal rather than on the value, because the rule cannot tell a
* module constant from a request field — and neither, at a glance, can a
* reader. Hoisting it makes the property structural instead of an assertion
* somebody has to re-make every time the line moves. See #294.
* A function rather than a constant so each caller gets a fresh builder. Kysely
* builders are immutable, so sharing one would be safe, but a function makes it
* obvious that adding a `where` does not affect anyone else.
*/
export const ADMIN_ITEM_BY_ID = `${ADMIN_ITEM_SELECT} WHERE i.id = $1`;
export function publicItemQuery() {
return db
.selectFrom('items as i')
.leftJoin('categories as c', 'c.id', 'i.category_id')
.select([
'i.id',
'i.name',
'i.description',
'i.price_cents',
'i.status',
'i.created_at',
'i.category_id',
'c.name as category_name'
])
.select(imagesFor)
.select(tagsFor);
}
/** The admin projection — every item column, plus the admin image fields. */
export function adminItemQuery() {
return db
.selectFrom('items as i')
.leftJoin('categories as c', 'c.id', 'i.category_id')
.selectAll('i')
.select('c.name as category_name')
.select(adminImagesFor)
.select(tagsFor);
}
/** The columns every item select returns, whichever of the two it is. */
interface ItemRowBase {