|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20176] THE storage form of a temporal value — one rule, read by every face |
| 5 | + * that compares one. |
| 6 | + * |
| 7 | + * ADR-0053 fixes one LOGICAL storage form per temporal field type: |
| 8 | + * |
| 9 | + * | Field type | Stored as | Why | |
| 10 | + * |---|---|---| |
| 11 | + * | `datetime` | canonical UTC ISO text, `YYYY-MM-DDTHH:MM:SS.sssZ` (`Date#toISOString`) | fixed width + UTC, so text order IS chronological order, and it is the wire form (D-B) | |
| 12 | + * | `date` | `YYYY-MM-DD` | a timezone-naive calendar day (Phase 1) | |
| 13 | + * | `time` | `HH:MM:SS`, `.fff` only when non-zero | a timezone-naive wall clock (D-C1); `.` sorts below every digit, so the two widths still sort chronologically | |
| 14 | + * |
| 15 | + * A filter comparand must be put into the column's form before it is compared, |
| 16 | + * or the comparison is decided by the two sides' SHAPES disagreeing rather than |
| 17 | + * by their values. Three faces apply that pairing: |
| 18 | + * |
| 19 | + * - `driver-sql` — on write (`formatInput`), on read presentation and on every |
| 20 | + * `where` comparand (`coerceFilterValue`, public as `temporalFilterValue`). |
| 21 | + * A MySQL `datetime` then takes a PHYSICAL spelling of the same instant |
| 22 | + * (`mysqlDatetimeLiteral`), which stays the driver's: it is a dialect's |
| 23 | + * literal syntax, not a second rule. |
| 24 | + * - `driver-memory` — on write and on every `where` comparand |
| 25 | + * (`coerceTemporalValue`). |
| 26 | + * - `@objectstack/objectql` — a per-aggregation `filter` and `having`, which |
| 27 | + * the engine evaluates itself over the rows a driver returned |
| 28 | + * (`having-filter.ts`, with the column's class from the object's declaration |
| 29 | + * or the aggregated row's). |
| 30 | + * |
| 31 | + * Each driver used to carry its own copy of the three functions below, word for |
| 32 | + * word; the engine carried none and compared type-blind, so an ISO instant |
| 33 | + * against a `date` column counted 1 row where the same condition in a `where` |
| 34 | + * counted 3. The copies agreed shape for shape when they were lifted here |
| 35 | + * (measured on 71 shapes × 3 kinds: `Date`s valid, invalid and out of range, |
| 36 | + * epoch numbers and strings, bare days, zone-naive and zone-explicit |
| 37 | + * timestamps, wall clocks in and out of range, junk, `null`, structures) — so |
| 38 | + * this is a move, not a change, for either driver. |
| 39 | + * |
| 40 | + * ## Form only, never bound semantics |
| 41 | + * |
| 42 | + * The function is operator-blind. The whole-day reading of a bare |
| 43 | + * `YYYY-MM-DD` used as an UPPER bound on a `datetime` column (`$lte`, a |
| 44 | + * `$between` max → `< nextUtcCalendarDay(day)`, ADR-0053 D-D) is a separate, |
| 45 | + * operator-sensitive rule every emitter applies BEFORE this one, from |
| 46 | + * `@objectstack/spec/data`'s `nextUtcCalendarDay`. |
| 47 | + * |
| 48 | + * ## Total, on purpose |
| 49 | + * |
| 50 | + * `null` / `undefined`, the empty string, an out-of-range wall clock |
| 51 | + * (`'25:00'`), an Invalid Date and unparseable junk come back UNCHANGED rather |
| 52 | + * than as an invented value — a value the rule cannot interpret is never |
| 53 | + * silently rewritten, so junk keeps failing its comparison. Which strings are |
| 54 | + * uninterpretable is the question `isUninterpretableTemporalComparand` |
| 55 | + * (`temporal-comparand.ts`) answers for the doors that refuse them. |
| 56 | + */ |
| 57 | + |
| 58 | +import type { TemporalComparandKind } from './temporal-comparand.js'; |
| 59 | + |
| 60 | +/** |
| 61 | + * The storage form of `value` for a column of `kind` — see the module note for |
| 62 | + * the table. |
| 63 | + * |
| 64 | + * - `datetime`: a `Date` → its ISO text; a finite number, or a bare integer |
| 65 | + * string in either sign → epoch milliseconds; a bare `YYYY-MM-DD` → midnight |
| 66 | + * UTC, stated explicitly so it is never re-read in a server's local zone; a |
| 67 | + * zone-naive `YYYY-MM-DD[ T]HH:MM[:SS[.fff]]` → its wall clock read AS UTC |
| 68 | + * (the rule `CURRENT_TIMESTAMP`-written rows take on read, ADR-0074); any |
| 69 | + * other string → whatever `Date.parse` reads, re-spelled in UTC. `Z` |
| 70 | + * everywhere: `'…T12:00+08:00'` and `'…T04:00Z'` are one instant but sort |
| 71 | + * differently as text. |
| 72 | + * - `date`: a `Date` → its UTC calendar day (the UTC clock, never the host's — |
| 73 | + * `SqlDriver.toDateOnly` records why); a string → its leading `YYYY-MM-DD`. |
| 74 | + * - `time`: a bare `HH:MM[:SS[.f…]]` in range → `HH:MM:SS`, `.fff` kept only |
| 75 | + * when non-zero (fractions beyond milliseconds truncated); anything else is |
| 76 | + * read as an INSTANT by the `datetime` rule and keeps its UTC time of day. |
| 77 | + * |
| 78 | + * An array is NOT mapped — a caller comparing a list maps its members, the way |
| 79 | + * the drivers' `coerceFilterValue` / `coerceTemporalValue` do. |
| 80 | + */ |
| 81 | +export function temporalStorageForm(value: unknown, kind: TemporalComparandKind): unknown { |
| 82 | + if (kind === 'datetime') return canonicalUtcDatetime(value); |
| 83 | + if (kind === 'time') return canonicalTimeOfDay(value); |
| 84 | + return canonicalCalendarDay(value); |
| 85 | +} |
| 86 | + |
| 87 | +function canonicalUtcDatetime(value: unknown): unknown { |
| 88 | + if (value == null) return value; |
| 89 | + if (value instanceof Date) { |
| 90 | + return Number.isNaN(value.getTime()) ? value : value.toISOString(); |
| 91 | + } |
| 92 | + if (typeof value === 'number') { |
| 93 | + if (!Number.isFinite(value)) return value; |
| 94 | + const d = new Date(value); |
| 95 | + return Number.isNaN(d.getTime()) ? value : d.toISOString(); |
| 96 | + } |
| 97 | + if (typeof value !== 'string') return value; |
| 98 | + const s = value.trim(); |
| 99 | + if (s === '') return value; |
| 100 | + // A bare integer (in either JS or string form) is epoch milliseconds — the |
| 101 | + // shape better-sqlite3 wrote for every `Date` bound before the canon (#3912). |
| 102 | + if (/^-?\d+$/.test(s)) { |
| 103 | + const d = new Date(Number(s)); |
| 104 | + return Number.isNaN(d.getTime()) ? value : d.toISOString(); |
| 105 | + } |
| 106 | + const iso = /^\d{4}-\d{2}-\d{2}$/.test(s) |
| 107 | + ? `${s}T00:00:00.000Z` |
| 108 | + : /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s) |
| 109 | + ? `${s.replace(' ', 'T')}Z` |
| 110 | + : s; |
| 111 | + const ms = Date.parse(iso); |
| 112 | + return Number.isFinite(ms) ? new Date(ms).toISOString() : value; |
| 113 | +} |
| 114 | + |
| 115 | +function canonicalCalendarDay(value: unknown): unknown { |
| 116 | + if (value == null) return value; |
| 117 | + if (value instanceof Date) { |
| 118 | + if (Number.isNaN(value.getTime())) return value; |
| 119 | + const y = value.getUTCFullYear(); |
| 120 | + const m = String(value.getUTCMonth() + 1).padStart(2, '0'); |
| 121 | + const d = String(value.getUTCDate()).padStart(2, '0'); |
| 122 | + return `${y}-${m}-${d}`; |
| 123 | + } |
| 124 | + if (typeof value === 'string') { |
| 125 | + const trimmed = value.trim(); |
| 126 | + if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10); |
| 127 | + } |
| 128 | + return value; |
| 129 | +} |
| 130 | + |
| 131 | +function canonicalTimeOfDay(value: unknown): unknown { |
| 132 | + if (value == null) return value; |
| 133 | + if (typeof value === 'string') { |
| 134 | + const s = value.trim(); |
| 135 | + if (s === '') return value; |
| 136 | + const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s); |
| 137 | + if (m) { |
| 138 | + const [, hh, mm, ss = '00', frac] = m; |
| 139 | + if (Number(hh) > 23 || Number(mm) > 59 || Number(ss) > 59) return value; |
| 140 | + const ms = frac ? `${frac}000`.slice(0, 3) : '000'; |
| 141 | + return ms === '000' ? `${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}.${ms}`; |
| 142 | + } |
| 143 | + } |
| 144 | + // Not a bare wall clock — a `Date`, epoch ms, or a full / zone-naive |
| 145 | + // timestamp: an instant. Read it by the ONE instant rule, keep its UTC time. |
| 146 | + const instant = canonicalUtcDatetime(value); |
| 147 | + if (typeof instant === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(instant)) { |
| 148 | + const time = instant.slice(11, 23); |
| 149 | + return time.endsWith('.000') ? time.slice(0, 8) : time; |
| 150 | + } |
| 151 | + return value; |
| 152 | +} |
0 commit comments