feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins

This commit is contained in:
jubnl
2026-09-23 20:05:38 +02:00
parent 3a039d5b19
commit a209e851af
4 changed files with 505 additions and 0 deletions
+80
View File
@@ -629,3 +629,83 @@ export function castIntegerKysely<DB, TB extends keyof DB>(
return unsupported(platform);
}
// ---------------------------------------------------------------------------
// Plan 3f Task 0 (R9) — no consumer yet: Task 2 (`collateNoCase`, SH9's
// `school-holidays.service.ts#checkName` duplicate-name guard) and Task 4
// (`nowMinusHours`, RJ4's `reminder-jobs.service.ts#todoTick`
// `datetime('now', '-20 hours')`) wire these in when each converts the
// statement that needs them. `lowerTrimParam` (AT31's value side, atlas's
// bucket-list dedup) is Task 1's. Added here, with their own SQLF-0xx tests
// pinning the SQLite text against a raw statement on seeded rows, so each
// task imports a tested helper instead of writing one inline.
// ---------------------------------------------------------------------------
/**
* A case-insensitive column reference via SQLite's `NOCASE` collating
* sequence, for use as a filter's object key — the same `{ [collateNoCase(
* platform, 'name')]: value }` shape `lower()` documents, rendering `WHERE
* name COLLATE NOCASE = <value-side>`. The legacy statement this replaces
* (`school-holidays.service.ts:50`, SH9) spells the COLLATE on the VALUE
* side instead (`name = ? COLLATE NOCASE`) — textually different, but SQLite
* resolves a binary comparison's collating sequence from the FIRST operand
* that carries an explicit `COLLATE`, scanning left to right, so `col
* COLLATE NOCASE = ?` and `col = ? COLLATE NOCASE` select the same collation
* (NOCASE) for the whole comparison either way — verified directly
* (SQLF-060: both forms select the identical row set on a mixed-case
* fixture), not assumed from the operator's documented precedence alone.
*
* **Not the same helper as `lower()`, and not reusable in its place.**
* SQLite's built-in `NOCASE` collation is documented to fold ONLY the 26
* ASCII letters, exactly like `LOWER()`'s own ASCII-only fold (program rule
* 18) — verified directly against `better-sqlite3` (SQLF-061): on every
* non-ASCII fixture this file's own `LOWER()` test (SQLF-014) already
* carries, `x = y COLLATE NOCASE` and `LOWER(x) = LOWER(y)` agree, letter for
* letter — neither engine folds a non-ASCII case pair. So `collateNoCase`
* does NOT diverge from `lower()` on any input constructed here; it is a
* NEW helper anyway (per R9's own instruction) because the two are
* textually different SQL constructs matching two textually different
* legacy statements (`COLLATE NOCASE` vs `LOWER(...)`), and because a
* `COLLATE`-based comparison lets SQLite use an index on the un-wrapped
* column in a way `LOWER(col) = ?` cannot — a real, if not row-visible,
* difference between the two shapes.
*/
export function collateNoCase(platform: Platform, ref: string): RawQueryFragment & symbol {
if (platform instanceof SqlitePlatform) return raw(`${column(ref)} COLLATE NOCASE`);
return unsupported(platform);
}
/**
* `datetime('now', '-N hours')` — the hour-granularity sibling of
* `nowMinusDays()` above, for `reminder-jobs.service.ts#todoTick`'s
* `datetime('now', '-20 hours')` (RJ4) — 20 hours is not expressible as
* `nowMinusDays` with a fractional day. Same validation shape as
* `nowMinusDays` (a non-negative integer, spelled directly into the
* fragment rather than bound — MikroORM's raw fragments do not accept a
* placeholder inside a SQLite date/time function's modifier argument, the
* same reasoning `nowMinusDays`'s own docstring gives).
*/
export function nowMinusHours(platform: Platform, hours: number): RawQueryFragment {
if (!Number.isInteger(hours) || hours < 0) {
throw new Error(`sql-functions: nowMinusHours needs a non-negative integer hour count, got ${hours}`);
}
if (platform instanceof SqlitePlatform) return raw(`datetime('now', '-${hours} hours')`);
return unsupported(platform);
}
/**
* The value-side twin of `lowerTrim()`: `LOWER(TRIM(?))` bound with the RAW,
* untransformed value — confirmed genuinely missing by reading this file's
* full export list before adding it (R9), not assumed from the inventory's
* own flag. Pairs with `lowerTrim()` as a filter KEY for the composed
* `LOWER(TRIM(<column>)) = LOWER(TRIM(?))` shape
* `atlas.service.ts:876-877`'s AT31 needs (`lower(trim(name)) =
* lower(trim(?))`), the same "bind the raw value, let SQL apply the SAME
* transform to both sides" reasoning `lowerParam()`'s own docstring gives —
* do NOT pre-trim/pre-lower the value in JS before calling this (program
* rule 18: mixing engines is the bug this pairing exists to avoid).
*/
export function lowerTrimParam(platform: Platform, value: string): RawQueryFragment {
if (platform instanceof SqlitePlatform) return raw('LOWER(TRIM(?))', [value]);
return unsupported(platform);
}
@@ -0,0 +1,138 @@
import type { ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely';
/**
* The `TRAVELER_OWNS` predicate atlas's `AtlasService` inlines as a
* `private static readonly` string fragment (`atlas.service.ts:1173-1175`,
* quoted here as this file's own oracle, the same "hold the STRING side
* fixed and independent of the production code it is proving" reasoning
* `reservation-visibility.ts`/`packing-visibility.ts` document) — ONE
* shared, typed predicate for the three call sites (AT6/AT45/AT46) that all
* inline it today (Plan 3f Task 0, R7):
*
* ```
* (
* NOT EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id)
* OR EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id AND rt.user_id = ?)
* )
* ```
*
* Binds exactly one parameter (the caller's own user id) and expects the
* reservation aliased `r` — every one of AT6/AT45/AT46 already uses that
* alias for `reservations` in its `FROM`/`JOIN` clause, so both typed forms
* below fix it too rather than taking an arbitrary alias parameter (the
* `ReservationVisibilityKyselyDB & { a: { id: number } }` precedent
* `publicStayExists` sets for a single, real, already-fixed alias — not
* `reservation-visibility.ts`'s RV1 shape, which genuinely serves more than
* one alias in production, `r` AND `vr`).
*
* **Backward-compat critical (#1966).** A reservation with ZERO
* `reservation_travelers` rows — every booking made before 4.0, before
* per-traveler assignment existed at all — must still count for the WHOLE
* trip (the `NOT EXISTS` arm). Only once at least one assignment row exists
* does the second arm narrow the reservation to "assigned to me". Getting
* this backwards either double-counts a shared trip's flights per member
* (if the `NOT EXISTS` arm is dropped or inverted) or zeroes every pre-4.0
* install's flown distance overnight (if the `EXISTS` arm is applied
* without the `NOT EXISTS` fallback) — the single highest-blast-radius
* predicate in this plan, per the plan's own risk list. **No consumer
* converts in this task** (the brief, item 5) — Task 1 (atlas) is the only
* consumer, at AT6/AT45/AT46, and reuses this file's own parity harness
* (`reservation-travelers-owns.parity.test.ts`) rather than re-deriving it.
*
* **Two shapes, not three** (unlike `reservation-visibility.ts`'s RV1/RV2
* split) — `TRAVELER_OWNS` is ONE OR-chain of two arms, always evaluated
* against the same, always-aliased-`r` `reservations` row, so there is no
* second alias shape or second predicate to support the way RV1/RV2 needed.
*
* - `travelerOwnsCondition` — a typed MikroORM QueryBuilder filter-object
* condition, spreadable into any `.where({...})` call against `Reservations`
* (or any query whose base/joined entity is `Reservations`, referenced by
* its `reservation_travelers_collection` relation property — a genuine
* `p.oneToMany(ReservationTravelers).mappedBy('reservation')` relation,
* `Reservations.entity.ts:103`, unlike RV2's blocker where
* `reservations.accommodation_id` has no FK/relation at all to join
* through). Built with MikroORM's own collection operators (`$none`/`$some`
* — "collection operators, sql only", `node_modules/@mikro-orm/core/enums.d.ts`'s
* `QueryOperator` — verified directly, not assumed: `$exists` is a
* DIFFERENT operator that renders `IS NOT NULL` for an embeddable/JSON
* path, not a correlated subquery, which is exactly why RV2 has no QB
* form at all), which MikroORM's SQL query compiler
* (`node_modules/@mikro-orm/sql/query/ObjectCriteriaNode.js`) renders as
* `<pk> IN (SELECT <pk> FROM reservations INNER JOIN reservation_travelers
* rt ON … WHERE rt.user_id = ?)` for `$some` and `<pk> NOT IN (SELECT <pk>
* FROM reservations INNER JOIN reservation_travelers rt ON …)` for
* `$none` — a semicolon-for-semicolon DIFFERENT SQL shape from the legacy
* correlated `NOT EXISTS`/`EXISTS` text, but row-identical on every input
* (an `IN`/`NOT IN` against a `INNER JOIN`-then-`SELECT pk` subquery
* selects exactly the same primary-key set a correlated `EXISTS` would),
* proven directly by this file's own parity harness rather than assumed
* equivalent from the operator names alone.
* - `travelerOwnsExpr` — the Kysely-expression twin, for a caller building a
* raw, multi-join statement through `this.kysely()` rather than the
* MikroORM QueryBuilder (AT6/AT45/AT46 are exactly this shape — large,
* multi-table `JOIN` aggregates with `CASE WHEN` projections, never a
* plain `em.createQueryBuilder(Reservations, 'r')` a QB filter object could
* spread into on its own). Spelled as a genuine correlated `EXISTS`/`NOT
* EXISTS` pair, matching the legacy fragment's own shape token-for-token —
* the same "no ORM relation to join those rows the QueryBuilder could
* filter through" reasoning `publicStayExists` documents, even though a
* relation DOES exist here (Kysely never uses MikroORM's entity relations
* at all — it is a separate, relation-unaware SQL builder).
*
* Both forms are proven identical to the legacy fragment on the two cases
* that matter (a reservation with zero `reservation_travelers` rows; a
* reservation with assignments, narrowed to the caller's own) by
* `reservation-travelers-owns.parity.test.ts` in this directory.
*/
export function travelerOwnsCondition(userId: number) {
return {
$or: [
{ reservation_travelers_collection: { $none: {} } },
{ reservation_travelers_collection: { $some: { user: userId } } },
],
};
}
/**
* `reservation_travelers`/`reservations`' REAL table names, the shape
* `travelerOwnsExpr` needs (fixed alias `r` for `reservations` — see this
* file's module docstring for why, unlike RV1's genuinely-multi-alias
* shape). A consumer's own local Kysely `DB` interface satisfies this by
* structural extension, the same `ReservationVisibilityKyselyDB`/
* `PackingVisibilityKyselyDB` precedent.
*/
export interface ReservationTravelersOwnsKyselyDB {
reservation_travelers: { reservation_id: number; user_id: number };
reservations: { id: number };
}
/**
* `TRAVELER_OWNS` as a Kysely predicate: `(NOT EXISTS (…) OR EXISTS (… AND
* rt.user_id = ?))`, correlated on `rt.reservation_id = r.id`. Call inside a
* `.where((eb) => travelerOwnsExpr(eb, userId))` on a query already carrying
* an `r` alias for `reservations` (a base `.selectFrom('reservations as
* r')`, or `reservations` joined in as `r` — both produce the same aliased
* `r: { id: number }` member Kysely's type inference needs) against a
* `Kysely<DB>` whose `DB` satisfies `ReservationTravelersOwnsKyselyDB`. The
* parameter type is fixed to exactly that shape rather than generic over an
* arbitrary caller `DB`/`TB`, the same `publicStayExists` precedent and for
* the same reason (`Trips.repository.ts`'s `tripSelectQuery` docstring: a
* shared Kysely helper generic across differently-shaped outer queries
* doesn't compose), proven end-to-end by this file's own parity-harness
* test rather than assumed from the type signature alone.
*/
export function travelerOwnsExpr(
eb: ExpressionBuilder<ReservationTravelersOwnsKyselyDB & { r: { id: number } }, 'r'>,
userId: number,
): ExpressionWrapper<ReservationTravelersOwnsKyselyDB & { r: { id: number } }, 'r', SqlBool> {
const forThisReservation = eb
.selectFrom('reservation_travelers as rt')
.select('rt.reservation_id')
.whereRef('rt.reservation_id', '=', 'r.id');
return eb.or([
eb.not(eb.exists(forThisReservation)),
eb.exists(forThisReservation.where('rt.user_id', '=', userId)),
]);
}