admin.ts has no untyped reads left. Typed sites go from 43 to 49. New ItemRecord in itemSelect.ts for the bare `items` row that `RETURNING *` gives back. Deliberately not AdminItemRow: that describes a select which joins the category and adds images and tags as subqueries, so typing a RETURNING * as it would promise three fields the result does not contain. Three shapes for one table, because three different queries return three different things. The typing found a real defect on its first run, which is the case for doing this at all. `ItemStatus` in types.ts was `'available' | 'reserved' | 'sold'`. The database has four values and defaults to 'pending' — items have arrived pending since #90. itemFilters.ts declared its own copy that had all four and was correct. Two declarations of one union with nothing connecting them: one went stale and nothing said so. It was invisible while query rows were `any`. Typing them turned `if (status === 'pending')` in admin.ts into TS2367, "this comparison appears to be unintentional because the types 'ItemStatus' and '\"pending\"' have no overlap" — a compiler telling us the unpublish route's guard could never be true, against a type that was simply wrong. Confirmed against the database rather than by picking the more plausible of the two declarations: `SELECT DISTINCT status FROM items` returns pending, available, reserved and sold. Fixed by removing the duplication rather than by patching both copies. types.ts now holds the only declaration and itemFilters.ts imports it, re-exporting so its existing importers are unaffected. Patching both would have left the next drift free to happen the same way. Verified: tsc clean, unit 254/254, integration 238/238, and backend lint unchanged — the four warnings it reports are identical to those on main with these changes stashed, so none of them are new. Refs #159
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
// Shared item SELECT 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// Images and tags are pulled as scalar 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.
|
|
|
|
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`;
|
|
|
|
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,
|
|
${IMAGES_SUBQUERY},
|
|
${TAGS_SUBQUERY}
|
|
${FROM_CLAUSE}`;
|
|
|
|
/** 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 PUBLIC_ITEM_SELECT returns. Deliberately no payment or reservation columns. */
|
|
export type PublicItemRow = ItemRowBase;
|
|
|
|
/**
|
|
* What ADMIN_ITEM_SELECT 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.
|
|
*/
|
|
export interface AdminItemRow extends ItemRowBase {
|
|
reserved_until: Date | null;
|
|
sold_at: Date | null;
|
|
paypal_order_id: string | null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|