4545 * the one rule both drivers read since #20176; before it, a copy in each).
4646 * That function is total on purpose: an input it cannot interpret is returned
4747 * UNCHANGED rather than becoming an invented instant. So, for a STRING, "the
48- * driver would return it unchanged" IS the definition of uninterpretable, and
49- * defining it any other way would refuse comparands that work today.
48+ * driver would return it unchanged" is the first half of the definition of
49+ * uninterpretable.
50+ *
51+ * [#20549] The second half is the write door's: a string is also
52+ * uninterpretable when the rule READS it, but not as the value it names, and
53+ * the record validator refuses the same string as a written value for that
54+ * reason. Two readings, both `Date.parse`'s:
55+ *
56+ * - **A calendar day that does not exist.** `Date.parse` rolls
57+ * `"2026-02-30T10:00:00Z"` over to March 2, so the comparand matched the
58+ * row stored on March 2; the `date` rule keeps `"2026-02-30"` as written and
59+ * compares it as text, and PostgreSQL refuses it (`22008`). Only a real
60+ * leading `YYYY-MM-DD` is read now ({@link namesRealCalendarDay}), for a
61+ * `date`, for the day part of a `datetime`, and for the day part of an
62+ * instant on a `time` column.
63+ * - **A `datetime` string outside the ISO 8601 spellings the platform writes**
64+ * ({@link ISO_DATETIME_WRITE_FORM}). `Date.parse` reads `"07/15/2026 10:00"`
65+ * and `"2026/07/15 10:00"` in the SERVER PROCESS's zone, so the rows a filter
66+ * matched were a property of the host; it reads `"07/08/2026"` month-first.
67+ * A bare integer string is not one of them either: the rule reads it as
68+ * epoch milliseconds, so `"2026"` meant two seconds after 1970 and matched
69+ * every later row. Epoch milliseconds stay a comparand as a NUMBER, which no
70+ * one can mistake for a year.
71+ *
72+ * Both halves together are the one rule for a string, and the record
73+ * validator's `date` / `datetime` arm asks this function rather than a copy of
74+ * it — so a string is refused as a comparand exactly when it is refused as a
75+ * written value.
5076 *
5177 * [#20240] One non-string class is uninterpretable by the same test's other
5278 * half — the rule cannot put it in the column's form. On a `date` column a
6793 * `temporal-storage-form.ts`'s `isOutsideTemporalYearRange`, the one the
6894 * record validator asks of a written value too; this predicate only calls it.
6995 *
70- * That is why this is not `utcInstantMs` (`@objectstack/spec/data`), which is
71- * the stricter canonical reader: it rejects a bare epoch-millisecond string and
72- * every non-ISO spelling `Date.parse` accepts, both of which the drivers read
73- * correctly today. Refusing those would be a narrowing this card did not ask
74- * for and no measurement supports.
75- *
7696 * ## Two things it deliberately does NOT judge
7797 *
7898 * - **Non-string comparands, save the year class above.** A number is epoch
@@ -118,15 +138,67 @@ export function temporalComparandKind(fieldType: unknown): TemporalComparandKind
118138}
119139
120140/**
121- * `temporalStorageForm`'s `datetime` reading, as a yes/no.
141+ * [#20525] [#20549] The ISO 8601 spellings a `datetime` STRING is read in,
142+ * after trimming — the ones the platform itself writes, each read the same on
143+ * every host by the `datetime` storage rule (`temporalStorageForm`):
144+ *
145+ * - `YYYY-MM-DD` — midnight UTC;
146+ * - `YYYY-MM-DDTHH:MM[:SS[.f…]]`, then `Z`, a `±HH:MM` / `±HHMM` offset, or
147+ * nothing — a zone-naive wall clock is read AS UTC (ADR-0074);
148+ * - `YYYY-MM-DD HH:MM[:SS[.f…]]`, zone-naive only — read AS UTC the same way.
149+ *
150+ * Every other spelling is uninterpretable, as a comparand and as a written
151+ * value alike (the record validator asks {@link isUninterpretableTemporalComparand}).
152+ * `Date.parse` reads `"2026/07/15 10:00"`, `"07/15/2026 10:00"` or
153+ * `"15 July 2026 10:00"` in the SERVER PROCESS's zone and `"07/08/2026"`
154+ * month-first, so the instant was a property of the deployment host; the rule
155+ * reads a bare integer string such as `"2026"` as epoch milliseconds
156+ * (`1970-01-01T00:00:02.026Z`), never as the year it looks like. A space
157+ * separator carries no zone because the rule hands such a string to
158+ * `Date.parse` whole, and its non-ISO reading moves
159+ * `"0050-01-01 10:00+01:00"` to 1950. `T` and `Z` are upper case: a zone-naive
160+ * `"…t10:00"` is not the form the rule reads as UTC.
161+ */
162+ const ISO_DATETIME_WRITE_FORM =
163+ / ^ \d { 4 } - \d { 2 } - \d { 2 } (?: T \d { 2 } : \d { 2 } (?: : \d { 2 } (?: \. \d + ) ? ) ? (?: Z | [ + - ] \d { 2 } : ? \d { 2 } ) ? | \d { 2 } : \d { 2 } (?: : \d { 2 } (?: \. \d + ) ? ) ? ) ? $ / ;
164+
165+ /**
166+ * [#20525] [#20549] Does the string's leading `YYYY-MM-DD` name a calendar day
167+ * that exists — month 01..12, day 01 to that month's length, February 29 only
168+ * in a leap year? Arithmetic, never a `Date` round trip: `Date.UTC` reads a
169+ * year 0..99 as 1900..1999, and `Date.parse` ROLLS an impossible day over
170+ * (`"2026-02-30"` is March 2), which is the defect this answers. A string with
171+ * no leading day answers `false`.
172+ */
173+ function namesRealCalendarDay ( s : string ) : boolean {
174+ const m = / ^ ( \d { 4 } ) - ( \d { 2 } ) - ( \d { 2 } ) / . exec ( s ) ;
175+ if ( ! m ) return false ;
176+ const year = Number ( m [ 1 ] ) ;
177+ const month = Number ( m [ 2 ] ) ;
178+ const day = Number ( m [ 3 ] ) ;
179+ if ( month < 1 || month > 12 || day < 1 ) return false ;
180+ const leap = year % 4 === 0 && ( year % 100 !== 0 || year % 400 === 0 ) ;
181+ const length = month === 2 ? ( leap ? 29 : 28 ) : month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31 ;
182+ return day <= length ;
183+ }
184+
185+ /**
186+ * `temporalStorageForm`'s `datetime` reading of a STRING, as a yes/no: one of
187+ * the {@link ISO_DATETIME_WRITE_FORM} spellings, on a calendar day that
188+ * exists, that the rule reads as an instant.
189+ *
190+ * The last step mirrors the rule: a bare `YYYY-MM-DD` is midnight UTC; a
191+ * zone-naive `YYYY-MM-DD[ T]HH:MM[:SS[.fff]]` has its wall clock read AS UTC;
192+ * anything else is handed to `Date.parse` exactly as the drivers hand it over
193+ * — which is what still refuses `T25:00` or a `+99:99` offset, both inside
194+ * the grammar's shape.
122195 *
123- * Mirrors it step for step: a bare integer in either sign is epoch
124- * milliseconds; a bare `YYYY-MM-DD` is midnight UTC; a zone-naive
125- * `YYYY-MM-DD[ T]HH:MM[:SS[.fff]]` has its wall clock read AS UTC; anything
126- * else is handed to `Date.parse` exactly as the drivers hand it over.
196+ * [#20549] Before the first two steps this read every spelling `Date.parse`
197+ * reads and every bare integer string, which is the comparand door the write
198+ * door was narrower than — see the module note.
127199 */
128200function readsAsInstant ( s : string ) : boolean {
129- if ( / ^ - ? \d + $ / . test ( s ) ) return Number . isFinite ( new Date ( Number ( s ) ) . getTime ( ) ) ;
201+ if ( ! ISO_DATETIME_WRITE_FORM . test ( s ) || ! namesRealCalendarDay ( s ) ) return false ;
130202 const iso = / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( s )
131203 ? `${ s } T00:00:00.000Z`
132204 : / ^ \d { 4 } - \d { 2 } - \d { 2 } [ T ] \d { 2 } : \d { 2 } ( : \d { 2 } ( \. \d + ) ? ) ? $ / . test ( s )
@@ -137,17 +209,19 @@ function readsAsInstant(s: string): boolean {
137209
138210/**
139211 * `temporalStorageForm`'s `date` reading of a STRING: its leading `YYYY-MM-DD`, and
140- * nothing else.
212+ * nothing else — [#20549] when that day exists .
141213 *
142214 * Narrower than {@link readsAsInstant} on purpose, because the rule it mirrors
143215 * is narrower: the `date` rule collapses a leading calendar day and returns
144216 * every other string untouched. `2026/07/15` is therefore uninterpretable for a
145217 * `date` column even though `Date.parse` reads it — today it survives to the
146218 * driver and compares as text against `YYYY-MM-DD` values, which is the silent
147- * wrong answer this door exists to stop.
219+ * wrong answer this door exists to stop. The rule keeps `"2026-02-30"` as
220+ * written, a day that does not exist: compared as text on memory and SQLite
221+ * (`200 []`), and a 500 on PostgreSQL.
148222 */
149223function readsAsCalendarDay ( s : string ) : boolean {
150- return / ^ \d { 4 } - \d { 2 } - \d { 2 } / . test ( s ) ;
224+ return namesRealCalendarDay ( s ) ;
151225}
152226
153227/**
@@ -165,7 +239,9 @@ function readsAsWallClock(s: string): boolean {
165239 * Is `value` a comparand that a `kind` column's storage rule cannot read?
166240 *
167241 * `true` for a non-empty, non-placeholder STRING that the kind's rule would
168- * hand back unchanged, and — on a `date` or `datetime` column — for a number,
242+ * hand back unchanged, or [#20549] would read as another value than it names
243+ * (an impossible calendar day, a `datetime` spelling outside
244+ * {@link ISO_DATETIME_WRITE_FORM}), and — on a `date` or `datetime` column — for a number,
169245 * a `Date` or a string the rule reads whose year falls outside 0001..9999
170246 * ([#20264], `isOutsideTemporalYearRange`). Everything else — any other number
171247 * or `Date`, `null`, a `{ $field }` reference, filter structure, the empty
0 commit comments