Skip to content

Commit cf33096

Browse files
committed
fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, at the comparand door and the write door (#20264)
WIP: the rule and its two doors; pins follow. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
1 parent b285508 commit cf33096

4 files changed

Lines changed: 206 additions & 92 deletions

File tree

‎packages/core/src/utils/temporal-comparand.ts‎

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,23 @@
5050
*
5151
* [#20240] One non-string class is uninterpretable by the same test's other
5252
* half — the rule cannot put it in the column's form. On a `date` column a
53-
* finite number or a `Date` becomes its UTC calendar day, and a year below 0 or
54-
* above 9999 has no `YYYY-MM-DD` spelling: the rule keeps writing
53+
* finite number or a `Date` becomes its UTC calendar day, and a year outside
54+
* the four-digit ones has no `YYYY-MM-DD` spelling: the rule keeps writing
5555
* `10000-01-01` / `-1-01-01` for the write and read paths, but as a comparand
5656
* that text orders as no day does (`10000-01-01` sorts below `2026-…`), and
5757
* PostgreSQL refuses `-1-01-01` outright. So such a comparand is judged
5858
* uninterpretable here, exactly as an unparseable string is, and refused by
5959
* the same doors.
6060
*
61+
* [#20264] That class is now the supported years, 0001..9999, on `date` AND
62+
* `datetime`, and in every spelling the kind's rule reads — a number, a `Date`
63+
* or a string. `datetime` spells an instant past 9999 `+010000-…` and one
64+
* before year 0 `-000001-…`, which sort as no instant does, and PostgreSQL
65+
* answers `22009` / `22007`; year 0 is refused on both kinds because
66+
* PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). The range is
67+
* `temporal-storage-form.ts`'s `isOutsideTemporalYearRange`, the one the
68+
* record validator asks of a written value too; this predicate only calls it.
69+
*
6170
* That is why this is not `utcInstantMs` (`@objectstack/spec/data`), which is
6271
* the stricter canonical reader: it rejects a bare epoch-millisecond string and
6372
* every non-ISO spelling `Date.parse` accepts, both of which the drivers read
@@ -66,15 +75,16 @@
6675
*
6776
* ## Two things it deliberately does NOT judge
6877
*
69-
* - **Non-string comparands, save the one `date` class above.** A number is
70-
* epoch milliseconds, a `Date` is an instant, `null` is a null test, and the
71-
* `datetime` and `time` rules read every finite one; so does the `date` rule
72-
* for a year from 0 to 9999. The #8690 refusal was scoped to strings by that
78+
* - **Non-string comparands, save the year class above.** A number is epoch
79+
* milliseconds, a `Date` is an instant, `null` is a null test, and the
80+
* `time` rule reads every finite one; so do the `date` and `datetime` rules
81+
* for a year from 0001 to 9999. The #8690 refusal was scoped to strings by that
7382
* card's own ruling — its triage queued "a non-interpretable bare string", and
7483
* the maintainer ruling scoped its two options "to non-empty strings" so the
7584
* empty-string cell stayed its own card. That scoped that change; it is not a
7685
* standing rule that a non-string is never refused. [#20240] extends the
77-
* refusal to the `date` class above by the triage direction on that card.
86+
* refusal to the `date` class above by the triage direction on that card,
87+
* and [#20264] to `datetime` and the range 0001..9999 by triage's ruling.
7888
* `NaN`, ±Infinity and an Invalid Date name no instant and no year, so they
7989
* are not that class and stay unjudged, as before; no JSON body can carry
8090
* one (JSON spells them `null`).
@@ -87,6 +97,7 @@
8797
*/
8898

8999
import { classifyFilterToken } from '@objectstack/spec/data';
100+
import { isOutsideTemporalYearRange } from './temporal-storage-form.js';
90101

91102
/** Which temporal storage rule a declared field takes. */
92103
export type TemporalComparandKind = 'datetime' | 'date' | 'time';
@@ -150,33 +161,14 @@ function readsAsWallClock(s: string): boolean {
150161
return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? '0') <= 59;
151162
}
152163

153-
/**
154-
* [#20240] `temporalStorageForm`'s `date` reading of a NUMBER or a `Date` lands
155-
* outside the four-digit years `YYYY-MM-DD` can spell.
156-
*
157-
* Both name an instant, and the rule takes that instant's UTC calendar day.
158-
* Its year must fall from 0 to 9999: `0999-06-15` is a day, `10000-01-01` and
159-
* `-1-01-01` are not. A finite number past ±8.64e15 names an instant the
160-
* `Date` type cannot hold at all — a year past ±271821 — so it is outside the
161-
* range too, and the rule hands it back unchanged. `NaN`, ±Infinity and an
162-
* Invalid Date name no instant and no year, and are not judged here.
163-
*/
164-
function isOutsideCalendarDayYears(value: number | Date): boolean {
165-
if (typeof value === 'number' && !Number.isFinite(value)) return false;
166-
const instant = typeof value === 'number' ? new Date(value) : value;
167-
if (Number.isNaN(instant.getTime())) return typeof value === 'number';
168-
const year = instant.getUTCFullYear();
169-
return year < 0 || year > 9999;
170-
}
171-
172164
/**
173165
* Is `value` a comparand that a `kind` column's storage rule cannot read?
174166
*
175167
* `true` for a non-empty, non-placeholder STRING that the kind's rule would
176-
* hand back unchanged, and — on a `date` column only — for a finite number or
177-
* a `Date` whose UTC calendar day falls in a year below 0 or above 9999
178-
* ({@link isOutsideCalendarDayYears}). Everything else — any other number or
179-
* `Date`, `null`, a `{ $field }` reference, filter structure, the empty
168+
* hand back unchanged, and — on a `date` or `datetime` column — for a number,
169+
* a `Date` or a string the rule reads whose year falls outside 0001..9999
170+
* ([#20264], `isOutsideTemporalYearRange`). Everything else — any other number
171+
* or `Date`, `null`, a `{ $field }` reference, filter structure, the empty
180172
* string, a `{token}` — answers `false`, each for a reason recorded in the
181173
* module note or below.
182174
*
@@ -191,16 +183,16 @@ export function isUninterpretableTemporalComparand(
191183
kind: TemporalComparandKind,
192184
value: unknown,
193185
): boolean {
194-
if (kind === 'date' && (typeof value === 'number' || value instanceof Date)) {
195-
return isOutsideCalendarDayYears(value);
186+
if (kind !== 'time' && (typeof value === 'number' || value instanceof Date)) {
187+
return isOutsideTemporalYearRange(value, kind);
196188
}
197189
if (typeof value !== 'string') return false;
198190
const s = value.trim();
199191
// The empty-string cell is its own card — see the module note.
200192
if (s === '') return false;
201193
// Another layer's vocabulary, and it has its own loud refusal.
202194
if (classifyFilterToken(value) !== null) return false;
203-
if (kind === 'datetime') return !readsAsInstant(s);
204-
if (kind === 'date') return !readsAsCalendarDay(s);
195+
if (kind === 'datetime') return !readsAsInstant(s) || isOutsideTemporalYearRange(s, kind);
196+
if (kind === 'date') return !readsAsCalendarDay(s) || isOutsideTemporalYearRange(s, kind);
205197
return !(readsAsWallClock(s) || readsAsInstant(s));
206198
}

‎packages/core/src/utils/temporal-storage-form.ts‎

Lines changed: 100 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,36 @@
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

6186
import 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

97122
function 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

125199
function 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

Comments
 (0)