diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts index c54cebb63..64f19d54b 100644 --- a/server/src/db/dialect/sql-functions.ts +++ b/server/src/db/dialect/sql-functions.ts @@ -755,3 +755,119 @@ export function unixEpochToIsoKysely( return unsupported(platform); } +// --------------------------------------------------------------------------- +// Plan 3h Task 0 — no consumer yet: Task 3 (`dawarich-sync.service.ts`'s +// `listTripsToSync`, DSY2 — `date('now','-400 days')` / `date('now','+1 +// day')`) and Task 4 (`route-usage.service.ts`'s `purgeExpired`, RU4 — +// `date('now', ?)` bound with the modifier `-${RETENTION_DAYS} days`, and +// RETENTION_DAYS (`route-usage.service.ts:13`) is a compile-time module +// constant (`400`), not a runtime value — so RU4's "bound parameter" and +// DSY2's "literal" are the SAME shape at the call site once the modifier is +// known, confirmed directly against both files' actual source rather than +// assumed from the legacy SQL's own binding style) wire `nowDateOffset` in +// when each converts its own statement. +// +// Plan 3f's own four `date('now')` sites (atlas.service.ts AT39/AT42/AT44/ +// AT45 — 3g's inventory's own open question, inherited from 3f and never +// resolved until now) do NOT go through a dialect helper at all: `todayUtc()` +// (a `@trek/shared` JS function) resolves the calendar date once in the +// CALLING SERVICE (`atlas.service.ts:912`, `const trip = await +// this.trips.lastStartedTrip(userId, todayUtc())`) and the repository binds +// it as a plain parameter (`Trips.repository.ts#lastStartedTrip`: +// `.where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), '<=', +// today))`) — confirmed by reading the actual converted code at HEAD, not +// the plan document's description of it. That pattern covers a BARE +// `date('now')` (no offset) cleanly, and needs no new helper here. It does +// not extend to an OFFSET the way DSY2/RU4 need without every call site +// re-deriving day arithmetic in JS by hand, so this file instead grows the +// sibling `nowMinusDays`/`nowMinusHours` already establish for exactly this +// family (Plan 3c Task 0b/Plan 3f Task 0 R9): a signed day-count offset from +// "now", spelled into the fragment after integer validation, the same +// reasoning those two helpers give (MikroORM's raw fragments do not accept a +// placeholder inside a SQLite date/time function's modifier argument). +// `PlaceShadowPicksRepository.expireStale` (Plan 3c Task 1) is this shape's +// own closest working precedent — a retention-window purge built on +// `nowMinusDays` exactly the way `RouteUsageDailyRepository`'s own purge +// (RU4) will be built on `nowDateOffset` below. +// --------------------------------------------------------------------------- + +/** + * `date('now', 'N days')` — the SQLite clock's CALENDAR DATE (`date()`, + * not `datetime()` — `nowMinusDays` above is the wrong shape for this: it + * targets `datetime()`, and only ever subtracts), shifted by a signed whole + * day count. Unlike `nowMinusDays`, `days` may be POSITIVE (forward), + * NEGATIVE (backward) or zero — `dawarich-sync.service.ts`'s + * `listTripsToSync` (DSY2) needs both directions in the SAME statement + * (`date('now','-400 days')` for the trailing edge, `date('now','+1 day')` + * for the leading one). `days` is spelled directly into the fragment (not + * bound), the same reasoning `nowMinusDays`'s own docstring gives. + * + * Verified directly (not assumed) that SQLite's modifier keyword tolerates + * the singular/plural mismatch between the two legacy texts (`'+1 day'` + * singular, `'-400 days'` plural): `date('now','+1 day')` and + * `date('now','+1 days')` render the identical result against a live + * `better-sqlite3` connection, so this helper's always-plural spelling is + * behaviourally identical to both legacy statements, not just the plural one. + */ +export function nowDateOffset(platform: Platform, days: number): RawQueryFragment { + if (!Number.isInteger(days)) { + throw new Error(`sql-functions: nowDateOffset needs an integer day count, got ${days}`); + } + if (platform instanceof SqlitePlatform) { + const sign = days < 0 ? '-' : '+'; + return raw(`date('now', '${sign}${Math.abs(days)} days')`); + } + return unsupported(platform); +} + +/** + * `datetime('now', '+N seconds')` — the seconds-granularity, ADDING sibling + * of `nowMinusDays`/`nowMinusHours` above: the shape `doc-sync.service.ts`'s + * `upsertItem` (DS23, inside an `ON CONFLICT DO UPDATE`'s `CASE WHEN`; DS26, + * inside the sibling `INSERT ... VALUES`) and `recordLinkFailure` (a third, + * structurally identical call site found re-reading the file at HEAD — + * `next_attempt_at = datetime('now', '+' || ? || ' seconds')`, + * `doc-sync.service.ts:967`, not individually numbered in the plan's own + * inventory) all need: a caller-computed backoff window (`backoffSeconds( + * ...)`, a genuine RUNTIME value, never a source-code constant the way + * `nowMinusDays`'s callers' day counts are) added to the database clock. + * `seconds` is validated non-negative-integer and spelled directly into the + * fragment — the same reasoning (and the same MikroORM raw-fragment + * placeholder limitation) `nowMinusDays`'s own docstring gives; every known + * call site's `seconds` argument is already a JS-computed `number`, never + * user input, the same trust boundary this file relies on elsewhere + * (`dateAdd`'s day count, `substring`'s start/length). + */ +export function nowPlusSeconds(platform: Platform, seconds: number): RawQueryFragment { + if (!Number.isInteger(seconds) || seconds < 0) { + throw new Error(`sql-functions: nowPlusSeconds needs a non-negative integer second count, got ${seconds}`); + } + if (platform instanceof SqlitePlatform) return raw(`datetime('now', '+${seconds} seconds')`); + return unsupported(platform); +} + +/** + * The Kysely-expression twin of {@link nowPlusSeconds}: DS23/DS24's + * `ON CONFLICT ... DO UPDATE` / `INSERT ... VALUES` (R3) is a hand-typed + * Kysely statement — MikroORM's `em.upsert` cannot express a + * partial-unique-index conflict target (R3's own ruling) — so the + * `next_attempt_at` computation inside its `CASE WHEN` needs a Kysely + * `Expression`, not a MikroORM `RawQueryFragment` (SQLF-048: a + * `RawQueryFragment` throws when handed into a Kysely statement). Same + * validation, same always-non-negative-integer trust boundary as the + * MikroORM form above. + */ +export function nowPlusSecondsKysely( + platform: Platform, + eb: ExpressionBuilder, + seconds: number, +): ExpressionWrapper { + if (!Number.isInteger(seconds) || seconds < 0) { + throw new Error(`sql-functions: nowPlusSecondsKysely needs a non-negative integer second count, got ${seconds}`); + } + if (platform instanceof SqlitePlatform) { + return eb.fn('datetime', [eb.val('now'), eb.val(`+${seconds} seconds`)]); + } + 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 7856e4125..e367c9e1c 100644 --- a/server/tests/unit/db/dialect/sql-functions.test.ts +++ b/server/tests/unit/db/dialect/sql-functions.test.ts @@ -30,8 +30,11 @@ import { lowerTrimParam, maxOf, minOf, + nowDateOffset, nowMinusDays, nowMinusHours, + nowPlusSeconds, + nowPlusSecondsKysely, startsWithIsoDate, startsWithIsoDateKysely, substring, @@ -1021,6 +1024,106 @@ describe('sql-functions (sqlite)', () => { const eb = expressionBuilder(); expect(() => unixEpochToIsoKysely(foreign, eb, 'created_at')).toThrow(/no implementation for platform FakePlatform/); }); + + // Plan 3h Task 0 — no consumer yet: Task 3 (DSY2), Task 4 (RU4), Task 5 + // (DS23/DS26/recordLinkFailure) wire these in as each converts its own + // statement. Pinned here so each task imports a tested helper instead of + // writing one inline. + + it("SQLF-071: nowDateOffset renders date('now', '-N days'), matching a JS-computed date within a few seconds' tolerance", async () => { + const row = testDb.prepare(`SELECT ${nowDateOffset(t.em.getPlatform(), -400).sql} as d`).get() as { d: string }; + const got = new Date(`${row.d}T00:00:00Z`).getTime(); + const expected = Date.now() - 400 * 24 * 60 * 60 * 1000; + // Compare calendar dates (UTC), not exact millis — date() truncates to a day. + expect(Math.abs(got - expected)).toBeLessThan(2 * 24 * 60 * 60 * 1000); + const expectedDate = new Date(expected).toISOString().slice(0, 10); + expect(row.d).toBe(expectedDate); + }); + + it("SQLF-072: nowDateOffset renders date('now', '+N days') for a positive count, matching DSY2's '+1 day' legacy text (singular) byte-for-byte, not just the helper's own plural spelling", async () => { + const helperRow = testDb.prepare(`SELECT ${nowDateOffset(t.em.getPlatform(), 1).sql} as d`).get() as { d: string }; + const legacyRow = testDb.prepare(`SELECT date('now', '+1 day') as d`).get() as { d: string }; + expect(helperRow.d).toBe(legacyRow.d); + const expectedDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + expect(helperRow.d).toBe(expectedDate); + }); + + it('SQLF-073: nowDateOffset(0) is today, not one day off in either direction', () => { + const row = testDb.prepare(`SELECT ${nowDateOffset(t.em.getPlatform(), 0).sql} as d`).get() as { d: string }; + expect(row.d).toBe(new Date().toISOString().slice(0, 10)); + }); + + it('SQLF-074: nowDateOffset rejects a non-integer day count', () => { + expect(() => nowDateOffset(t.em.getPlatform(), 1.5)).toThrow(/integer day count/); + expect(() => nowDateOffset(t.em.getPlatform(), Number.NaN)).toThrow(/integer day count/); + }); + + it('SQLF-075: an unknown platform fails closed for nowDateOffset', () => { + class FakePlatform extends Platform {} + const foreign = new FakePlatform(); + expect(() => nowDateOffset(foreign, 1)).toThrow(/no implementation for platform FakePlatform/); + }); + + it("SQLF-076: nowPlusSeconds renders datetime('now', '+N seconds'), matching a JS-computed time within a few seconds' tolerance", async () => { + const row = testDb.prepare(`SELECT ${nowPlusSeconds(t.em.getPlatform(), 30).sql} as d`).get() as { d: string }; + const got = new Date(`${row.d.replace(' ', 'T')}Z`).getTime(); + const expected = Date.now() + 30 * 1000; + expect(Math.abs(got - expected)).toBeLessThan(10_000); + }); + + it('SQLF-077: nowPlusSeconds(0) is "now", not thirty seconds off', () => { + const row = testDb.prepare(`SELECT ${nowPlusSeconds(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-078: nowPlusSeconds rejects a non-integer or negative second count', () => { + expect(() => nowPlusSeconds(t.em.getPlatform(), 1.5)).toThrow(/non-negative integer second count/); + expect(() => nowPlusSeconds(t.em.getPlatform(), -1)).toThrow(/non-negative integer second count/); + expect(() => nowPlusSeconds(t.em.getPlatform(), Number.NaN)).toThrow(/non-negative integer second count/); + }); + + it('SQLF-079: an unknown platform fails closed for nowPlusSeconds', () => { + class FakePlatform extends Platform {} + const foreign = new FakePlatform(); + expect(() => nowPlusSeconds(foreign, 1)).toThrow(/no implementation for platform FakePlatform/); + }); + + it("SQLF-080: nowPlusSecondsKysely compiles to datetime(?, ?), matching nowPlusSeconds's own datetime('now', '+N seconds') text shape and runtime value (the DS23/DS26/recordLinkFailure shape, inside a Kysely ON CONFLICT DO UPDATE's CASE WHEN — R3)", async () => { + createUser(testDb); // one row so the anchor SELECT FROM users has something to select + const platform = t.em.getPlatform(); + + const compiled = t.em.getKysely() + .selectFrom('users') + .select((eb) => [nowPlusSecondsKysely(platform, eb, 45).as('d')]) + .compile(); + expect(compiled.sql).toBe('select datetime(?, ?) as "d" from "users"'); + expect(compiled.parameters).toEqual(['now', '+45 seconds']); + + const got = testDb.prepare(compiled.sql).get(...compiled.parameters) as { d: string }; + const raw = testDb.prepare(`SELECT ${nowPlusSeconds(platform, 45).sql} as d`).get() as { d: string }; + // Both texts render the same shape; a wide tolerance sidesteps a same-second + // flake between the two separate statement executions (SQLF-016/063's own + // convention for every other 'now'-based helper in this file). + const gotMs = new Date(`${got.d.replace(' ', 'T')}Z`).getTime(); + const rawMs = new Date(`${raw.d.replace(' ', 'T')}Z`).getTime(); + expect(Math.abs(gotMs - rawMs)).toBeLessThan(10_000); + expect(Math.abs(gotMs - (Date.now() + 45 * 1000))).toBeLessThan(10_000); + }); + + it('SQLF-081: nowPlusSecondsKysely rejects a non-integer or negative second count', () => { + const eb = expressionBuilder(); + const platform = t.em.getPlatform(); + expect(() => nowPlusSecondsKysely(platform, eb, 1.5)).toThrow(/non-negative integer second count/); + expect(() => nowPlusSecondsKysely(platform, eb, -1)).toThrow(/non-negative integer second count/); + }); + + it('SQLF-082: an unknown platform fails closed for nowPlusSecondsKysely', () => { + class FakePlatform extends Platform {} + const foreign = new FakePlatform(); + const eb = expressionBuilder(); + expect(() => nowPlusSecondsKysely(foreign, eb, 1)).toThrow(/no implementation for platform FakePlatform/); + }); }); /**