5151 * (`'25:00'`), an Invalid Date and unparseable junk come back UNCHANGED rather
5252 * than as an invented value — a value the rule cannot interpret is never
5353 * silently rewritten, so junk keeps failing its comparison. Which comparands
54- * are uninterpretable — a string this rule hands back unchanged, and [#20240] a
55- * number or `Date` on a `date` column whose UTC year falls outside 0..9999, the
56- * four-digit years a `YYYY-MM-DD` day can spell — is the question
54+ * are uninterpretable — a string this rule hands back unchanged, and a value
55+ * outside the supported years below — is the question
5756 * `isUninterpretableTemporalComparand` (`temporal-comparand.ts`) answers for
5857 * the doors that refuse them.
58+ *
59+ * ## [#20264] The supported years: 0001 to 9999, for `date` and `datetime`
60+ *
61+ * A `date` or `datetime` value names a year from 0001 to 9999, or it is
62+ * refused: `INVALID_FILTER` / 400 as a comparand, at the engine's
63+ * temporal-comparand door (`where`, a per-aggregation `filter`, `having`), and
64+ * `VALIDATION_FAILED` / 400 as a written value, at the record validator. Both
65+ * doors ask {@link isOutsideTemporalYearRange}, so there is one range.
66+ *
67+ * - Above 9999 the forms stop being fixed-width text: `toISOString()` spells
68+ * `+010000-01-01T00:00:00.000Z`, which sorts below every four-digit year, and
69+ * PostgreSQL refuses it.
70+ * - Below 0001 there is year 0 and before. PostgreSQL's `DATE` and
71+ * `timestamptz` have no year 0 (`0000-06-15` is `date/time field value out of
72+ * range`), and a negative year's spelling (`-000001-…`) orders as no instant
73+ * does.
74+ * - Every shipped backend holds 0001..9999 for a `date`. MySQL documents its
75+ * `DATETIME` from year 1000 only, and reads a stored `DATETIME` in years
76+ * 0001..0099 back a century late through its client's instant parser (ADR-0053
77+ * D-F2 keeps that parser): a known misread inside the range, not something
78+ * this rule decides.
79+ *
80+ * The rule itself stays total: a year outside the range keeps the spelling
81+ * `toISOString()` or the unpadded year gives it on the write and read paths
82+ * that call this function — no ordered form is invented — and the doors refuse
83+ * it before it gets there.
5984 */
6085
6186import type { TemporalComparandKind } from './temporal-comparand.js' ;
@@ -77,10 +102,10 @@ import type { TemporalComparandKind } from './temporal-comparand.js';
77102 * read as the `Date` of that value and so its UTC calendar day (a time of
78103 * day is dropped, never rounded); a string → its leading `YYYY-MM-DD`. The
79104 * year of a `Date` or a number is padded to four digits (`0999-06-15`); a
80- * year below 0 or above 9999 has no `YYYY-MM-DD` form, keeps its unpadded
81- * spelling (`10000-01-01`, `- 1-01-01`) for the write and read paths that
82- * call this rule, and is refused as a comparand by the temporal-comparand
83- * door .
105+ * year outside 0001.. 9999 has no `YYYY-MM-DD` form, keeps its unpadded
106+ * spelling (`10000-01-01`, `0-06-15`, `- 1-01-01`) for the write and read
107+ * paths that call this rule, and is refused by the doors in front of them
108+ * (see the module note's supported years) .
84109 * - `time`: a bare `HH:MM[:SS[.f…]]` in range → `HH:MM:SS`, `.fff` kept only
85110 * when non-zero (fractions beyond milliseconds truncated); anything else is
86111 * read as an INSTANT by the `datetime` rule and keeps its UTC time of day.
@@ -95,31 +120,80 @@ export function temporalStorageForm(value: unknown, kind: TemporalComparandKind)
95120}
96121
97122function canonicalUtcDatetime ( value : unknown ) : unknown {
98- if ( value == null ) return value ;
123+ const ms = instantMs ( value ) ;
124+ return ms === undefined ? value : new Date ( ms ) . toISOString ( ) ;
125+ }
126+
127+ /**
128+ * The instant the `datetime` rule reads `value` as, in epoch milliseconds, or
129+ * `undefined` when it reads none — the one reading {@link canonicalUtcDatetime}
130+ * spells and [#20264] {@link isOutsideTemporalYearRange} takes the year of.
131+ */
132+ function instantMs ( value : unknown ) : number | undefined {
133+ if ( value == null ) return undefined ;
99134 if ( value instanceof Date ) {
100- return Number . isNaN ( value . getTime ( ) ) ? value : value . toISOString ( ) ;
135+ const t = value . getTime ( ) ;
136+ return Number . isNaN ( t ) ? undefined : t ;
101137 }
102138 if ( typeof value === 'number' ) {
103- if ( ! Number . isFinite ( value ) ) return value ;
104- const d = new Date ( value ) ;
105- return Number . isNaN ( d . getTime ( ) ) ? value : d . toISOString ( ) ;
139+ if ( ! Number . isFinite ( value ) ) return undefined ;
140+ const t = new Date ( value ) . getTime ( ) ;
141+ return Number . isNaN ( t ) ? undefined : t ;
106142 }
107- if ( typeof value !== 'string' ) return value ;
143+ if ( typeof value !== 'string' ) return undefined ;
108144 const s = value . trim ( ) ;
109- if ( s === '' ) return value ;
145+ if ( s === '' ) return undefined ;
110146 // A bare integer (in either JS or string form) is epoch milliseconds — the
111147 // shape better-sqlite3 wrote for every `Date` bound before the canon (#3912).
112- if ( / ^ - ? \d + $ / . test ( s ) ) {
113- const d = new Date ( Number ( s ) ) ;
114- return Number . isNaN ( d . getTime ( ) ) ? value : d . toISOString ( ) ;
115- }
148+ if ( / ^ - ? \d + $ / . test ( s ) ) return instantMs ( Number ( s ) ) ;
116149 const iso = / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( s )
117150 ? `${ s } T00:00:00.000Z`
118151 : / ^ \d { 4 } - \d { 2 } - \d { 2 } [ T ] \d { 2 } : \d { 2 } ( : \d { 2 } ( \. \d + ) ? ) ? $ / . test ( s )
119152 ? `${ s . replace ( ' ' , 'T' ) } Z`
120153 : s ;
121154 const ms = Date . parse ( iso ) ;
122- return Number . isFinite ( ms ) ? new Date ( ms ) . toISOString ( ) : value ;
155+ return Number . isFinite ( ms ) ? ms : undefined ;
156+ }
157+
158+ /** [#20264] The first and the last year a `date` or `datetime` value may name. */
159+ const FIRST_SUPPORTED_YEAR = 1 ;
160+ const LAST_SUPPORTED_YEAR = 9999 ;
161+
162+ /**
163+ * [#20264] Does `value` name a year outside 0001..9999 for a column of `kind`?
164+ * The one range both doors ask — the temporal-comparand door on a comparand,
165+ * the record validator on a written value; see the module note.
166+ *
167+ * The year is the one the kind's rule reads:
168+ *
169+ * - `datetime`: the UTC year of the instant {@link canonicalUtcDatetime}
170+ * reads, so `9999-12-31T23:59:59-01:00` (year 10000 in UTC) is outside and
171+ * `0001-01-01T00:00:00+08:00` (year 0 in UTC) is outside too.
172+ * - `date`: a string's leading `YYYY-MM-DD` year; otherwise — a number, a
173+ * `Date`, or a string with no leading day that still names an instant, such
174+ * as `+010000-01-01T00:00:00.000Z` — the UTC year of that instant, whose UTC
175+ * calendar day the rule takes.
176+ * - `time`: never. A wall clock has no year.
177+ *
178+ * A finite number past ±8.64e15 names an instant the `Date` type cannot hold,
179+ * a year past ±271821, so it is outside. `null`, `NaN`, ±Infinity, an Invalid
180+ * Date and a string that names no instant name no year: `false`, and whether
181+ * such a value is refused is the caller's other question.
182+ */
183+ export function isOutsideTemporalYearRange ( value : unknown , kind : TemporalComparandKind ) : boolean {
184+ if ( kind === 'time' ) return false ;
185+ if ( typeof value === 'number' && Number . isFinite ( value ) && instantMs ( value ) === undefined ) return true ;
186+ let year : number | undefined ;
187+ if ( kind === 'date' && typeof value === 'string' ) {
188+ const day = / ^ ( \d { 4 } ) - \d { 2 } - \d { 2 } / . exec ( value . trim ( ) ) ;
189+ if ( day ) year = Number ( day [ 1 ] ) ;
190+ }
191+ if ( year === undefined ) {
192+ const ms = instantMs ( value ) ;
193+ if ( ms === undefined ) return false ;
194+ year = new Date ( ms ) . getUTCFullYear ( ) ;
195+ }
196+ return year < FIRST_SUPPORTED_YEAR || year > LAST_SUPPORTED_YEAR ;
123197}
124198
125199function canonicalCalendarDay ( value : unknown ) : unknown {
@@ -135,12 +209,14 @@ function canonicalCalendarDay(value: unknown): unknown {
135209 // [#20240] The year is padded to four digits, the width `YYYY-MM-DD`
136210 // declares and the ISO-string and bare-day arms below already produce:
137211 // `0999-06-15`, never `999-06-15`, which sorted above every padded day as
138- // text (`'9' > '0'`). A year below 0 or above 9999 has no `YYYY-MM-DD`
139- // form at all; it keeps the spelling it always had, and the
140- // temporal-comparand door refuses it as a comparand before it reaches a
141- // comparison (`isUninterpretableTemporalComparand`, `temporal-comparand.ts`).
212+ // text (`'9' > '0'`). [#20264] The padding covers 0001..0999, the padded
213+ // part of the supported years: a year outside 0001..9999 — year 0
214+ // included, which PostgreSQL's `DATE` does not have — has no `YYYY-MM-DD`
215+ // form here; it keeps its unpadded spelling, and the doors refuse it
216+ // before it reaches a comparison or a write
217+ // ({@link isOutsideTemporalYearRange}).
142218 const y = instant . getUTCFullYear ( ) ;
143- const yyyy = y >= 0 ? String ( y ) . padStart ( 4 , '0' ) : String ( y ) ;
219+ const yyyy = y >= FIRST_SUPPORTED_YEAR ? String ( y ) . padStart ( 4 , '0' ) : String ( y ) ;
144220 const m = String ( instant . getUTCMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
145221 const d = String ( instant . getUTCDate ( ) ) . padStart ( 2 , '0' ) ;
146222 return `${ yyyy } -${ m } -${ d } ` ;
0 commit comments