Skip to content

Commit 05a7547

Browse files
fix(core,objectql)!: a datetime names a year from 1000 to 9999 at both engine doors; a date keeps 0001..9999 (#20280) (#20843)
Fixes #20280 Clause-②: no (narrowing) ## What this changes The `datetime` half of the card, by triage's answer A (comment 5905050200): **a `datetime` value names a year from 1000 to 9999**, and one before year 1000 is refused at both engine doors. A `date` keeps 0001..9999, and a `time` column's range does not move. - **One per-kind bound, in core's one range function.** `packages/core/src/utils/temporal-storage-form.ts`: `FIRST_SUPPORTED_YEAR` is now `{ date: 1, datetime: 1000 }`, and `isOutsideTemporalYearRange` asks `FIRST_SUPPORTED_YEAR[kind]`. No dialect switch: the range is the contract on every backend. The write door (the record validator) and the comparand door both reach it through `isUninterpretableTemporalComparand`, so neither door needed a second edit to the range. The `date` rule's four-digit padding reads the `date` bound (`FIRST_SUPPORTED_YEAR.date`), so it still pads 0001..0999. - **The refusal words.** `packages/objectql/src/temporal-comparand-door.ts`: the `datetime` year class names 1000 to 9999. A `datetime` in 0001..0999 gets its own consequence sentence, because it sorts and compares as the instant it names on every backend. The misorder sentence ("does not sort as an instant") would be false for it. The existing wire codes are unchanged: `INVALID_FILTER` / 400 for a comparand, and `VALIDATION_FAILED` with the field's `invalid_date` code for a written value. - **The `time` class keeps its meaning.** A `time` refusal's class ("an instant whose UTC year falls outside the years 0001 to 9999, so no time of day is read from it") used to ask the `datetime` range. It now asks the four-digit years explicitly, through core's `date` range over the instant the `datetime` rule reads. The corpus below shows every `time` answer byte-identical. - **Comments that stated 0001..9999 for a `datetime`** are corrected in `temporal-storage-form.ts`, `temporal-comparand.ts`, `datetime.ts`, `record-validator.ts`, and one comment-only line in `packages/spec/src/data/calendar-day.ts` (the spec lane's file; declared here and in the report). ## The published accept sets that narrow (per door, per spelling) Each one refuses a `datetime` whose UTC year falls in 0001..0999, and accepted it at the base: - **Write door** (`VALIDATION_FAILED` / `invalid_date`). Covers `engine.insert`, `engine.update` (one row and many) and the dry-run `engine.validate`, so also `POST /api/v1/data/:object`, `PATCH /api/v1/data/:object/:id` and each row of `POST /api/v1/data/:object/import`. Spellings: an ISO instant string, a bare `YYYY-MM-DD` (midnight UTC), a zone-naive `YYYY-MM-DD HH:MM`, and a `Date`. A number was already refused there, because a `datetime` is stored as text, so no number spelling narrows at this door. - **Comparand door** (`INVALID_FILTER` / 400). Covers `where`, a per-aggregation `filter` and `having`, on the engine and on `POST /api/v1/data/:object/query`. Spellings: a string, a `Date` and a number (epoch milliseconds). The analytics native-SQL strategy asks the same predicate (`service-analytics` `comparand-shape.ts`), so it declines such a comparand and the query reaches this door. That is read from source; its suite is green. - The year is the UTC year: `1000-01-01T00:00:00+08:00` (year 999 in UTC) is refused, and `0999-12-31T23:00:00-02:00` (year 1000 in UTC) is read. Nothing widens: across the corpus below, no answer moved from refused to accepted. ## Before and after, measured (scratch harness, not committed) Every door answer went through the public engine API on a recording driver: `find` (`where`), `aggregate` (a per-aggregation `filter`, and `having` on `min`), `validate`, `insert` and `update`. The fields were a `date`, a `datetime` and a `time`. There were 69 values: 12 years, from -1 and 0 up to 9999 and 10000, each as a string, a number and a `Date`, plus zone-crossing offsets, impossible days, non-ISO spellings, bare days, zone-naive stamps, bare-integer strings, placeholders and junk. That is 1242 answers. Base `4b45afaed5` against head `65728398b2` (the source commit; later commits are tests and the changeset): - `date` field: **0 of 414 answers moved.** `time` field: **0 of 414 moved.** - `datetime` field: **189 moved.** 126 are narrowings over 24 values in 0001..0999, in every spelling (108 accepted-to-refused, plus 18 `validate` verdicts from valid to invalid). 63 are refusals whose words moved: the range now reads 1000 to 9999. Six of those, in 0001..0999, were already refused for their spelling or day (`0500-02-30T10:00:00Z`, `0500/07/15 10:00`, a bare-integer string) and are now named by their year class first, as a year past 9999 already was. - 1000 and 9999, in every spelling, answer as before (accepted). ## Stored rows before the floor (ADR-0087: refused with guidance, never shifted) Nothing rewrites a stored `datetime` below 1000. It reads back as before; on MySQL a year in 0001..0099 still presents a century late (ADR-0053 D-F2; ruling 5859414357, C: no mysql2 parser change). An operator finds such rows with `$lt` on `1000-01-01T00:00:00.000Z`, a comparand both doors admit. A PATCH that leaves the field out is accepted. A write that carries a year below 1000 is refused, so the field is written again with an instant from year 1000 on, or `null`. `packages/rest/src/data-date-read-year-below-100.test.ts` pins exactly this at the REST door: the census, the refused comparand below the floor, a PATCH of another field on such a row, a refused PATCH that keeps the year, and an accepted PATCH onto 1000. The changeset carries one ADR-0087 marker, `not-required (no-migration-prescription)`: the change refuses a value, and no key, export or stored metadata shape moves. Its body has no FROM/TO table and no arrow. ## Tests All at head `a3afb404b6` (after merging `origin/main` `b28054654d`), process `TZ=America/New_York`: - `@objectstack/core` 60 files, 1729 passed · `@objectstack/objectql` 344 files, 6773 passed · `@objectstack/driver-memory` 65 files, 1470 passed · `@objectstack/service-analytics` 141 files, 3267 passed. - `@objectstack/driver-sql` 209 passed, 3 skipped files (4047 passed, 95 skipped), on SQLite and on a private PostgreSQL 16.13 (server zone Asia/Shanghai). `@objectstack/rest` 233 files, 4571 passed, 33 skipped, on SQLite and the same PostgreSQL. - **NOT MEASURED: live MySQL.** No MySQL server is available in this container, so every MySQL cell ran as a named skip. `Temporal Conformance (live PG + MySQL)` runs `driver-sql`'s year-range file there, including its new year-1000 row. - Typecheck, each exit 0: core, objectql, driver-memory, driver-sql, rest and spec, each with its `check:test-typecheck` test layer held. - **Ablation of the bound** (run at `d767c1ca81`; the merge did not touch the file, blob `17abe8b044` both sides): - `node scripts/ablation-replace.mjs` set `{ date: 1, datetime: 1000 }` back to `{ date: 1, datetime: 1 }` (anchor 1 to 0, blob `17abe8b044` to `7a7adacdb9`). Core was rebuilt, and `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.cjs` (exit 0, with `--source-marker`). - Result: core 3 failed of 109, objectql 7 failed of 69. Every red is a floor pin: the `datetime` 0001..0999 refusals on both doors, the `having` twin, and the year-0050 write control. - Restore under trap: blob equals HEAD, `git diff HEAD` empty, core rebuilt, preflight `--absent` exit 0 with a clean tree. Green again: 109 of 109 and 69 of 69. - Lint: `eslint --no-inline-config --format json` over the 19 changed TS files reported 19 files, 0 errors, 0 warnings. Population: `eslint.config.mjs` line 971, `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, covers all 19. Invariance: the config enables no type-aware linting (its lines 327-328), so this diff moves no untouched file's verdict. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` at `a3afb404b6` derived 89. All were run with recorded exit codes. `--ran` reports "89 derived, 87 run, 2 NOT-MEASURED, 0 UNRUN". The two NOT MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 (PREREQUISITE NOT MET: they read every package's `dist` and this worktree built only the touched closures). ## The pins, and each one's fate - **Flipped to refused**, with the 1000 edge added as the accepted control: - core `temporal-storage-form.test.ts` and `temporal-comparand.test.ts`; - objectql `engine-temporal-year-range.test.ts` (both doors, every position), `engine-aggregate-having-temporal-door.test.ts` (the year-1 control became a refusal row; year 1000 is read), `engine-temporal-write-real-day-iso.test.ts` (the `0050-01-01T10:00:00Z` write); - rest `data-temporal-year-range.test.ts`, `import-date-cell-iso-real-day.test.ts` (the `dt` cell `0001-01-01` is now refused per row by the write door behind the reader), `import-datetime-year-below-100.test.ts` (the reader layer is kept; the import door refuses each row below 1000 and stores 1000 and 2026; the round trip runs at 1000 and 2026), and `data-query-calendar-day-year-below-100.test.ts` (the whole-day bound is measured at `1000-01-01`; the card's `0050-01-01` bounds and row are pinned as refusals). - **Moved to `date`** as the control: the year-4 leap day in core, and `date` 0001..0999 in every file above (still accepted). - **Kept because they never pass a door:** driver-memory `memory-20264-temporal-year-range.test.ts` and driver-sql `sql-driver-20264-temporal-year-range.test.ts`. They write straight through the driver, and their `datetime` rows below 1000 are what a row stored before the floor holds. Headers are corrected, and a year-1000 edge row is added. Also kept: the #20599 / #20550 unit pins on parts, week keys and calendar-day helpers (core `datetime-year-below-100.test.ts`, service-analytics `week-key-year-below-100.test.ts`, spec `calendar-day.test.ts`), since no door is in their path. - `data-date-read-year-below-100.test.ts` (rest) now writes its `datetime` values below 1000 through the driver, as stored-before-floor rows, and pins the operator path above. ## Acceptance notes - **File surface.** Five REST test files went red and are rewritten above, plus one comment line in a sixth (count corrected by the seat after review 5910551203). REST's source is untouched. The dispatch's surface named pins in core, objectql, driver-memory and driver-sql; these are the same pins one door further out. - **The write door's words name the kind, not the range.** The ruling asks for a refusal "naming the kind and the range". The comparand door names both. The write door answers the existing `invalid_date` sentence ("must be a valid datetime (ISO-8601)"), as it did for the 0001..9999 range: naming the range there needs a new `validation-message.ts` key in `packages/spec`, outside this card's surface. Raised in the report. - **Placeholders are not judged by the door, by design.** A relative-date placeholder is stepped around ahead of its resolution, so `{1977_years_ago}` on a `datetime` still reaches the driver. It compares as the instant it names (year 49; measured, correct rows). The same path lets placeholders that land past 9999 or before year 1 through, with wrong answers. That class is older than this change; the measured case is in the report. - **Levels.** `minor` for `@objectstack/core` and `@objectstack/objectql`, whose source moves. REST and the analytics service narrow through them without a source change of their own, and the changeset config's one version group moves them in lockstep. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 820d3f4 commit 05a7547

20 files changed

Lines changed: 560 additions & 158 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
fix(core,objectql)!: a `datetime` value names a year from 1000 to 9999 at both engine doors, so one before year 1000 is refused as a written value and as a filter comparand; a `date` keeps 0001 to 9999
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a VALUE at the two engine doors: a datetime whose UTC year falls in 0001..0999, refused VALIDATION_FAILED (field code invalid_date) as a written value and INVALID_FILTER / 400 as a comparand. No authorable key, spelling, export or stored metadata shape moves: FieldSchema's datetime type, every object definition and every query shape parse as before, and @objectstack/core and @objectstack/objectql export nothing new and nothing less (isOutsideTemporalYearRange keeps its signature). A datetime already stored before year 1000 is record data, not metadata: nothing converts it, no conversion is registered, and it is never shifted; the body says how an operator finds such rows and that a write carrying one is refused, which is guidance about data, not a rewrite of any consumer's code or metadata. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers a value range and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
11+
12+
**BREAKING**: this narrows what the engine accepts as a `datetime`. The one range function both doors ask, `isOutsideTemporalYearRange` in `@objectstack/core`, now takes a lower bound per kind: a `datetime` starts at year 1000 (its UTC year), a `date` stays at 0001, and both still end at 9999. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
13+
14+
**What is refused now.** A `datetime` whose UTC year falls in 0001..0999, which both doors accepted before:
15+
16+
- **The write door** (the record validator), in every spelling it took: an ISO instant string, a bare `YYYY-MM-DD` (midnight UTC), a zone-naive `YYYY-MM-DD HH:MM`, and a `Date`. It answers `VALIDATION_FAILED` with the field's `invalid_date` code, on `engine.insert`, `engine.update` (one row or many) and the dry-run `engine.validate`, so on `POST /api/v1/data/:object`, `PATCH /api/v1/data/:object/:id` and each row of `POST /api/v1/data/:object/import` too. A number was already refused there, because a `datetime` is stored as text.
17+
- **The comparand door** (the temporal-comparand door), in every spelling: an ISO string, a `Date` and epoch milliseconds as a number. It answers `INVALID_FILTER` / 400 at `where`, at a per-aggregation `filter` and at `having`, on the engine and on `POST /api/v1/data/:object/query`. The analytics native-SQL strategy asks the same predicate, so it declines such a comparand and the query reaches this door.
18+
- The year is the instant's UTC year: `1000-01-01T00:00:00+08:00` is year 999 in UTC and is refused, and `0999-12-31T23:00:00-02:00` is year 1000 in UTC and is read.
19+
20+
**What an author sees.** The comparand refusal names the field, the value, its position and the range: "an instant whose UTC year falls outside the years 1000 to 9999, the years a datetime value may name". It then says why the floor sits at 1000, instead of the misorder words that a year past 9999 still gets: MySQL documents its `DATETIME` from year 1000 only, and reads one stored in the years 0001 to 0099 back a century late. A comparand in those years that was already refused for its spelling or its day (a non-ISO spelling, a day that does not exist) is now named by its year first, as one past 9999 already was. The write refusal is the existing `invalid_date` sentence for a `datetime` field.
21+
22+
**Why.** MySQL documents `DATETIME` from year 1000, and it reads a stored `DATETIME` in 0001..0099 back a century late through its client's instant parser (`0009-03-04 10:00` comes back as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps. The range is the contract on every backend, so SQLite, PostgreSQL and the in-memory driver, which held these years, refuse them too. No writer or query of a `datetime` before year 1000 was found.
23+
24+
**A `datetime` already stored before year 1000.** Nothing rewrites it, and nothing shifts it into the range. It reads back as before, and on MySQL a year in 0001..0099 still presents a century late. To find such rows, filter the field with `$lt` on `1000-01-01T00:00:00.000Z`, the floor's first instant, which both doors admit; the comparison runs on the stored value, so it finds them on MySQL as well. An update that leaves the field out is accepted. A write that carries a year below 1000 is refused, so the field can be written again with an instant from year 1000 on, or with `null`, and the author decides which.
25+
26+
**Unchanged.** A `date` keeps 0001..9999, padded to four digits as before. A `time` column still reads the time of day of an instant in 0001..0999, and a `time` comparand refused for another reason keeps that reason's words. Every year from 1000 to 9999 on a `datetime`, and every refusal outside 0001..9999 on either kind, answers as before, apart from the range the words name.

‎packages/core/src/utils/datetime.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export interface WallClockParts extends CalendarParts {
4646
*
4747
* Not `Date.UTC(year, …)` and not `new Date(year, …)`: both read a year from 0
4848
* to 99 as 1900 + year (ECMA-262 `MakeFullYear`), so `0050-01-01` is built as
49-
* 1950-01-01 — while 0001..9999 is the supported range (`temporalStorageForm`).
49+
* 1950-01-01 — while 0001..9999 is a `date`'s supported range ([#20280] a
50+
* `datetime`'s is 1000..9999; `isOutsideTemporalYearRange`).
5051
* `setUTCFullYear` takes the year as written.
5152
*
5253
* Every component rolls over past its end exactly as `Date.UTC` rolls it, and

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

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
// AND `datetime`: year 0 joins the refused years — PostgreSQL's `DATE` and
1616
// `timestamptz` have no year 0 — and a `datetime` number, `Date` or string
1717
// outside the range is refused as the `date` one is. `time` has no year.
18+
//
19+
// [#20280] A `datetime` starts at 1000 (triage's re-ruling on that card):
20+
// MySQL documents its `DATETIME` from year 1000 only, and reads one stored in
21+
// 0001..0099 back a century late. A `date` keeps 0001..9999, and a `time`
22+
// column still reads the time of day of an instant in 0001..0999.
1823

1924
import { describe, it, expect } from 'vitest';
2025
import { isUninterpretableTemporalComparand } from './temporal-comparand.js';
@@ -41,6 +46,13 @@ const PAST_THE_DATE_RANGE: ReadonlyArray<readonly [string, number]> = [
4146
['MAX_SAFE_INTEGER', Number.MAX_SAFE_INTEGER],
4247
];
4348

49+
/** [#20280] Finite numbers in 0001..0999: a `date` and a `time` read them, a `datetime` refuses them. */
50+
const BEFORE_THE_DATETIME_FLOOR: ReadonlyArray<readonly [string, number]> = [
51+
['the first millisecond of year 1', at('0001-01-01T00:00:00.000Z')],
52+
['0999-06-15', -30627504000000],
53+
['the last millisecond of year 999', at('0999-12-31T23:59:59.999Z')],
54+
];
55+
4456
/** Finite numbers whose UTC calendar day has a four-digit year — read as before. */
4557
const IN_RANGE: ReadonlyArray<readonly [string, number]> = [
4658
['[#20264] the first millisecond of year 1', at('0001-01-01T00:00:00.000Z')],
@@ -93,17 +105,23 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
93105
expect(isUninterpretableTemporalComparand('date', '0001-01-01')).toBe(false);
94106
});
95107

96-
it('[#20264] judges a datetime number or Date by the same years — the rule reads it, but its year is outside', () => {
97-
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE]) {
108+
it('[#20264] judges a datetime number or Date by its own years — the rule reads it, but its year is outside', () => {
109+
// [#20280] 0001..0999 joined the refused years on a `datetime`.
110+
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...BEFORE_THE_DATETIME_FLOOR]) {
98111
expect(isUninterpretableTemporalComparand('datetime', ms), `number, ${name}`).toBe(true);
99112
if (Number.isFinite(new Date(ms).getTime())) {
100113
expect(isUninterpretableTemporalComparand('datetime', new Date(ms)), `Date, ${name}`).toBe(true);
101114
}
102115
}
103-
for (const [name, ms] of IN_RANGE) {
116+
for (const [name, ms] of IN_RANGE.filter(([, v]) => new Date(v).getUTCFullYear() >= 1000)) {
104117
expect(isUninterpretableTemporalComparand('datetime', ms), `number, ${name}`).toBe(false);
105118
expect(isUninterpretableTemporalComparand('datetime', new Date(ms)), `Date, ${name}`).toBe(false);
106119
}
120+
// The control: a `date` column reads the same numbers and Dates.
121+
for (const [name, ms] of BEFORE_THE_DATETIME_FLOOR) {
122+
expect(isUninterpretableTemporalComparand('date', ms), `date number, ${name}`).toBe(false);
123+
expect(isUninterpretableTemporalComparand('date', new Date(ms)), `date Date, ${name}`).toBe(false);
124+
}
107125
});
108126

109127
// [#20480] A wall clock has no year, but a `time` column reads a number or a
@@ -115,10 +133,12 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
115133
// asserted all of them read before.
116134
it('[#20480] a time column reads the time of day of every instant with a four-digit UTC year, and refuses the rest', () => {
117135
const YEAR_0 = new Set(['[#20264] the first millisecond of year 0', '[#20264] the last millisecond of year 0']);
118-
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) {
136+
// [#20280] The `datetime` floor is not `time`'s: 0001..0999 keep their time of day.
137+
const READ = [...IN_RANGE, ...BEFORE_THE_DATETIME_FLOOR];
138+
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...READ]) {
119139
const spelled = temporalStorageForm(ms, 'time');
120140
const keeps = typeof spelled === 'string' && /^\d{2}:\d{2}:\d{2}(\.\d{3})?$/.test(spelled);
121-
const expected = !(IN_RANGE.some(([n]) => n === name) || YEAR_0.has(name));
141+
const expected = !(READ.some(([n]) => n === name) || YEAR_0.has(name));
122142
expect(keeps, `the rule keeps a time of day for ${name}`).toBe(!expected);
123143
expect(isUninterpretableTemporalComparand('time', ms), `number, ${name}`).toBe(expected);
124144
if (Number.isFinite(new Date(ms).getTime())) {
@@ -150,7 +170,7 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
150170
// answer is 0 / 7 / 0), and PostgreSQL answered 500 (`22009`, `22007`) for
151171
// them and for year 0 (`22008`). Each is now uninterpretable, beside the same
152172
// instant a millisecond inside the range.
153-
describe('[#20264] isUninterpretableTemporalComparand — a datetime string outside the years 0001 to 9999', () => {
173+
describe('[#20264] isUninterpretableTemporalComparand — a datetime string outside the years 1000 to 9999', () => {
154174
const REFUSED: ReadonlyArray<readonly [string, string]> = [
155175
['the extended ISO spelling of year 10000', '+010000-01-01T00:00:00.000Z'],
156176
['a bare extended day', '10000-01-01'],
@@ -161,12 +181,20 @@ describe('[#20264] isUninterpretableTemporalComparand — a datetime string outs
161181
['year 9999 in its zone, year 10000 in UTC', '9999-12-31T23:59:59-01:00'],
162182
['year 1 in its zone, year 0 in UTC', '0001-01-01T00:00:00+08:00'],
163183
['epoch milliseconds for year 10000, as a string', '253402300800000'],
184+
// [#20280] Before year 1000, MySQL's documented `DATETIME` floor — the
185+
// three #20264 read as inside, and the floor's own edges.
186+
['[#20280] the first instant of year 1', '0001-01-01T00:00:00.000Z'],
187+
['[#20280] year 1, a bare day', '0001-01-01'],
188+
['[#20280] year 0099', '0099-03-04T10:00:00.000Z'],
189+
['[#20280] the last instant of year 999', '0999-12-31T23:59:59.999Z'],
190+
['[#20280] year 999, zone-naive (read as UTC)', '0999-12-31 23:59'],
191+
['[#20280] year 1000 in its zone, 999 in UTC', '1000-01-01T00:00:00+08:00'],
164192
];
165193
const READ: ReadonlyArray<readonly [string, string]> = [
166-
['the first instant of year 1', '0001-01-01T00:00:00.000Z'],
194+
['the first instant of year 1000', '1000-01-01T00:00:00.000Z'],
195+
['year 1000, a bare day', '1000-01-01'],
196+
['year 999 in its zone, 1000 in UTC', '0999-12-31T23:00:00-02:00'],
167197
['the last instant of year 9999', '9999-12-31T23:59:59.999Z'],
168-
['year 1, a bare day', '0001-01-01'],
169-
['year 0099 — inside the range', '0099-03-04T10:00:00.000Z'],
170198
['a 2026 instant (the control)', '2026-02-01T10:00:00.000Z'],
171199
['a 2026 zone-naive timestamp (the control)', '2026-02-01 10:00'],
172200
// [#20549] The 2026 epoch-millisecond control is a NUMBER now: as a
@@ -180,6 +208,11 @@ describe('[#20264] isUninterpretableTemporalComparand — a datetime string outs
180208
expect(isUninterpretableTemporalComparand('date', value), value).toBe(true);
181209
}
182210
});
211+
it('[#20280] a date column still reads the years before 1000 — the control the datetime floor must not reach', () => {
212+
for (const value of ['0001-01-01', '0099-03-04', '0999-12-31', '0001-01-01T00:00:00.000Z', '0999-12-31T23:59:59.999Z']) {
213+
expect(isUninterpretableTemporalComparand('date', value), value).toBe(false);
214+
}
215+
});
183216
it('reads every instant inside the range, as before — the discriminating half', () => {
184217
for (const [name, value] of READ) {
185218
expect(isUninterpretableTemporalComparand('datetime', value), name).toBe(false);
@@ -216,11 +249,13 @@ describe('[#20549] isUninterpretableTemporalComparand — a real calendar day, a
216249
});
217250

218251
it('reads every real day beside them — the leap days and the month ends, the discriminating half', () => {
219-
for (const day of ['2028-02-29', '2000-02-29', '2026-02-28', '2026-04-30', '2026-12-31', '0004-02-29', '2026-01-01']) {
252+
for (const day of ['2028-02-29', '2000-02-29', '2026-02-28', '2026-04-30', '2026-12-31', '1004-02-29', '2026-01-01']) {
220253
expect(isUninterpretableTemporalComparand('date', day), `date ${day}`).toBe(false);
221254
expect(isUninterpretableTemporalComparand('datetime', day), `datetime ${day}`).toBe(false);
222255
expect(isUninterpretableTemporalComparand('datetime', `${day}T10:00:00Z`), `datetime ${day}T10:00:00Z`).toBe(false);
223256
}
257+
// [#20280] The year-4 leap day stays a `date` control: a `datetime` begins at 1000.
258+
expect(isUninterpretableTemporalComparand('date', '0004-02-29'), 'date 0004-02-29').toBe(false);
224259
});
225260

226261
it('refuses a datetime string outside the ISO spellings — each one Date.parse reads', () => {

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@
8989
* or a string. `datetime` spells an instant past 9999 `+010000-…` and one
9090
* before year 0 `-000001-…`, which sort as no instant does, and PostgreSQL
9191
* answers `22009` / `22007`; year 0 is refused on both kinds because
92-
* PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). The range is
92+
* PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). [#20280] A
93+
* `datetime` starts at 1000, MySQL's documented `DATETIME` floor, so its range
94+
* is 1000..9999; a `date` keeps 0001..9999. The range is
9395
* `temporal-storage-form.ts`'s `isOutsideTemporalYearRange`, the one the
9496
* record validator asks of a written value too; this predicate only calls it.
9597
*
@@ -111,14 +113,16 @@
111113
* - **Non-string comparands, save the year classes above.** A number is epoch
112114
* milliseconds, a `Date` is an instant, `null` is a null test, and the
113115
* `time` rule reads every finite one whose UTC year has four digits; so do
114-
* the `date` and `datetime` rules for a year from 0001 to 9999. The #8690 refusal was scoped to strings by that
116+
* the `date` rule for a year from 0001 to 9999 and [#20280] the `datetime`
117+
* rule for one from 1000 to 9999. The #8690 refusal was scoped to strings by that
115118
* card's own ruling — its triage queued "a non-interpretable bare string", and
116119
* the maintainer ruling scoped its two options "to non-empty strings" so the
117120
* empty-string cell stayed its own card. That scoped that change; it is not a
118121
* standing rule that a non-string is never refused. [#20240] extends the
119122
* refusal to the `date` class above by the triage direction on that card,
120-
* [#20264] to `datetime` and the range 0001..9999 by triage's ruling, and
121-
* [#20480] to a `time` column's instant outside the four-digit years.
123+
* [#20264] to `datetime` and the range 0001..9999 by triage's ruling,
124+
* [#20480] to a `time` column's instant outside the four-digit years, and
125+
* [#20280] to a `datetime` before year 1000 by triage's re-ruling.
122126
* `NaN`, ±Infinity and an Invalid Date name no instant and no year, so they
123127
* are not that class and stay unjudged, as before; no JSON body can carry
124128
* one (JSON spells them `null`).
@@ -270,8 +274,9 @@ function keepsTimeOfDay(value: unknown): boolean {
270274
* hand back unchanged, or [#20549] would read as another value than it names
271275
* (an impossible calendar day, a `datetime` spelling outside
272276
* {@link ISO_DATETIME_WRITE_FORM}), and — on a `date` or `datetime` column — for a number,
273-
* a `Date` or a string the rule reads whose year falls outside 0001..9999
274-
* ([#20264], `isOutsideTemporalYearRange`), and — on a `time` column — for a
277+
* a `Date` or a string the rule reads whose year falls outside the kind's
278+
* supported years — 0001..9999 for a `date`, [#20280] 1000..9999 for a
279+
* `datetime` ([#20264], `isOutsideTemporalYearRange`) — and — on a `time` column — for a
275280
* finite number, a valid `Date` or an instant string whose UTC year has no
276281
* four-digit spelling ([#20480]). Everything else — any other number
277282
* or `Date`, `null`, a `{ $field }` reference, filter structure, the empty

0 commit comments

Comments
 (0)