feat(server): plan 3h groundwork — date-offset dialect helpers, entity/repository fitness confirmations, pre-init and encryption pattern confirmations, survivor re-grep

This commit is contained in:
jubnl
2026-09-23 22:34:31 +02:00
parent 6486159289
commit 9e75aea73f
2 changed files with 219 additions and 0 deletions
+116
View File
@@ -755,3 +755,119 @@ export function unixEpochToIsoKysely<DB, TB extends keyof DB>(
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', '<sign>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<DB, TB extends keyof DB>(
platform: Platform,
eb: ExpressionBuilder<DB, TB>,
seconds: number,
): ExpressionWrapper<DB, TB, string> {
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<string>('datetime', [eb.val('now'), eb.val(`+${seconds} seconds`)]);
}
return unsupported(platform);
}
@@ -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<UsersKyselyDB, 'users'>();
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<UsersKyselyDB>()
.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<UsersKyselyDB, 'users'>();
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<UsersKyselyDB, 'users'>();
expect(() => nowPlusSecondsKysely(foreign, eb, 1)).toThrow(/no implementation for platform FakePlatform/);
});
});
/**