mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins
This commit is contained in:
@@ -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)),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user