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
+32 -32
View File
@@ -1,30 +1,28 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { PUBLIC_ITEM_SELECT, PublicItemRow } from '../itemSelect';
import { publicItemQuery, PublicItemRow, ItemContext } from '../itemSelect';
import {
parseItemFilters,
buildItemFilterSql,
itemFilterExpressions,
FilterError,
NON_PUBLIC_STATUSES,
STOREFRONT_DEFAULT_STATUSES,
STOREFRONT_ALL_STATUSES
} from '../itemFilters';
// Applied to every public read, unconditionally. This route has never had a
// status filter of its own — sold items are listed and rendered with a Sold
// badge on purpose — so hiding pending items cannot be expressed as one more
// optional filter. It has to be a clause the caller cannot opt out of.
const EXCLUDE_PENDING = `i.status <> 'pending'`;
/**
* One public item, by id — the whole query rather than a fragment.
* Pending items are excluded everywhere, not only from the list. A pending item
* that stayed fetchable by id would be hidden from the catalogue and still
* reachable by anyone who guessed or kept a link.
*
* Built once here so the call site interpolates nothing. Both halves were
* always constants and the id was always bound as $1, but a template literal at
* a query call is a thing a reader has to verify rather than see. See #294.
* An expression rather than the SQL literal this was until #308, so it composes
* with the filter clauses through `eb.and` instead of being joined into a
* string. That join used to need its own argument about why AND could not
* weaken it; `and` cannot re-associate anything.
*/
const PUBLIC_ITEM_BY_ID = `${PUBLIC_ITEM_SELECT} WHERE i.id = $1 AND ${EXCLUDE_PENDING}`;
function notPending(eb: ItemContext) {
return eb('i.status', '!=', 'pending');
}
const router = Router();
@@ -81,28 +79,30 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
status: filters.status ?? [...defaultStatuses]
};
const { clauses, params } = buildItemFilterSql(effectiveFilters, 1, req.customerId ?? null);
// The same construct SonarQube flagged as S2077 in admin.ts and which is
// marked Reviewed/Safe there (#180) — and this is the copy reachable without
// signing in, so it is worth saying here too rather than relying on the
// reader having seen the other one. It holds for the same reason: the clauses
// are literals from buildItemFilterSql carrying only placeholder indices, and
// EXCLUDE_PENDING is a module constant. Joining with AND cannot weaken
// EXCLUDE_PENDING either, because no fragment contains a top-level OR for the
// join to re-associate against.
const where = [EXCLUDE_PENDING, ...clauses].join(' AND ');
const { rows } = await pool.query<PublicItemRow>(
`${PUBLIC_ITEM_SELECT} WHERE ${where} ORDER BY i.created_at DESC`,
params
);
// `.$castTo` narrows `status` from the schema mirror's generic `string` (the
// column is a CHECK-constrained text column, not a native Postgres enum, so
// kysely-codegen has no literal union to give it) to the app-level
// `ItemStatus` the CHECK constraint actually enforces. Every other field on
// PublicItemRow already matches the projection without a cast.
const rows: PublicItemRow[] = await publicItemQuery()
.where((eb) =>
eb.and([
notPending(eb),
...itemFilterExpressions(eb, effectiveFilters, req.customerId ?? null)
])
)
.orderBy('i.created_at', 'desc')
.$castTo<PublicItemRow>()
.execute();
res.json(rows);
}));
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
// Excluded here too, not only from the list. A pending item that stayed
// fetchable by id would be hidden from the catalogue and still reachable by
// anyone who guessed or kept a link.
const { rows } = await pool.query<PublicItemRow>(PUBLIC_ITEM_BY_ID, [req.params.id]);
const rows = await publicItemQuery()
.where('i.id', '=', Number(req.params.id))
.where((eb) => notPending(eb))
.execute();
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
}));