From 71ebf5588b8a33073a8890083d38c14ab48fb499 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 23 Sep 2026 21:57:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20plan=203g=20groundwork=20?= =?UTF-8?q?=E2=80=94=20GALLERY=5FCHRONOLOGICAL=5FORDER=20Kysely=20ordering?= =?UTF-8?q?,=20unixEpochToIsoKysely=20helper,=20composite-PK=20DDL=20pins,?= =?UTF-8?q?=20JB5/R7=20confirmations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/db/dialect/sql-functions.ts | 46 ++++ .../unit/db/dialect/sql-functions.test.ts | 202 +++++++++++++++++- 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts index 7cb443bfb..c54cebb63 100644 --- a/server/src/db/dialect/sql-functions.ts +++ b/server/src/db/dialect/sql-functions.ts @@ -709,3 +709,49 @@ export function lowerTrimParam(platform: Platform, value: string): RawQueryFragm return unsupported(platform); } +// --------------------------------------------------------------------------- +// Plan 3g Task 0 (R1) — no consumer yet: Task 1 (`getJourneyFull`'s gallery +// read) and Task 3 (`getPublicJourney`'s gallery read) each fold this into +// their own rebuild of `journey-gallery-order.ts`'s `GALLERY_CHRONOLOGICAL_ORDER` +// text (a `COALESCE(NULLIF(tp.taken_at, ''), , +// )` ORDER BY, worked out in full in Task 0's report for both to paste +// in verbatim, neither re-deriving it). Added here, Kysely-only (no MikroORM +// `raw()` sibling): the shape it exists for is genuinely Kysely-only (a +// correlated scalar subquery cannot be expressed through the MikroORM +// QueryBuilder — the same "T6 ruling" `castIntegerKysely`'s own docstring +// cites for RS20/RV2), and nothing else in this plan's inventory needs a +// MikroORM-form epoch-to-ISO conversion (§6: "no other strftime/julianday +// sites found elsewhere in this cluster"). A raw twin can be added later, +// same as every other helper here, the moment a real consumer needs one. +// --------------------------------------------------------------------------- + +/** + * `strftime('%Y-%m-%dT%H:%M:%SZ', / 1000, 'unixepoch')` — the last of + * `GALLERY_CHRONOLOGICAL_ORDER`'s three fallback tiers (R1): when a gallery + * photo has neither a capture time (`tp.taken_at`) nor a linked entry to + * borrow a date from, the moment it was added is what is left to order by. + * `` is an epoch-MILLISECONDS column (`journey_photos.created_at`, like + * every other `*_at` epoch column in this codebase) — SQLite's `unixepoch` + * modifier expects whole SECONDS, so the division is load-bearing, not + * decorative, and matches the legacy text's own `gp.created_at / 1000` + * exactly. SQLite's `/` on two integers truncates instead of rounding; that + * sub-second loss is the legacy statement's own behaviour, preserved here + * rather than "fixed" into a float divide that would render a different ISO + * string for any timestamp not an exact multiple of 1000ms. + * + * Named for what it computes, not the SQL function it spells — matching + * every other `*Kysely` twin in this file (`castIntegerKysely`, not + * `castKysely`; `startsWithIsoDateKysely`, not `globKysely`). + */ +export function unixEpochToIsoKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + if (platform instanceof SqlitePlatform) { + const seconds = eb(ref, '/', eb.val(1000)); + return eb.fn('strftime', [eb.val('%Y-%m-%dT%H:%M:%SZ'), seconds, eb.val('unixepoch')]); + } + return unsupported(platform); +} + diff --git a/server/tests/unit/db/dialect/sql-functions.test.ts b/server/tests/unit/db/dialect/sql-functions.test.ts index 0520571e4..7856e4125 100644 --- a/server/tests/unit/db/dialect/sql-functions.test.ts +++ b/server/tests/unit/db/dialect/sql-functions.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { Platform } from '@mikro-orm/core'; -import { expressionBuilder } from 'kysely'; +import { expressionBuilder, type ExpressionBuilder } from 'kysely'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; @@ -37,7 +37,9 @@ import { substring, substringKysely, trim, + unixEpochToIsoKysely, } from '../../../../src/db/dialect/sql-functions'; +import { createJourney, createJourneyEntry } from '../../../helpers/factories'; /** The `users` columns the Kysely-expression tests below read/write, narrowed the same way every other Kysely-typed repository method in this program declares its own `TDB`. */ interface UsersKyselyDB { @@ -985,4 +987,202 @@ describe('sql-functions (sqlite)', () => { const foreign = new FakePlatform(); expect(() => lowerTrimParam(foreign, 'x')).toThrow(/no implementation for platform FakePlatform/); }); + + // Plan 3g Task 0 (R1) — unixEpochToIsoKysely, the third fallback tier of + // `GALLERY_CHRONOLOGICAL_ORDER` (journey-gallery-order.ts). No consumer + // yet: Task 1 (getJourneyFull) and Task 3 (getPublicJourney) each fold this + // into their own rebuild of the constant. The full composed ORDER BY proof + // (coalesce + nullif + correlated subquery + this helper, on a seeded + // gallery) is the describe block below this one. + + it("SQLF-068: unixEpochToIsoKysely renders strftime('%Y-%m-%dT%H:%M:%SZ', / 1000, 'unixepoch'), matching a raw statement on the SAME fixed value, string-for-string", () => { + createUser(testDb); // one row so the anchor SELECT FROM users has something to select + const platform = t.em.getPlatform(); + const createdAt = 1_700_000_000_000; // fixed epoch-millis value — the exact shape gp.created_at stores + + const compiled = t.em.getKysely() + .selectFrom('users') + .select((eb) => [unixEpochToIsoKysely(platform, eb, eb.val(createdAt)).as('iso')]) + .compile(); + expect(compiled.sql).toBe('select strftime(?, ? / ?, ?) as "iso" from "users"'); + expect(compiled.parameters).toEqual(['%Y-%m-%dT%H:%M:%SZ', createdAt, 1000, 'unixepoch']); + + const got = testDb.prepare(compiled.sql).get(...compiled.parameters) as { iso: string }; + const expected = testDb + .prepare("SELECT strftime('%Y-%m-%dT%H:%M:%SZ', ? / 1000, 'unixepoch') as iso") + .get(createdAt) as { iso: string }; + expect(got.iso).toBe(expected.iso); + expect(got.iso).toBe('2023-11-14T22:13:20Z'); // human-checkable: date -u -d @1700000000 + }); + + it('SQLF-069: an unknown platform fails closed for unixEpochToIsoKysely', () => { + class FakePlatform extends Platform {} + const foreign = new FakePlatform(); + const eb = expressionBuilder(); + expect(() => unixEpochToIsoKysely(foreign, eb, 'created_at')).toThrow(/no implementation for platform FakePlatform/); + }); +}); + +/** + * Plan 3g Task 0 (R1) — the full Kysely rebuild of + * `journey-gallery-order.ts`'s `GALLERY_CHRONOLOGICAL_ORDER`, as a worked + * example: `COALESCE(NULLIF(tp.taken_at, ''), , ) ASC, gp.sort_order ASC, gp.id ASC`. NOT exported from + * `sql-functions.ts` (only `unixEpochToIsoKysely`, this file's one named + * deliverable, lives there) — Task 1/Task 3 each own the actual repository + * method this becomes (`JourneyPhotosRepository`/callers, not yet built); + * this function is the verbatim builder chain Task 0's report hands them, + * proven row-order-identical to the legacy SQL text against a seeded + * gallery here so neither task re-derives it from scratch. + */ +interface GalleryOrderTestDB { + journey_photos: { + id: number; + journey_id: number; + photo_id: number; + sort_order: number | null; + created_at: number; + }; + trek_photos: { + id: number; + taken_at: string | null; + }; + journey_entry_photos: { + entry_id: number; + journey_photo_id: number; + }; + journey_entries: { + id: number; + entry_date: string; + entry_time: string | null; + }; +} + +function galleryChronologicalOrderExpr( + platform: Platform, + // Fixed to exactly the shape `.selectFrom('journey_photos as gp') + // .innerJoin('trek_photos as tp', ...)` itself produces against a + // `Kysely` (`GalleryOrderTestDB` plus the derived + // `gp`/`tp` alias members, read off the SAME interface's own + // `journey_photos`/`trek_photos` entries) — the `publicStayExists` + // precedent (`_shared/reservation-visibility.ts`): a table's alias is a + // property of the QUERY, never of the `DB` interface itself, so a + // correlated-subquery helper with a fixed alias contract is typed this + // way rather than generic over an arbitrary caller `DB`/`TB`. + eb: ExpressionBuilder, +) { + return eb.fn.coalesce( + eb.fn('nullif', [eb.ref('tp.taken_at'), eb.val('')]), + eb + .selectFrom('journey_entry_photos as jep') + .innerJoin('journey_entries as je', 'je.id', 'jep.entry_id') + .select((eb2) => + eb2.fn + .min( + concatKysely( + platform, + eb2, + { column: 'je.entry_date' }, + { value: 'T' }, + { expression: eb2.fn.coalesce(eb2.fn('nullif', [eb2.ref('je.entry_time'), eb2.val('')]), eb2.val('00:00')) }, + ), + ) + .as('min_dt'), + ) + .whereRef('jep.journey_photo_id', '=', 'gp.id'), + unixEpochToIsoKysely(platform, eb, 'gp.created_at'), + ); +} + +describe('GALLERY_CHRONOLOGICAL_ORDER Kysely rebuild (Plan 3g Task 0, R1 worked example)', () => { + it('SQLF-070: row order matches the legacy GALLERY_CHRONOLOGICAL_ORDER text exactly on a seeded gallery covering all three fallback tiers, ties, NULL/empty taken_at, and entries with/without linked photos', async () => { + const platform = t.em.getPlatform(); + const { user } = createUser(testDb, { username: 'gallery-owner' }); + const journey = createJourney(testDb, user.id); + const entryEarly = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-05' }); + testDb.prepare('UPDATE journey_entries SET entry_time = ? WHERE id = ?').run('14:30', entryEarly.id); + const entryNoTime = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-05' }); + // entry_time left NULL — the entry-linked tier's own COALESCE(NULIF(entry_time,''),'00:00') fallback + + const trekPhoto = (takenAt: string | null) => { + const r = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id, taken_at) VALUES (?, ?, ?, ?)') + .run('immich', `asset-${Math.random()}`, user.id, takenAt); + return r.lastInsertRowid as number; + }; + const galleryPhoto = (photoId: number, createdAt: number, sortOrder: number) => { + const r = testDb + .prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, ?, ?)') + .run(journey.id, photoId, sortOrder, createdAt); + return r.lastInsertRowid as number; + }; + const linkEntry = (entryId: number, journeyPhotoId: number) => { + testDb + .prepare('INSERT INTO journey_entry_photos (entry_id, journey_photo_id, created_at) VALUES (?, ?, ?)') + .run(entryId, journeyPhotoId, Date.now()); + }; + + // Tier 1: has a capture time — wins outright regardless of everything else. + const pCapture = galleryPhoto(trekPhoto('2026-02-01T09:00:00Z'), 1_700_000_000_000, 0); + // Tier 1 skipped via empty-string NULLIF, not SQL NULL — proves both forms fall through. + const pEmptyTaken = galleryPhoto(trekPhoto(''), 1_600_000_000_000, 0); + linkEntry(entryEarly.id, pEmptyTaken); // tier 2: '2026-01-05T14:30' + // Tier 2 via SQL NULL taken_at, linked to the no-entry-time entry. + const pNullTaken = galleryPhoto(trekPhoto(null), 1_600_000_000_000, 0); + linkEntry(entryNoTime.id, pNullTaken); // tier 2: '2026-01-05T00:00' + // Tier 2, linked to TWO entries — proves the correlated subquery's MIN, not just any match. + const pMultiEntry = galleryPhoto(trekPhoto(null), 1_600_000_000_000, 0); + linkEntry(entryEarly.id, pMultiEntry); // '2026-01-05T14:30' + const entryLater = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-06-01' }); + linkEntry(entryLater.id, pMultiEntry); // '2026-06-01T00:00' — MIN must still pick the earlier one + // Tier 3: no taken_at, no entry link at all — falls all the way to created_at. + const pFallbackA = galleryPhoto(trekPhoto(null), 1_650_000_000_000, 5); + // Tier 3 tie: identical created_at to pFallbackA, broken by sort_order (lower first). + const pFallbackTieLow = galleryPhoto(trekPhoto(null), 1_650_000_000_000, 1); + const pFallbackTieHigh = galleryPhoto(trekPhoto(null), 1_650_000_000_000, 1); + // (pFallbackTieLow/pFallbackTieHigh share BOTH created_at and sort_order — + // the final id ASC tiebreak must separate them, insertion order = id order.) + + const rows = await t.em + .getKysely() + .selectFrom('journey_photos as gp') + .innerJoin('trek_photos as tp', 'tp.id', 'gp.photo_id') + .select('gp.id') + .where('gp.journey_id', '=', journey.id) + .orderBy((eb) => galleryChronologicalOrderExpr(platform, eb), 'asc') + .orderBy('gp.sort_order', 'asc') + .orderBy('gp.id', 'asc') + .execute(); + + const legacy = testDb + .prepare(` + SELECT gp.id + FROM journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id + WHERE gp.journey_id = ? + ORDER BY COALESCE( + NULLIF(tp.taken_at, ''), + (SELECT MIN(je.entry_date || 'T' || COALESCE(NULLIF(je.entry_time, ''), '00:00')) + FROM journey_entry_photos jep + JOIN journey_entries je ON je.id = jep.entry_id + WHERE jep.journey_photo_id = gp.id), + strftime('%Y-%m-%dT%H:%M:%SZ', gp.created_at / 1000, 'unixepoch') + ) ASC, + gp.sort_order ASC, + gp.id ASC + `) + .all(journey.id) as { id: number }[]; + + expect(rows.map((r) => r.id)).toEqual(legacy.map((r) => r.id)); + // Pinned expected order, so a future change to the fixture that happens to + // keep both queries agreeing (but wrong) still gets caught: + expect(rows.map((r) => r.id)).toEqual([ + pFallbackTieLow, // tier 3, strftime('2022-04-15T05:20:00Z'), sort_order=1, lower id first + pFallbackTieHigh, // tier 3, same created_at+sort_order, id tiebreak + pFallbackA, // tier 3, same created_at, sort_order=5 — sorts after the tier-3 pair above + pNullTaken, // tier 2, '2026-01-05T00:00' — earliest tier-2 text (entry_time NULL -> '00:00') + pEmptyTaken, // tier 2, '2026-01-05T14:30' + pMultiEntry, // tier 2, MIN of two links = '2026-01-05T14:30' (ties pEmptyTaken, id breaks it) + pCapture, // tier 1, '2026-02-01T09:00:00Z' — a real taken_at always wins tier 1 + ]); + }); });