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)),
]);
}
@@ -13,6 +13,7 @@ import {
castIntegerKysely,
coalesce,
coalesceParam,
collateNoCase,
columnIncrementedBy,
columnRef,
concat,
@@ -26,9 +27,11 @@ import {
lower,
lowerParam,
lowerTrim,
lowerTrimParam,
maxOf,
minOf,
nowMinusDays,
nowMinusHours,
startsWithIsoDate,
startsWithIsoDateKysely,
substring,
@@ -881,4 +884,105 @@ describe('sql-functions (sqlite)', () => {
);
expect(compiled.parameters).toEqual(['2026-09-21', '2026-09-21', 11, 1]);
});
// Plan 3f Task 0 (R9) — no consumer yet: Task 2 (collateNoCase), Task 4
// (nowMinusHours) and Task 1 (lowerTrimParam) wire these in as each
// converts the statement that needs them. Pinned here so each task imports
// a tested helper instead of writing one inline.
it('SQLF-060: collateNoCase renders <col> COLLATE NOCASE, and as a filter key selects the same rows as the legacy value-side spelling (name = ? COLLATE NOCASE)', async () => {
const { user: mixed } = createUser(testDb, { username: 'MixedCase' });
const { user: other } = createUser(testDb, { username: 'SomethingElse' });
const platform = t.em.getPlatform();
const typed = await t.em.find(Users, { [collateNoCase(platform, 'username')]: 'mixedcase' });
expect(typed.map((r) => r.id)).toEqual([mixed.id]);
const legacy = testDb
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE')
.all('mixedcase') as { id: number }[];
expect(typed.map((r) => r.id)).toEqual(legacy.map((r) => r.id));
const none = await t.em.find(Users, { [collateNoCase(platform, 'username')]: 'nobody-has-this-name' });
expect(none).toEqual([]);
expect(other.id).toBeDefined(); // seeded so the fixture has more than one row
});
it("SQLF-061: COLLATE NOCASE folds the same 26 ASCII letters LOWER() does, and does NOT diverge from LOWER() on SQLF-014's own non-ASCII fixtures — verified directly, not assumed", () => {
const cases: Array<[string, string]> = [
['JOSÉ@x.com', 'josé@x.com'],
['ÄNNA@x.com', 'änna@x.com'],
['ΣIGMA@x.com', 'σigma@x.com'],
['İSTANBUL@x.com', 'i̇stanbul@x.com'],
['ASCII@x.com', 'ascii@x.com'],
];
for (const [upper, lower_] of cases) {
const collateMatch = testDb.prepare('SELECT (? = ? COLLATE NOCASE) as m').get(upper, lower_) as { m: number };
const lowerMatch = testDb.prepare('SELECT (LOWER(?) = LOWER(?)) as m').get(upper, lower_) as { m: number };
expect(collateMatch.m).toBe(lowerMatch.m);
}
// The ASCII case is the one pair both engines actually fold to equal;
// every non-ASCII pair above is left unfolded by BOTH engines (neither
// recognises the Unicode case mapping), which is exactly why they agree
// rather than diverge — this is genuinely the "no divergence found"
// branch R9 anticipates, not a skipped check.
const asciiCollate = testDb.prepare('SELECT (? = ? COLLATE NOCASE) as m').get('ASCII@x.com', 'ascii@x.com') as { m: number };
expect(asciiCollate.m).toBe(1);
const nonAsciiCollate = testDb.prepare('SELECT (? = ? COLLATE NOCASE) as m').get('JOSÉ@x.com', 'josé@x.com') as { m: number };
expect(nonAsciiCollate.m).toBe(0);
});
it('SQLF-062: an unknown platform fails closed for collateNoCase', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => collateNoCase(foreign, 'u.name')).toThrow(/no implementation for platform FakePlatform/);
});
it("SQLF-063: nowMinusHours renders datetime('now', '-N hours'), matching a JS-computed time within a few seconds' tolerance", async () => {
const row = testDb.prepare(`SELECT ${nowMinusHours(t.em.getPlatform(), 20).sql} as d`).get() as { d: string };
const got = new Date(`${row.d.replace(' ', 'T')}Z`).getTime();
const expected = Date.now() - 20 * 60 * 60 * 1000;
expect(Math.abs(got - expected)).toBeLessThan(10_000);
});
it('SQLF-063b: nowMinusHours(0) is "now", not one hour back', async () => {
const row = testDb.prepare(`SELECT ${nowMinusHours(t.em.getPlatform(), 0).sql} as d`).get() as { d: string };
const got = new Date(`${row.d.replace(' ', 'T')}Z`).getTime();
expect(Math.abs(got - Date.now())).toBeLessThan(10_000);
});
it('SQLF-064: nowMinusHours rejects a non-integer or negative hour count', () => {
expect(() => nowMinusHours(t.em.getPlatform(), 1.5)).toThrow(/non-negative integer hour count/);
expect(() => nowMinusHours(t.em.getPlatform(), -1)).toThrow(/non-negative integer hour count/);
expect(() => nowMinusHours(t.em.getPlatform(), Number.NaN)).toThrow(/non-negative integer hour count/);
});
it('SQLF-065: an unknown platform fails closed for nowMinusHours', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => nowMinusHours(foreign, 1)).toThrow(/no implementation for platform FakePlatform/);
});
it('SQLF-066: lowerTrimParam pairs with lowerTrim to fold BOTH sides in SQLite (the AT31 dedup shape), and a JS-lowered+trimmed bind does NOT match — the mutation-proof for mixing engines', async () => {
const { user } = createUser(testDb, { username: ' JOSÉ ' });
const platform = t.em.getPlatform();
const exact = await t.em.findOne(Users, { [lowerTrim(platform, 'username')]: lowerTrimParam(platform, ' josÉ ') });
expect(exact?.id).toBe(user.id);
// A JS-lowered+trimmed value-side bind (mixing engines, program rule 18)
// must NOT match: SQLite's LOWER() leaves 'É' untouched, so the stored,
// SQL-folded spelling is 'josÉ', not the JS-folded 'josé'.
const jsLowered = await t.em.findOne(Users, { [lowerTrim(platform, 'username')]: ' josé '.trim().toLowerCase() });
expect(jsLowered).toBeNull();
const none = await t.em.findOne(Users, { [lowerTrim(platform, 'username')]: lowerTrimParam(platform, 'nobody') });
expect(none).toBeNull();
});
it('SQLF-067: an unknown platform fails closed for lowerTrimParam', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => lowerTrimParam(foreign, 'x')).toThrow(/no implementation for platform FakePlatform/);
});
});
@@ -0,0 +1,183 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../../helpers/db-mock';
import { resetTestDb } from '../../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm';
import { createReservation, createTrip, createUser } from '../../../../helpers/factories';
import { Reservations } from '../../../../../src/db/entities/Reservations.entity';
import {
travelerOwnsCondition,
travelerOwnsExpr,
type ReservationTravelersOwnsKyselyDB,
} from '../../../../../src/db/repositories/_shared/reservation-travelers-owns';
/**
* The legacy fragment this harness proves parity against
* (`AtlasService`'s `TRAVELER_OWNS`, `atlas.service.ts:1173-1175`), kept
* here byte-identical to the source text as this test's own oracle — the
* same "hold the STRING side fixed and independent of the production code
* it is proving" reasoning `reservation-visibility.test.ts`/
* `packing-visibility.parity.test.ts` document. Binds the caller's user id
* once. Expects the reservation aliased `r`, matching every one of
* AT6/AT45/AT46's own alias.
*/
const TRAVELER_OWNS = `(
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 = ?)
)`;
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb); });
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function addTraveler(reservationId: number, userId: number): void {
testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(reservationId, userId);
}
async function legacyOwnedIds(tripId: number, userId: number): Promise<number[]> {
const rows = testDb
.prepare(`SELECT id FROM reservations r WHERE r.trip_id = ? AND ${TRAVELER_OWNS} ORDER BY id ASC`)
.all(tripId, userId) as { id: number }[];
return rows.map((row) => row.id);
}
async function typedQbOwnedIds(tripId: number, userId: number): Promise<number[]> {
const rows = await t.em
.createQueryBuilder(Reservations, 'r')
.select(['r.id'])
.where({ 'r.trip_id': tripId, ...travelerOwnsCondition(userId) })
.orderBy({ id: 'asc' })
.execute<{ id: number }[]>('all', false);
return rows.map((row) => row.id);
}
interface TestDB extends ReservationTravelersOwnsKyselyDB {
reservations: { id: number; trip_id: number };
}
async function typedKyselyOwnedIds(tripId: number, userId: number): Promise<number[]> {
const rows = await t.em
.getKysely<TestDB>()
.selectFrom('reservations as r')
.select('r.id')
.where('r.trip_id', '=', tripId)
.where((eb) => travelerOwnsExpr(eb, userId))
.orderBy('r.id', 'asc')
.execute();
return rows.map((row) => row.id);
}
describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that matter, #1966)', () => {
it('TRAVOWNS-001: a reservation with ZERO reservation_travelers rows counts for the WHOLE trip (pre-4.0 backward-compat) — every user sees it', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const unassigned = createReservation(testDb, trip.id, { title: 'Pre-4.0 booking, no travelers' });
for (const userId of [owner.id, other.id]) {
const legacy = await legacyOwnedIds(trip.id, userId);
const typedQb = await typedQbOwnedIds(trip.id, userId);
const typedKysely = await typedKyselyOwnedIds(trip.id, userId);
expect(legacy).toEqual([unassigned.id]);
expect(typedQb).toEqual(legacy);
expect(typedKysely).toEqual(legacy);
}
});
it('TRAVOWNS-002: a reservation WITH assignments narrows to the caller\'s own — an assigned traveler sees it, a non-traveler does not', async () => {
const { user: traveler } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, traveler.id);
const assigned = createReservation(testDb, trip.id, { title: 'Flight with named travelers' });
addTraveler(assigned.id, traveler.id);
const legacyForTraveler = await legacyOwnedIds(trip.id, traveler.id);
const typedQbForTraveler = await typedQbOwnedIds(trip.id, traveler.id);
const typedKyselyForTraveler = await typedKyselyOwnedIds(trip.id, traveler.id);
expect(legacyForTraveler).toEqual([assigned.id]);
expect(typedQbForTraveler).toEqual(legacyForTraveler);
expect(typedKyselyForTraveler).toEqual(legacyForTraveler);
const legacyForStranger = await legacyOwnedIds(trip.id, stranger.id);
const typedQbForStranger = await typedQbOwnedIds(trip.id, stranger.id);
const typedKyselyForStranger = await typedKyselyOwnedIds(trip.id, stranger.id);
expect(legacyForStranger).toEqual([]);
expect(typedQbForStranger).toEqual(legacyForStranger);
expect(typedKyselyForStranger).toEqual(legacyForStranger);
});
it('TRAVOWNS-003: a reservation with MULTIPLE travelers — each assigned traveler sees it, exactly once, a non-traveler does not', async () => {
const { user: travelerA } = createUser(testDb);
const { user: travelerB } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, travelerA.id);
const shared = createReservation(testDb, trip.id, { title: 'Family flight' });
addTraveler(shared.id, travelerA.id);
addTraveler(shared.id, travelerB.id);
for (const userId of [travelerA.id, travelerB.id]) {
const legacy = await legacyOwnedIds(trip.id, userId);
const typedQb = await typedQbOwnedIds(trip.id, userId);
const typedKysely = await typedKyselyOwnedIds(trip.id, userId);
expect(legacy).toEqual([shared.id]);
expect(typedQb).toEqual(legacy);
expect(typedKysely).toEqual(legacy);
}
const legacyStranger = await legacyOwnedIds(trip.id, stranger.id);
const typedQbStranger = await typedQbOwnedIds(trip.id, stranger.id);
const typedKyselyStranger = await typedKyselyOwnedIds(trip.id, stranger.id);
expect(legacyStranger).toEqual([]);
expect(typedQbStranger).toEqual(legacyStranger);
expect(typedKyselyStranger).toEqual(legacyStranger);
});
it('TRAVOWNS-004: a mixed trip — one unassigned booking, one assigned-to-someone-else booking — resolves identically between all three forms', async () => {
const { user: owner } = createUser(testDb);
const { user: travelerB } = createUser(testDb);
const { user: viewer } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const unassigned = createReservation(testDb, trip.id, { title: 'Unassigned' });
const assignedToOther = createReservation(testDb, trip.id, { title: 'Assigned to someone else' });
addTraveler(assignedToOther.id, travelerB.id);
const legacy = await legacyOwnedIds(trip.id, viewer.id);
const typedQb = await typedQbOwnedIds(trip.id, viewer.id);
const typedKysely = await typedKyselyOwnedIds(trip.id, viewer.id);
// `viewer` is nobody's traveler: the unassigned booking still counts
// (the NOT EXISTS arm), the booking assigned to travelerB does not.
expect(legacy).toEqual([unassigned.id]);
expect(typedQb).toEqual(legacy);
expect(typedKysely).toEqual(legacy);
});
// Mutation-sensitive (R7/the plan's own risk list: "getting this wrong
// either double-counts a shared trip's flights per member or zeroes every
// pre-4.0 install's flown distance overnight" — a dedicated matrix before
// any consumer converts). Manually replacing `travelerOwnsCondition`'s
// `$or` with JUST the `$some` arm (dropping the `$none`/NOT-EXISTS
// fallback) and re-running this suite: TRAVOWNS-001 goes red (the
// pre-4.0, zero-traveler `unassigned` booking disappears for every user,
// including its own owner) while TRAVOWNS-002/003 stay green — proving
// this case is not redundant with the rest of the matrix and that the
// `$none` arm is genuinely load-bearing. Restored immediately after
// observing the failure; the predicate module itself is unchanged in this
// commit.
it('TRAVOWNS-005 (mutation-sensitive): dropping the zero-traveler fallback would hide a pre-4.0 booking from its own owner', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const unassigned = createReservation(testDb, trip.id, { title: 'Pre-4.0 booking' });
const legacy = await legacyOwnedIds(trip.id, owner.id);
const typedQb = await typedQbOwnedIds(trip.id, owner.id);
const typedKysely = await typedKyselyOwnedIds(trip.id, owner.id);
expect(legacy).toEqual([unassigned.id]);
expect(typedQb).toEqual(legacy);
expect(typedKysely).toEqual(legacy);
});
});