// 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. 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. // // These were SQL string constants until #308. They had to be kept in step with // their row types by hand, because `pool.query` 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 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. `jsonArrayFrom` emits // `coalesce(json_agg(agg), '[]')`, which is what these hand-wrote before. import { ExpressionBuilder, Generated, Kysely } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { db } from './db'; import { DB } from './db-kysely/schema'; import { ItemStatus, ItemImage, ItemTag } from './types'; /** * The mirror types `items.status` as `Generated` because Postgres holds * it as CHECK-constrained text rather than a native enum, so `kysely-codegen` * has nothing narrower to emit. `types.ts` already states the real domain. * * Narrowed here, once, rather than asserted at each call site. `$castTo` at the * call site would have replaced the entire row type with an assertion — which * would silently accept a projection that had lost a column, and losing the * compile error on exactly that is what this change exists to prevent. */ type ItemsWithStatus = Omit & { status: Generated }; type ItemDB = Omit & { items: ItemsWithStatus }; const itemDb = db as unknown as Kysely; /** * 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< ItemDB & { i: ItemDB['items']; c: ItemDB['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 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. */ 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'); } 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'); } /** * The storefront's projection — an explicit column list, because it has no * business seeing paypal_order_id or reserved_until. * * 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 function publicItemQuery() { return itemDb .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 itemDb .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 { id: number; name: string; description: string | null; price_cents: number; status: ItemStatus; created_at: Date; category_id: number | null; category_name: string | null; // json_agg with a COALESCE fallback, so these are always arrays and never null. images: ItemImage[]; tags: ItemTag[]; } /** What publicItemQuery returns. Deliberately no payment or reservation columns. */ export type PublicItemRow = ItemRowBase; /** * An admin item's image: everything `ItemImage` has, plus where the * background-removed photo's original went. `null` for a photo that was never * cut out. */ export interface AdminItemImage extends ItemImage { original_image_path: string | null; } /** * What adminItemQuery returns: `i.*`, so every column on the table. * * The extra fields are the ones the storefront is not allowed to see, which is * the whole reason the two selects differ. `images` is narrowed rather than * inherited as-is, to match `ADMIN_IMAGES_SUBQUERY` carrying * `original_image_path` where the public select's images do not. */ export interface AdminItemRow extends ItemRowBase { reserved_until: Date | null; sold_at: Date | null; paypal_order_id: string | null; images: AdminItemImage[]; } /** * A bare `items` row, as `RETURNING *` gives it back. * * Distinct from the two select rows above and not interchangeable with them: * this is the table, so it has no category_name, no images and no tags. Those * come from the joins and subqueries the selects add, and typing a RETURNING * * as AdminItemRow would promise three fields that are not in the result. */ export interface ItemRecord { id: number; name: string; description: string | null; price_cents: number; status: ItemStatus; reserved_until: Date | null; sold_at: Date | null; paypal_order_id: string | null; created_at: Date; category_id: number | null; }