`.$castTo<AdminItemRow>()` / `.$castTo<PublicItemRow>()` at the two list-query call sites replaced the entire result type with an assertion rather than narrowing the one column that actually disagreed, which meant a projection that silently lost a column would still compile — exactly the failure mode this task exists to close, and the opposite of what the commit body claims. Fixed at the source instead: `itemSelect.ts` now defines `ItemsWithStatus`/`ItemDB`, narrowing `items.status` from the schema mirror's `Generated<string>` (a CHECK-constrained text column, so `kysely-codegen` has no literal union to give it) to `Generated<ItemStatus>`, and builds `ItemContext`, `adminItemQuery()` and `publicItemQuery()` from an `itemDb` typed with `ItemDB` instead of `db`/`DB`. Both `$castTo` calls and their comments are gone; the `AdminItemRow[]` / `PublicItemRow[]` annotations at the two call sites now check for real. Verified by temporarily dropping a column from `adminItemQuery`'s projection: the `AdminItemRow[]` assignment failed to compile as expected, confirming the guarantee actually holds. Also corrected two now-false statements left over from the conversion: `db-kysely/CONVENTIONS.md`'s worked-example section said `buildItemFilterSql` was "still raw `pg`" and that converting it "would put a second copy of a live function in `src/` that nothing calls" — both untrue since #308 shipped it as `itemFilterExpressions`. And two doc comments in `itemSelect.ts` still named the deleted `PUBLIC_ITEM_SELECT`/`ADMIN_ITEM_SELECT` constants instead of the functions that replaced them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
7.3 KiB
TypeScript
200 lines
7.3 KiB
TypeScript
// 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<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 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<string>` 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<DB['items'], 'status'> & { status: Generated<ItemStatus> };
|
|
type ItemDB = Omit<DB, 'items'> & { items: ItemsWithStatus };
|
|
|
|
const itemDb = db as unknown as Kysely<ItemDB>;
|
|
|
|
/**
|
|
* 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;
|
|
}
|