diff --git a/.changeset/20280-datetime-year-floor.md b/.changeset/20280-datetime-year-floor.md new file mode 100644 index 00000000000..05eb6e8ff24 --- /dev/null +++ b/.changeset/20280-datetime-year-floor.md @@ -0,0 +1,26 @@ +--- +'@objectstack/core': minor +'@objectstack/objectql': minor +--- + +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 + +Clause-②: no (narrowing) + + + +**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. + +**What is refused now.** A `datetime` whose UTC year falls in 0001..0999, which both doors accepted before: + +- **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. +- **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. +- 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. + +**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. + +**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. + +**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. + +**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. diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index fda5c68ba9b..caafa8f1615 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -46,7 +46,8 @@ export interface WallClockParts extends CalendarParts { * * Not `Date.UTC(year, …)` and not `new Date(year, …)`: both read a year from 0 * to 99 as 1900 + year (ECMA-262 `MakeFullYear`), so `0050-01-01` is built as - * 1950-01-01 — while 0001..9999 is the supported range (`temporalStorageForm`). + * 1950-01-01 — while 0001..9999 is a `date`'s supported range ([#20280] a + * `datetime`'s is 1000..9999; `isOutsideTemporalYearRange`). * `setUTCFullYear` takes the year as written. * * Every component rolls over past its end exactly as `Date.UTC` rolls it, and diff --git a/packages/core/src/utils/temporal-comparand.test.ts b/packages/core/src/utils/temporal-comparand.test.ts index 29a1154bceb..0f5acedd1f6 100644 --- a/packages/core/src/utils/temporal-comparand.test.ts +++ b/packages/core/src/utils/temporal-comparand.test.ts @@ -15,6 +15,11 @@ // AND `datetime`: year 0 joins the refused years — PostgreSQL's `DATE` and // `timestamptz` have no year 0 — and a `datetime` number, `Date` or string // outside the range is refused as the `date` one is. `time` has no year. +// +// [#20280] A `datetime` starts at 1000 (triage's re-ruling on that card): +// MySQL documents its `DATETIME` from year 1000 only, and reads one stored in +// 0001..0099 back a century late. A `date` keeps 0001..9999, and a `time` +// column still reads the time of day of an instant in 0001..0999. import { describe, it, expect } from 'vitest'; import { isUninterpretableTemporalComparand } from './temporal-comparand.js'; @@ -41,6 +46,13 @@ const PAST_THE_DATE_RANGE: ReadonlyArray = [ ['MAX_SAFE_INTEGER', Number.MAX_SAFE_INTEGER], ]; +/** [#20280] Finite numbers in 0001..0999: a `date` and a `time` read them, a `datetime` refuses them. */ +const BEFORE_THE_DATETIME_FLOOR: ReadonlyArray = [ + ['the first millisecond of year 1', at('0001-01-01T00:00:00.000Z')], + ['0999-06-15', -30627504000000], + ['the last millisecond of year 999', at('0999-12-31T23:59:59.999Z')], +]; + /** Finite numbers whose UTC calendar day has a four-digit year — read as before. */ const IN_RANGE: ReadonlyArray = [ ['[#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 expect(isUninterpretableTemporalComparand('date', '0001-01-01')).toBe(false); }); - it('[#20264] judges a datetime number or Date by the same years — the rule reads it, but its year is outside', () => { - for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE]) { + it('[#20264] judges a datetime number or Date by its own years — the rule reads it, but its year is outside', () => { + // [#20280] 0001..0999 joined the refused years on a `datetime`. + for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...BEFORE_THE_DATETIME_FLOOR]) { expect(isUninterpretableTemporalComparand('datetime', ms), `number, ${name}`).toBe(true); if (Number.isFinite(new Date(ms).getTime())) { expect(isUninterpretableTemporalComparand('datetime', new Date(ms)), `Date, ${name}`).toBe(true); } } - for (const [name, ms] of IN_RANGE) { + for (const [name, ms] of IN_RANGE.filter(([, v]) => new Date(v).getUTCFullYear() >= 1000)) { expect(isUninterpretableTemporalComparand('datetime', ms), `number, ${name}`).toBe(false); expect(isUninterpretableTemporalComparand('datetime', new Date(ms)), `Date, ${name}`).toBe(false); } + // The control: a `date` column reads the same numbers and Dates. + for (const [name, ms] of BEFORE_THE_DATETIME_FLOOR) { + expect(isUninterpretableTemporalComparand('date', ms), `date number, ${name}`).toBe(false); + expect(isUninterpretableTemporalComparand('date', new Date(ms)), `date Date, ${name}`).toBe(false); + } }); // [#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 // asserted all of them read before. it('[#20480] a time column reads the time of day of every instant with a four-digit UTC year, and refuses the rest', () => { const YEAR_0 = new Set(['[#20264] the first millisecond of year 0', '[#20264] the last millisecond of year 0']); - for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) { + // [#20280] The `datetime` floor is not `time`'s: 0001..0999 keep their time of day. + const READ = [...IN_RANGE, ...BEFORE_THE_DATETIME_FLOOR]; + for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...READ]) { const spelled = temporalStorageForm(ms, 'time'); const keeps = typeof spelled === 'string' && /^\d{2}:\d{2}:\d{2}(\.\d{3})?$/.test(spelled); - const expected = !(IN_RANGE.some(([n]) => n === name) || YEAR_0.has(name)); + const expected = !(READ.some(([n]) => n === name) || YEAR_0.has(name)); expect(keeps, `the rule keeps a time of day for ${name}`).toBe(!expected); expect(isUninterpretableTemporalComparand('time', ms), `number, ${name}`).toBe(expected); if (Number.isFinite(new Date(ms).getTime())) { @@ -150,7 +170,7 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe // answer is 0 / 7 / 0), and PostgreSQL answered 500 (`22009`, `22007`) for // them and for year 0 (`22008`). Each is now uninterpretable, beside the same // instant a millisecond inside the range. -describe('[#20264] isUninterpretableTemporalComparand — a datetime string outside the years 0001 to 9999', () => { +describe('[#20264] isUninterpretableTemporalComparand — a datetime string outside the years 1000 to 9999', () => { const REFUSED: ReadonlyArray = [ ['the extended ISO spelling of year 10000', '+010000-01-01T00:00:00.000Z'], ['a bare extended day', '10000-01-01'], @@ -161,12 +181,20 @@ describe('[#20264] isUninterpretableTemporalComparand — a datetime string outs ['year 9999 in its zone, year 10000 in UTC', '9999-12-31T23:59:59-01:00'], ['year 1 in its zone, year 0 in UTC', '0001-01-01T00:00:00+08:00'], ['epoch milliseconds for year 10000, as a string', '253402300800000'], + // [#20280] Before year 1000, MySQL's documented `DATETIME` floor — the + // three #20264 read as inside, and the floor's own edges. + ['[#20280] the first instant of year 1', '0001-01-01T00:00:00.000Z'], + ['[#20280] year 1, a bare day', '0001-01-01'], + ['[#20280] year 0099', '0099-03-04T10:00:00.000Z'], + ['[#20280] the last instant of year 999', '0999-12-31T23:59:59.999Z'], + ['[#20280] year 999, zone-naive (read as UTC)', '0999-12-31 23:59'], + ['[#20280] year 1000 in its zone, 999 in UTC', '1000-01-01T00:00:00+08:00'], ]; const READ: ReadonlyArray = [ - ['the first instant of year 1', '0001-01-01T00:00:00.000Z'], + ['the first instant of year 1000', '1000-01-01T00:00:00.000Z'], + ['year 1000, a bare day', '1000-01-01'], + ['year 999 in its zone, 1000 in UTC', '0999-12-31T23:00:00-02:00'], ['the last instant of year 9999', '9999-12-31T23:59:59.999Z'], - ['year 1, a bare day', '0001-01-01'], - ['year 0099 — inside the range', '0099-03-04T10:00:00.000Z'], ['a 2026 instant (the control)', '2026-02-01T10:00:00.000Z'], ['a 2026 zone-naive timestamp (the control)', '2026-02-01 10:00'], // [#20549] The 2026 epoch-millisecond control is a NUMBER now: as a @@ -180,6 +208,11 @@ describe('[#20264] isUninterpretableTemporalComparand — a datetime string outs expect(isUninterpretableTemporalComparand('date', value), value).toBe(true); } }); + it('[#20280] a date column still reads the years before 1000 — the control the datetime floor must not reach', () => { + 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']) { + expect(isUninterpretableTemporalComparand('date', value), value).toBe(false); + } + }); it('reads every instant inside the range, as before — the discriminating half', () => { for (const [name, value] of READ) { expect(isUninterpretableTemporalComparand('datetime', value), name).toBe(false); @@ -216,11 +249,13 @@ describe('[#20549] isUninterpretableTemporalComparand — a real calendar day, a }); it('reads every real day beside them — the leap days and the month ends, the discriminating half', () => { - 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']) { + 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']) { expect(isUninterpretableTemporalComparand('date', day), `date ${day}`).toBe(false); expect(isUninterpretableTemporalComparand('datetime', day), `datetime ${day}`).toBe(false); expect(isUninterpretableTemporalComparand('datetime', `${day}T10:00:00Z`), `datetime ${day}T10:00:00Z`).toBe(false); } + // [#20280] The year-4 leap day stays a `date` control: a `datetime` begins at 1000. + expect(isUninterpretableTemporalComparand('date', '0004-02-29'), 'date 0004-02-29').toBe(false); }); it('refuses a datetime string outside the ISO spellings — each one Date.parse reads', () => { diff --git a/packages/core/src/utils/temporal-comparand.ts b/packages/core/src/utils/temporal-comparand.ts index 0ef24fdcb40..d66544bdf25 100644 --- a/packages/core/src/utils/temporal-comparand.ts +++ b/packages/core/src/utils/temporal-comparand.ts @@ -89,7 +89,9 @@ * or a string. `datetime` spells an instant past 9999 `+010000-…` and one * before year 0 `-000001-…`, which sort as no instant does, and PostgreSQL * answers `22009` / `22007`; year 0 is refused on both kinds because - * PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). The range is + * PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). [#20280] A + * `datetime` starts at 1000, MySQL's documented `DATETIME` floor, so its range + * is 1000..9999; a `date` keeps 0001..9999. The range is * `temporal-storage-form.ts`'s `isOutsideTemporalYearRange`, the one the * record validator asks of a written value too; this predicate only calls it. * @@ -111,14 +113,16 @@ * - **Non-string comparands, save the year classes above.** A number is epoch * milliseconds, a `Date` is an instant, `null` is a null test, and the * `time` rule reads every finite one whose UTC year has four digits; so do - * the `date` and `datetime` rules for a year from 0001 to 9999. The #8690 refusal was scoped to strings by that + * the `date` rule for a year from 0001 to 9999 and [#20280] the `datetime` + * rule for one from 1000 to 9999. The #8690 refusal was scoped to strings by that * card's own ruling — its triage queued "a non-interpretable bare string", and * the maintainer ruling scoped its two options "to non-empty strings" so the * empty-string cell stayed its own card. That scoped that change; it is not a * standing rule that a non-string is never refused. [#20240] extends the * refusal to the `date` class above by the triage direction on that card, - * [#20264] to `datetime` and the range 0001..9999 by triage's ruling, and - * [#20480] to a `time` column's instant outside the four-digit years. + * [#20264] to `datetime` and the range 0001..9999 by triage's ruling, + * [#20480] to a `time` column's instant outside the four-digit years, and + * [#20280] to a `datetime` before year 1000 by triage's re-ruling. * `NaN`, ±Infinity and an Invalid Date name no instant and no year, so they * are not that class and stay unjudged, as before; no JSON body can carry * one (JSON spells them `null`). @@ -270,8 +274,9 @@ function keepsTimeOfDay(value: unknown): boolean { * hand back unchanged, or [#20549] would read as another value than it names * (an impossible calendar day, a `datetime` spelling outside * {@link ISO_DATETIME_WRITE_FORM}), and — on a `date` or `datetime` column — for a number, - * a `Date` or a string the rule reads whose year falls outside 0001..9999 - * ([#20264], `isOutsideTemporalYearRange`), and — on a `time` column — for a + * a `Date` or a string the rule reads whose year falls outside the kind's + * supported years — 0001..9999 for a `date`, [#20280] 1000..9999 for a + * `datetime` ([#20264], `isOutsideTemporalYearRange`) — and — on a `time` column — for a * finite number, a valid `Date` or an instant string whose UTC year has no * four-digit spelling ([#20480]). Everything else — any other number * or `Date`, `null`, a `{ $field }` reference, filter structure, the empty diff --git a/packages/core/src/utils/temporal-storage-form.test.ts b/packages/core/src/utils/temporal-storage-form.test.ts index d3992c91da5..6bcca4b5dca 100644 --- a/packages/core/src/utils/temporal-storage-form.test.ts +++ b/packages/core/src/utils/temporal-storage-form.test.ts @@ -189,11 +189,11 @@ describe('temporalStorageForm — total: what the rule cannot read comes back un }); }); -// [#20264] The supported years, 0001..9999, on `date` and `datetime`: the one -// range the temporal-comparand door (a comparand) and the record validator (a -// written value) both ask, so they cannot disagree about a year. The year is -// the one the kind's rule reads — a `date` string's leading day, otherwise the -// UTC year of the instant. +// [#20264] The supported years — a `date` 0001..9999, [#20280] a `datetime` +// 1000..9999 — the one range the temporal-comparand door (a comparand) and the +// record validator (a written value) both ask, so they cannot disagree about a +// year. The year is the one the kind's rule reads — a `date` string's leading +// day, otherwise the UTC year of the instant. describe('[#20264] isOutsideTemporalYearRange — the years a date or datetime value may name', () => { const at = (iso: string) => Date.parse(iso); const OUTSIDE: ReadonlyArray = [ @@ -208,22 +208,41 @@ describe('[#20264] isOutsideTemporalYearRange — the years a date or datetime v ['year 10000 in UTC, 9999 in its zone', '9999-12-31T23:59:59-01:00', 'datetime'], ['year 0 in UTC, 1 in its zone', '0001-01-01T00:00:00+08:00', 'datetime'], ['epoch milliseconds for year 10000, as a string', '253402300800000', 'datetime'], + // [#20280] A `datetime` before year 1000, MySQL's documented `DATETIME` + // floor — in every spelling — where #20264 read these as inside. + ['[#20280] the first instant of year 1, a number', at('0001-01-01T00:00:00.000Z'), 'datetime'], + ['[#20280] the first instant of year 1, a Date', new Date(at('0001-01-01T00:00:00.000Z')), 'datetime'], + ['[#20280] year 1, a bare day (midnight UTC)', '0001-01-01', 'datetime'], + ['[#20280] year 0099, an ISO instant', '0099-03-04T10:00:00.000Z', 'datetime'], + ['[#20280] the last instant of year 999, an ISO instant', '0999-12-31T23:59:59.999Z', 'datetime'], + ['[#20280] the last instant of year 999, a number', at('0999-12-31T23:59:59.999Z'), 'datetime'], + ['[#20280] year 999 in UTC, 1000 in its zone', '1000-01-01T00:00:00+08:00', 'datetime'], ]; const INSIDE: ReadonlyArray = [ - ['the first instant of year 1', at('0001-01-01T00:00:00.000Z'), 'both'], ['the last instant of year 9999', new Date(at('9999-12-31T23:59:59.999Z')), 'both'], - ['year 1, a bare day', '0001-01-01', 'both'], ['year 9999, a bare day', '9999-12-31', 'both'], - ['year 0099', '0099-03-04T10:00:00.000Z', 'both'], ['a 2026 instant (the control)', '2026-02-01T10:00:00.000Z', 'both'], ['a 2026 number (the control)', 1769940000000, 'both'], + // [#20280] The `datetime` floor's edge, in every spelling, on both kinds. + ['the first instant of year 1000, a number', at('1000-01-01T00:00:00.000Z'), 'both'], + ['the first instant of year 1000, a Date', new Date(at('1000-01-01T00:00:00.000Z')), 'both'], + ['the first instant of year 1000, an ISO instant', '1000-01-01T00:00:00.000Z', 'both'], + ['year 1000, a bare day', '1000-01-01', 'both'], + ['year 1000 in UTC, 999 in its zone', '0999-12-31T23:00:00-02:00', 'both'], + // A `date` keeps 0001..9999: the years before 1000 are the control the + // `datetime` floor must not reach. + ['the first instant of year 1', at('0001-01-01T00:00:00.000Z'), 'date'], + ['year 1, a bare day', '0001-01-01', 'date'], + ['year 0099', '0099-03-04T10:00:00.000Z', 'date'], + ['year 0999, a bare day', '0999-12-31', 'date'], // A `date` takes a string's leading day, whatever instant the rest names. ['year 9999 in its leading day, 10000 as an instant', '9999-12-31T23:59:59-01:00', 'date'], ['year 1 in its leading day, 0 as an instant', '0001-01-01T00:00:00+08:00', 'date'], + ['year 1000 in its leading day, 999 as an instant', '1000-01-01T00:00:00+08:00', 'date'], ]; const kindsOf = (k: 'date' | 'datetime' | 'both') => (k === 'both' ? (['date', 'datetime'] as const) : [k]); - it('is true for a year outside 0001..9999, in every spelling the kind reads', () => { + it('is true for a year outside the kind\'s years, in every spelling the kind reads', () => { for (const [name, value, kinds] of OUTSIDE) { for (const kind of kindsOf(kinds)) expect(isOutsideTemporalYearRange(value, kind), `${kind}, ${name}`).toBe(true); } diff --git a/packages/core/src/utils/temporal-storage-form.ts b/packages/core/src/utils/temporal-storage-form.ts index 7801894ecb6..17abe8b044a 100644 --- a/packages/core/src/utils/temporal-storage-form.ts +++ b/packages/core/src/utils/temporal-storage-form.ts @@ -56,13 +56,14 @@ * `isUninterpretableTemporalComparand` (`temporal-comparand.ts`) answers for * the doors that refuse them. * - * ## [#20264] The supported years: 0001 to 9999, for `date` and `datetime` + * ## [#20264] The supported years: a `date` 0001 to 9999, a `datetime` 1000 to 9999 * - * A `date` or `datetime` value names a year from 0001 to 9999, or it is - * refused: `INVALID_FILTER` / 400 as a comparand, at the engine's - * temporal-comparand door (`where`, a per-aggregation `filter`, `having`), and - * `VALIDATION_FAILED` / 400 as a written value, at the record validator. Both - * doors ask {@link isOutsideTemporalYearRange}, so there is one range. + * A `date` value names a year from 0001 to 9999, and [#20280] a `datetime` + * value a year from 1000 to 9999, or it is refused: `INVALID_FILTER` / 400 as + * a comparand, at the engine's temporal-comparand door (`where`, a + * per-aggregation `filter`, `having`), and `VALIDATION_FAILED` / 400 as a + * written value, at the record validator. Both doors ask + * {@link isOutsideTemporalYearRange}, so there is one range per kind. * * - Above 9999 the forms stop being fixed-width text: `toISOString()` spells * `+010000-01-01T00:00:00.000Z`, which sorts below every four-digit year, and @@ -71,11 +72,13 @@ * `timestamptz` have no year 0 (`0000-06-15` is `date/time field value out of * range`), and a negative year's spelling (`-000001-…`) orders as no instant * does. - * - Every shipped backend holds 0001..9999 for a `date`. MySQL documents its + * - Every shipped backend holds 0001..9999 for a `date`. [#20280] A `datetime` + * starts at 1000, MySQL's documented `DATETIME` floor: MySQL documents its * `DATETIME` from year 1000 only, and reads a stored `DATETIME` in years - * 0001..0099 back a century late through its client's instant parser (ADR-0053 - * D-F2 keeps that parser): a known misread inside the range, not something - * this rule decides. + * 0001..0099 back a century late through its client's instant parser, which + * ADR-0053 D-F2 keeps. The range is the contract on every backend, never a + * dialect's: a `datetime` before 1000 is refused on SQLite, PostgreSQL and + * the in-memory driver too, which would have held it. * * The rule itself stays total: a year outside the range keeps the spelling * `toISOString()` or the unpadded year gives it on the write and read paths @@ -155,12 +158,19 @@ function instantMs(value: unknown): number | undefined { return Number.isFinite(ms) ? ms : undefined; } -/** [#20264] The first and the last year a `date` or `datetime` value may name. */ -const FIRST_SUPPORTED_YEAR = 1; +/** + * [#20264] The first year a value of each kind may name — [#20280] per kind: + * a `date` from 0001, a `datetime` from 1000, MySQL's documented `DATETIME` + * floor (see the module note). The `date` entry is also the year the `date` + * rule pads to four digits from ({@link canonicalCalendarDay}). + */ +const FIRST_SUPPORTED_YEAR: Readonly> = { date: 1, datetime: 1000 }; +/** [#20264] The last year a `date` or `datetime` value may name. */ const LAST_SUPPORTED_YEAR = 9999; /** - * [#20264] Does `value` name a year outside 0001..9999 for a column of `kind`? + * [#20264] Does `value` name a year outside the supported years for a column + * of `kind` — 0001..9999 for a `date`, [#20280] 1000..9999 for a `datetime`? * The one range both doors ask — the temporal-comparand door on a comparand, * the record validator on a written value; see the module note. * @@ -168,7 +178,7 @@ const LAST_SUPPORTED_YEAR = 9999; * * - `datetime`: the UTC year of the instant {@link canonicalUtcDatetime} * reads, so `9999-12-31T23:59:59-01:00` (year 10000 in UTC) is outside and - * `0001-01-01T00:00:00+08:00` (year 0 in UTC) is outside too. + * [#20280] `1000-01-01T00:00:00+08:00` (year 999 in UTC) is outside too. * - `date`: a string's leading `YYYY-MM-DD` year; otherwise — a number, a * `Date`, or a string with no leading day that still names an instant, such * as `+010000-01-01T00:00:00.000Z` — the UTC year of that instant, whose UTC @@ -193,7 +203,7 @@ export function isOutsideTemporalYearRange(value: unknown, kind: TemporalCompara if (ms === undefined) return false; year = new Date(ms).getUTCFullYear(); } - return year < FIRST_SUPPORTED_YEAR || year > LAST_SUPPORTED_YEAR; + return year < FIRST_SUPPORTED_YEAR[kind] || year > LAST_SUPPORTED_YEAR; } function canonicalCalendarDay(value: unknown): unknown { @@ -210,13 +220,14 @@ function canonicalCalendarDay(value: unknown): unknown { // declares and the ISO-string and bare-day arms below already produce: // `0999-06-15`, never `999-06-15`, which sorted above every padded day as // text (`'9' > '0'`). [#20264] The padding covers 0001..0999, the padded - // part of the supported years: a year outside 0001..9999 — year 0 + // part of a `date`'s supported years: a year outside 0001..9999 — year 0 // included, which PostgreSQL's `DATE` does not have — has no `YYYY-MM-DD` // form here; it keeps its unpadded spelling, and the doors refuse it // before it reaches a comparison or a write - // ({@link isOutsideTemporalYearRange}). + // ({@link isOutsideTemporalYearRange}). [#20280] The `date` floor, never + // the `datetime` one: a calendar day in 0001..0999 is a supported `date`. const y = instant.getUTCFullYear(); - const yyyy = y >= FIRST_SUPPORTED_YEAR ? String(y).padStart(4, '0') : String(y); + const yyyy = y >= FIRST_SUPPORTED_YEAR.date ? String(y).padStart(4, '0') : String(y); const m = String(instant.getUTCMonth() + 1).padStart(2, '0'); const d = String(instant.getUTCDate()).padStart(2, '0'); return `${yyyy}-${m}-${d}`; diff --git a/packages/drivers/driver-memory/src/memory-20264-temporal-year-range.test.ts b/packages/drivers/driver-memory/src/memory-20264-temporal-year-range.test.ts index 49ea1f21d1b..b153d7793f8 100644 --- a/packages/drivers/driver-memory/src/memory-20264-temporal-year-range.test.ts +++ b/packages/drivers/driver-memory/src/memory-20264-temporal-year-range.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20264] The supported years of a `date` and a `datetime` are 0001..9999. - * The refusal outside them sits one layer up, at the engine's two doors (the + * [#20264] The supported years of a `date` are 0001..9999, and [#20280] of a + * `datetime` 1000..9999. The refusal outside them sits one layer up, at the engine's two doors (the * temporal-comparand door and the record validator, both asking * `@objectstack/core`'s `isOutsideTemporalYearRange`), so this driver's own * `where` and write paths are not a door and are not pinned as one. What this @@ -16,6 +16,13 @@ * REST create stored a `date` of `"+010000-01-01T00:00:00.000Z"` verbatim. * Both are refused at the engine now; the engine's own suite and REST's pin * those cells. + * + * [#20280] The `datetime` rows in 0001..0999 stay, on purpose: they are what a + * row stored before the `datetime` floor holds, written here straight through + * the driver, which no door fronts. The engine refuses such a value as a + * written value and as a comparand now; the driver still stores it and compares + * it as the instant it names, so an operator's `$lt` on year 1000 finds it. + * Year 1000 is the floor's edge row. */ import { beforeAll, describe, expect, it } from 'vitest'; @@ -30,13 +37,15 @@ const ROWS = [ { id: 'first', placed_on: '0001-01-01', opened_at: '0001-01-01T00:00:00.000Z' }, { id: 'y0099', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' }, { id: 'y0999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' }, + // [#20280] The `datetime` floor's edge. + { id: 'y1000', placed_on: '1000-01-01', opened_at: '1000-01-01T00:00:00.000Z' }, { id: 'c2026', placed_on: '2026-02-01', opened_at: '2026-02-01T10:00:00.000Z' }, { id: 'last', placed_on: '9999-12-31', opened_at: '9999-12-31T23:59:59.999Z' }, ]; -const ORDER = ['first', 'y0099', 'y0999', 'c2026', 'last']; +const ORDER = ['first', 'y0099', 'y0999', 'y1000', 'c2026', 'last']; -describe('[#20264] every year in 0001..9999 is stored and compared as the day or instant it names', () => { +describe('[#20264] every year in 0001..9999 is stored and compared by this driver as the day or instant it names', () => { let driver: InMemoryDriver; const ids = async (where: FilterCondition) => (await driver.find(OBJECT, { where })).map((r) => r.id as string).sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b)); @@ -53,7 +62,7 @@ describe('[#20264] every year in 0001..9999 is stored and compared as the day or } }); - it('reads each row back as written — the edges and the 2026 control', async () => { + it('reads each row back as written — the edges, a datetime stored before the floor, and the 2026 control', async () => { for (const row of ROWS) { expect(await driver.findOne(OBJECT, { where: { id: row.id } }), row.id).toMatchObject(row); } diff --git a/packages/drivers/driver-sql/src/sql-driver-20264-temporal-year-range.test.ts b/packages/drivers/driver-sql/src/sql-driver-20264-temporal-year-range.test.ts index 4698e923cd9..41286b81a69 100644 --- a/packages/drivers/driver-sql/src/sql-driver-20264-temporal-year-range.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-20264-temporal-year-range.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20264] The supported years of a `date` and a `datetime` are 0001..9999, on - * every dialect this driver speaks. The refusal outside them sits one layer up, + * [#20264] The supported years of a `date` are 0001..9999, and [#20280] of a + * `datetime` 1000..9999, on every dialect this driver speaks. The refusal + * outside them sits one layer up, * at the engine's two doors (the temporal-comparand door and the record * validator, both asking `@objectstack/core`'s `isOutsideTemporalYearRange`), so * this driver's `where` and write paths are not a door and are not pinned as @@ -23,10 +24,13 @@ * MySQL documents `DATETIME` from year 1000, and a `DATETIME` in years * 0001..0099 is stored right and read back a century late through mysql2's * instant parser (`0009-03-04 10:00` → `2004-09-03T10:00Z`), which ADR-0053 - * D-F2 keeps. Those years are inside the range, so the doors accept them; the - * MySQL cell asserts their STORED text, and the misread is a decision returned - * to the maintainer. From year 0100 up MySQL reads a `DATETIME` back as written, - * which the cell asserts. + * D-F2 keeps. [#20280] That is why a `datetime` begins at year 1000: the doors + * refuse one below it now, as a written value and as a comparand. The + * `datetime` rows below 1000 stay here on purpose — they are what a row stored + * before that floor holds, written straight through the driver, which no door + * fronts — and the MySQL cell asserts their STORED text. From year 0100 up + * MySQL reads a `DATETIME` back as written, which the cell asserts; year 1000 + * is the floor's edge row. * * ## [#20549] A leap day, and the ISO spellings, beside the range * @@ -54,12 +58,14 @@ const shape = (name: string) => ({ fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } }, }) as any; -/** In chronological order on both fields: the edges, three early years and a 2026 control. */ +/** In chronological order on both fields: the edges, four early years and a 2026 control. */ const ROWS = [ { id: 'first', placed_on: '0001-01-01', opened_at: '0001-01-01T00:00:00.000Z' }, { id: 'y0099', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' }, { id: 'y0100', placed_on: '0100-03-04', opened_at: '0100-03-04T10:00:00.000Z' }, { id: 'y0999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' }, + // [#20280] The `datetime` floor's edge. + { id: 'y1000', placed_on: '1000-01-01', opened_at: '1000-01-01T00:00:00.000Z' }, { id: 'c2026', placed_on: '2026-02-01', opened_at: '2026-02-01T10:00:00.000Z' }, // [#20549] The leap control: a February 29 that exists. { id: 'l2028', placed_on: '2028-02-29', opened_at: '2028-02-29T10:00:00.000Z' }, @@ -94,7 +100,7 @@ const readsBackAsWritten = (cell: DialectCell, row: (typeof ROWS)[number]) => !(cell.id === 'mysql' && Number(row.opened_at.slice(0, 4)) < 100); function measure(cell: DialectCell): void { - describe(`[#20264] the supported years 0001..9999 — ${cell.label}`, () => { + describe(`[#20264] the supported years, a date 0001..9999 and a datetime 1000..9999 — ${cell.label}`, () => { let driver: SqlDriver; const ids = async (where: FilterCondition) => (await driver.find(TABLE, { where }, NO_AUDIT)).map((r) => r.id as string) @@ -119,7 +125,7 @@ function measure(cell: DialectCell): void { } }); - it('a datetime in every year of the range is stored as that instant, and read back as written save the MySQL 0001..0099 cell', async () => { + it('a datetime in every year of the range, and one stored before its floor, is stored as that instant, and read back as written save the MySQL 0001..0099 cell', async () => { for (const row of ROWS) { const read = (await driver.findOne(TABLE, { where: { id: row.id } }, NO_AUDIT))?.opened_at; if (readsBackAsWritten(cell, row)) { diff --git a/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts b/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts index d1cc4a3e1df..ec0028980fa 100644 --- a/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts +++ b/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts @@ -200,7 +200,14 @@ describe('[#20263] having — a comparand its column cannot read is refused befo // base this `$lt` kept no group — the extended text sorts below every year. ['an extended-year ISO string on min(datetime), in the year class\'s words', () => ({ first_opened: { $lt: '+010000-01-01T00:00:00.000Z' } }), { opened_at: { $lt: '+010000-01-01T00:00:00.000Z' } }, - ["`having` on 'first_opened' (min(opened_at), a datetime column)", 'outside the years 0001 to 9999', 'keep the wrong groups']], + ["`having` on 'first_opened' (min(opened_at), a datetime column)", 'outside the years 1000 to 9999', 'keep the wrong groups']], + // [#20280] A datetime before year 1000, MySQL's documented `DATETIME` + // floor, is the year class now too, in its own words: it sorts as the + // instant it names, so the message claims no misorder. Kept every group at + // the base (the read control below said "inside the range"). + ['[#20280] the first instant of year 1 on min(datetime), before the datetime floor', + () => ({ first_opened: { $gt: '0001-01-01T00:00:00.000Z' } }), { opened_at: { $gt: '0001-01-01T00:00:00.000Z' } }, + ["`having` on 'first_opened' (min(opened_at), a datetime column)", 'outside the years 1000 to 9999', 'Before year 1000']], ['the number for year 0 on max(date) — [#20264] year 0 joins the refused years', () => ({ last_placed: { $gt: Date.parse('0000-06-15T00:00:00.000Z') } }), { placed_on: { $gt: Date.parse('0000-06-15T00:00:00.000Z') } }, ['outside the years 0001 to 9999']], @@ -305,6 +312,8 @@ describe('[#20263] having — one predicate: refused exactly when @objectstack/c const VALUES: readonly unknown[] = [ 'not-a-date', '2026-02-01', '2026-02-01T10:00:00.000Z', '2026-02-01 10:00', '+010000-01-01T00:00:00.000Z', '10:00', '10:00:00', '25:00', 'last_30_days', '1769940000000', '{today}', '{not_a_token}', '', ' ', + // [#20280] Read on a date and a time column, refused on a datetime one. + '0500-07-15T10:00:00.000Z', Date.parse('0500-07-15T10:00:00.000Z'), 1769940000000, Y10000, -62198755200000, new Date(1769940000000), new Date(Y10000), null, true, ]; for (const [kind, column, field] of COLUMNS) { @@ -337,8 +346,10 @@ describe('[#20263] having — what the door leaves alone answers exactly as befo ['a 2026 instant on min(datetime)', { first_opened: { $gt: '2026-02-01T00:00:00.000Z' } }, ['c2', 'c3', 'c4']], ['a bare day as the upper bound of min(datetime)', { first_opened: { $lte: '2026-02-01' } }, ['c1', 'c2']], // [#20264] An extended-year instant on min(datetime) is refused now (see - // the REFUSED table); the first instant of year 1 is read, as before. - ['the first instant of year 1 on min(datetime) — inside the range', { first_opened: { $gt: '0001-01-01T00:00:00.000Z' } }, ['c1', 'c2', 'c3', 'c4']], + // the REFUSED table); [#20280] so is the first instant of year 1, and the + // floor's edge, year 1000, is read — as is year 1 on a date column. + ['[#20280] the first instant of year 1000 on min(datetime) — the floor\'s edge', { first_opened: { $gt: '1000-01-01T00:00:00.000Z' } }, ['c1', 'c2', 'c3', 'c4']], + ['[#20280] year 1 on max(date) — a date keeps 0001..9999', { last_placed: { $gt: '0001-01-01' } }, ['c1', 'c2', 'c3', 'c4']], ['a wall clock on max(time)', { last_slot: { $gte: '12:00' } }, ['c2', 'c3', 'c4']], ['[#20480] the same wall clock as a 2026 instant on max(time) — the control', { last_slot: { $gte: '2026-01-01T12:00:00Z' } }, ['c2', 'c3', 'c4']], ['[#20480] the same wall clock as a 2026 epoch-ms number on max(time)', { last_slot: { $gte: Date.parse('2026-01-01T12:00:00Z') } }, ['c2', 'c3', 'c4']], diff --git a/packages/objectql/src/engine-temporal-write-real-day-iso.test.ts b/packages/objectql/src/engine-temporal-write-real-day-iso.test.ts index bc83c6b8594..99fe5daa60c 100644 --- a/packages/objectql/src/engine-temporal-write-real-day-iso.test.ts +++ b/packages/objectql/src/engine-temporal-write-real-day-iso.test.ts @@ -101,14 +101,25 @@ const ACCEPTED: ReadonlyArray = [ ['opened_at', '2026-07-15 10:00'], ['opened_at', '2026-07-15 10:00:00.5'], ['opened_at', ' 2026-07-15 10:00'], - ['opened_at', '0050-01-01T10:00:00Z'], + // [#20280] The `datetime` floor's edge; a `date` in year 0050 stays above. + ['opened_at', '1000-01-01T10:00:00Z'], ['opened_at', new Date(Date.UTC(2026, 6, 15, 10))], ]; +/** + * [#20280] Accepted here until a `datetime` began at year 1000 (MySQL's + * documented `DATETIME` floor): a real day in an ISO spelling, refused now for + * its year alone, with the same `invalid_date` code. + */ +const BEFORE_THE_DATETIME_FLOOR: ReadonlyArray = [ + ['opened_at', '0050-01-01T10:00:00Z'], +]; + const REFUSED: ReadonlyArray = [ ...IMPOSSIBLE_DAY, ...NON_ISO_DATETIME.map((v) => ['opened_at', v] as const), ...STILL_REFUSED, + ...BEFORE_THE_DATETIME_FLOOR, ]; /** A driver that records every read and write, and answers none. */ diff --git a/packages/objectql/src/engine-temporal-year-range.test.ts b/packages/objectql/src/engine-temporal-year-range.test.ts index 015af568cc1..0cfd909cc28 100644 --- a/packages/objectql/src/engine-temporal-year-range.test.ts +++ b/packages/objectql/src/engine-temporal-year-range.test.ts @@ -1,9 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20264] A `date` or `datetime` value names a year from 0001 to 9999, or it - * is refused — at both doors, by one range (`@objectstack/core`'s - * `isOutsideTemporalYearRange`): + * [#20264] A `date` value names a year from 0001 to 9999, and [#20280] a + * `datetime` value a year from 1000 to 9999, or it is refused — at both doors, + * by one range (`@objectstack/core`'s `isOutsideTemporalYearRange`): * * - the temporal-comparand door, `INVALID_FILTER` / 400, on `where`, a * per-aggregation `filter` and `having`, before any driver read; @@ -25,10 +25,18 @@ * Year 10000 in a `datetime` spells `+010000-…`, which sorts below every * four-digit year as text (the right answer for `$gt` / `$lt` / `$eq` was * 0 / 7 / 0); PostgreSQL has no year 0 in `DATE` or `timestamptz`. Every refusal - * sits beside its POSITIVE CONTROL: the range's edges (0001, 9999) and a 2026 - * value reach the driver. The drivers are not this file's subject — the doors - * sit in front of every one; the REST door over three real drivers is + * sits beside its POSITIVE CONTROL: the range's edges (0001 for a `date`, 1000 + * for a `datetime`, 9999 for both) and a 2026 value reach the driver. The + * drivers are not this file's subject — the doors sit in front of every one; + * the REST door over three real drivers is * `packages/rest/src/data-temporal-year-range.test.ts`. + * + * [#20280] A `datetime` in 0001..0999 was accepted by both doors at the base + * (`b285508188` onward) and compared as the instant it names; it is refused now + * in every spelling, because MySQL documents its `DATETIME` from year 1000 + * only and reads one stored in 0001..0099 back a century late. A `date` in + * 0001..0999 is the control: still accepted. So is a `time` comparand naming an + * instant in those years — a `time` column keeps its time of day. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -52,7 +60,7 @@ const Y10000 = at('+010000-01-01T00:00:00.000Z'); const YNEG1 = at('-000001-01-01T00:00:00.000Z'); const Y0 = at('0000-06-15T00:00:00.000Z'); -/** `datetime` comparands outside 0001..9999, every spelling the rule reads. */ +/** `datetime` comparands outside 1000..9999, every spelling the rule reads. */ const DATETIME_OUT: ReadonlyArray = [ ['year 10000, a number', Y10000], ['year 10000, a Date', new Date(Y10000)], @@ -65,6 +73,15 @@ const DATETIME_OUT: ReadonlyArray = [ ['year 0, its ISO string', '0000-06-15T00:00:00.000Z'], ['year 0, a bare day', '0000-06-15'], ['year 9999 in its zone, 10000 in UTC', '9999-12-31T23:59:59-01:00'], + // [#20280] Before the `datetime` floor, year 1000 — each read at the base. + ['[#20280] year 1, a number', at('0001-01-01T00:00:00.000Z')], + ['[#20280] year 1, its ISO string', '0001-01-01T00:00:00.000Z'], + ['[#20280] year 9, a Date', new Date(at('0009-03-04T10:00:00.000Z'))], + ['[#20280] year 99, its ISO string', '0099-03-04T10:00:00.000Z'], + ['[#20280] year 500, a bare day (midnight UTC)', '0500-07-15'], + ['[#20280] year 999, zone-naive (read as UTC)', '0999-12-31 23:59'], + ['[#20280] the last instant of year 999, a number', at('0999-12-31T23:59:59.999Z')], + ['[#20280] year 1000 in its zone, 999 in UTC', '1000-01-01T00:00:00+08:00'], ]; /** `date` comparands in year 0 — every spelling. */ @@ -77,11 +94,15 @@ const DATE_OUT: ReadonlyArray = [ /** The range's edges and a 2026 control — every one reaches the driver. */ const DATETIME_IN: readonly unknown[] = [ - at('0001-01-01T00:00:00.000Z'), '0001-01-01T00:00:00.000Z', new Date(at('9999-12-31T23:59:59.999Z')), + // [#20280] The floor's edge, in every spelling, where year 1 stood. + at('1000-01-01T00:00:00.000Z'), new Date(at('1000-01-01T00:00:00.000Z')), '1000-01-01T00:00:00.000Z', '1000-01-01', + '0999-12-31T23:00:00-02:00', new Date(at('9999-12-31T23:59:59.999Z')), '9999-12-31T23:59:59.999Z', 1769940000000, '2026-02-01T10:00:00.000Z', '2026-02-01', ]; const DATE_IN: readonly unknown[] = [ at('0001-01-01T00:00:00.000Z'), '0001-01-01', '9999-12-31', 1769940000000, '2026-02-01', + // [#20280] The control: a `date` keeps 0001..0999. + '0099-03-04', '0999-12-31', new Date(at('0500-07-15T00:00:00.000Z')), ]; /** A driver that records every read and write, and answers none. */ @@ -110,7 +131,7 @@ function makeRecordingDriver() { const refusalOf = async (p: Promise) => p.then(() => null, (e: any) => e as Error & { code?: string; status?: number; fields?: Array<{ field: string; code: string }> }); -describe('[#20264] the temporal-comparand door — a year outside 0001..9999 is refused on every position, before any read', () => { +describe('[#20264] the temporal-comparand door — a year outside the kind\'s years is refused on every position, before any read', () => { let engine: ObjectQL; let reads: unknown[]; @@ -177,7 +198,7 @@ describe('[#20264] the temporal-comparand door — a year outside 0001..9999 is expect(err, JSON.stringify(where)).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); } expect(reads).toHaveLength(0); - await expect(engine.find('ledger', { where: { opened_at: { $in: [inside, '0001-01-01T00:00:00.000Z'] } } })).resolves.toEqual([]); + await expect(engine.find('ledger', { where: { opened_at: { $in: [inside, '1000-01-01T00:00:00.000Z'] } } })).resolves.toEqual([]); expect(reads).toHaveLength(1); }); @@ -204,12 +225,56 @@ describe('[#20264] the temporal-comparand door — a year outside 0001..9999 is const err = await refusalOf(engine.find('ledger', { where: { opened_at: { $gt: Y10000 } } })); expect(err!.message).toContain("'opened_at'"); expect(err!.message).toContain('where.opened_at.$gt'); - expect(err!.message).toContain('0001 to 9999'); + expect(err!.message).toContain('1000 to 9999'); expect(err!.message).not.toContain('compare false for EVERY row'); + // A `date` keeps its own years in its words. + const day = await refusalOf(engine.find('ledger', { where: { placed_on: { $gt: Y10000 } } })); + expect(day!.message).toContain('0001 to 9999'); + }); + + // [#20280] A `datetime` in 0001..0999 sorts and compares as the instant it + // names on every backend, so its refusal claims no misorder: it names the + // floor and why it sits at 1000. Past 9999 the misorder words stay. + it('[#20280] a datetime before year 1000 is refused in its own words, on where and on having', async () => { + const where = await refusalOf(engine.find('ledger', { where: { opened_at: { $gt: '0500-07-15T10:00:00.000Z' } } })); + expect(where).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + expect(where!.message).toContain('outside the years 1000 to 9999'); + expect(where!.message).toContain('Before year 1000'); + expect(where!.message).not.toContain('does not sort'); + const having = await refusalOf(engine.aggregate('ledger', { + groupBy: ['customer_id'], + aggregations: [{ function: 'min', field: 'opened_at', alias: 'first' }], + having: { first: { $gt: new Date(at('0500-07-15T10:00:00.000Z')) } }, + } as never)); + expect(having).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + expect(having!.message).toContain('Before year 1000'); + expect(having!.message).not.toContain('does not sort'); + const past = await refusalOf(engine.find('ledger', { where: { opened_at: { $gt: '+010000-01-01T00:00:00.000Z' } } })); + expect(past!.message).toContain('does not sort as an instant'); + expect(past!.message).not.toContain('Before year 1000'); + expect(reads).toHaveLength(0); + }); + + // [#20280] The `datetime` floor is not `time`'s: a `time` column keeps the + // time of day of an instant in 0001..0999, and one it refuses for another + // reason is named by that reason, never as an instant with no four-digit year. + it('[#20280] a time field still reads an instant before year 1000, and names another refusal by its own class', async () => { + for (const comparand of ['0500-07-15T10:00:00.000Z', at('0009-03-04T10:00:00.000Z'), new Date(at('0999-12-31T23:59:59.999Z'))]) { + await expect(engine.find('ledger', { where: { opens_at: { $gt: comparand } } }), String(comparand)).resolves.toEqual([]); + } + expect(reads).toHaveLength(3); + const impossible = await refusalOf(engine.find('ledger', { where: { opens_at: { $gt: '0500-02-30T10:00:00Z' } } })); + expect(impossible).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + expect(impossible!.message).toContain('whose calendar day does not exist'); + expect(impossible!.message).not.toContain('no time of day is read'); + const notIso = await refusalOf(engine.find('ledger', { where: { opens_at: { $gt: '0500/07/15 10:00' } } })); + expect(notIso!.message).toContain('not one of the ISO 8601 spellings'); + expect(notIso!.message).not.toContain('no time of day is read'); + expect(reads, 'every refusal precedes the driver').toHaveLength(3); }); }); -describe('[#20264] the write door — a date or datetime value outside 0001..9999 is VALIDATION_FAILED, before any write', () => { +describe('[#20264] the write door — a date or datetime value outside its kind\'s years is VALIDATION_FAILED, before any write', () => { let engine: ObjectQL; let writes: unknown[]; @@ -235,6 +300,12 @@ describe('[#20264] the write door — a date or datetime value outside 0001..999 ['opened_at', '0000-06-15T10:00:00.000Z'], ['opened_at', '9999-12-31T23:59:59-01:00'], ['opened_at', new Date(YNEG1)], + // [#20280] A datetime before year 1000 — each a 201 here at the base. + ['opened_at', '0001-01-01T00:00:00.000Z'], + ['opened_at', '0099-03-04T10:00:00.000Z'], + ['opened_at', '0999-12-31T23:59:59.999Z'], + ['opened_at', '1000-01-01T00:00:00+08:00'], + ['opened_at', new Date(at('0500-07-15T10:00:00.000Z'))], ]; // The edges, and a 2026 control, on both kinds. const ACCEPTED: ReadonlyArray = [ @@ -242,9 +313,13 @@ describe('[#20264] the write door — a date or datetime value outside 0001..999 ['placed_on', '9999-12-31'], ['placed_on', '2026-02-01'], ['placed_on', new Date(at('0001-01-01T00:00:00.000Z'))], - ['opened_at', '0001-01-01T00:00:00.000Z'], + // [#20280] The control: a `date` keeps 0001..0999. + ['placed_on', '0099-03-04'], + ['placed_on', '0999-12-31'], + ['opened_at', '1000-01-01T00:00:00.000Z'], + ['opened_at', new Date(at('1000-01-01T00:00:00.000Z'))], + ['opened_at', '0999-12-31T23:00:00-02:00'], ['opened_at', '9999-12-31T23:59:59.999Z'], - ['opened_at', '0099-03-04T10:00:00.000Z'], ['opened_at', '2026-02-01T10:00:00.000Z'], ]; diff --git a/packages/objectql/src/temporal-comparand-door.ts b/packages/objectql/src/temporal-comparand-door.ts index e09c160ca08..2b6573f1ec6 100644 --- a/packages/objectql/src/temporal-comparand-door.ts +++ b/packages/objectql/src/temporal-comparand-door.ts @@ -104,19 +104,24 @@ * is read as before. The #8690 ruling scoped THAT change to strings; it did * not rule non-strings out of this door. * - * ## [#20264] The supported years, 0001..9999 - * - * A `date` or `datetime` comparand whose year falls outside 0001..9999 is - * refused here, in the year class's own words, whatever its spelling — a - * number, a `Date`, or a string the kind's rule reads (`+010000-01-01T…Z`, - * `-000001-…`, `0000-06-15`). Measured before this on InMemoryDriver and - * SqlDriver on SQLite and PostgreSQL 16: a `datetime` comparand for year 10000 - * or −1 counted `$gt` / `$lt` / `$eq` 7 / 0 / 0 on memory and SQLite (its - * extended-year text sorts below every four-digit year) and answered 500 on - * PostgreSQL; year 0 answered 500 on PostgreSQL, on both kinds, in every - * spelling. `@objectstack/core`'s `isOutsideTemporalYearRange` is the range; - * the predicate this door calls asks it, and so does the record validator's - * write door, so the two doors cannot disagree about a year. + * ## [#20264] The supported years: a `date` 0001..9999, a `datetime` 1000..9999 + * + * A `date` comparand whose year falls outside 0001..9999, and [#20280] a + * `datetime` comparand whose UTC year falls outside 1000..9999, is refused + * here, in the year class's own words, whatever its spelling — a number, a + * `Date`, or a string the kind's rule reads (`+010000-01-01T…Z`, `-000001-…`, + * `0000-06-15`, `0500-07-15T10:00:00Z` on a `datetime`). Measured before this + * on InMemoryDriver and SqlDriver on SQLite and PostgreSQL 16: a `datetime` + * comparand for year 10000 or −1 counted `$gt` / `$lt` / `$eq` 7 / 0 / 0 on + * memory and SQLite (its extended-year text sorts below every four-digit year) + * and answered 500 on PostgreSQL; year 0 answered 500 on PostgreSQL, on both + * kinds, in every spelling. A `datetime` in 0001..0999 compared as the instant + * it names on every backend; it is refused because 1000 is MySQL's documented + * `DATETIME` floor, and MySQL reads a stored `DATETIME` in 0001..0099 back a + * century late, so its words say that rather than a misorder. + * `@objectstack/core`'s `isOutsideTemporalYearRange` is the range; the + * predicate this door calls asks it, and so does the record validator's write + * door, so the two doors cannot disagree about a year. * * ## [#20549] A value the rule reads as another value than it names * @@ -189,6 +194,7 @@ import { isOutsideTemporalYearRange, isUninterpretableTemporalComparand, temporalComparandKind, + temporalStorageForm, type TemporalComparandKind, } from '@objectstack/core'; import { isTextFilterOperator } from '@objectstack/spec/data'; @@ -202,8 +208,9 @@ export interface UninterpretableTemporalComparand { kind: TemporalComparandKind; /** * A non-empty string — or, on a `date` or `datetime` field, a number or - * `Date` whose year falls outside 0001..9999 ([#20240], [#20264]), and on a - * `time` field one whose UTC year has no four-digit spelling ([#20480]). + * `Date` whose year falls outside the kind's supported years ([#20240], + * [#20264]; [#20280] 1000..9999 on a `datetime`), and on a `time` field one + * whose UTC year has no four-digit spelling ([#20480]). */ value: unknown; /** The `where.…` (or `having.…`, `aggregations[i].filter.…`) key path the offending comparand sits at. */ @@ -388,33 +395,90 @@ const REMEDY: Record = { }; /** - * [#20240] [#20264] The year class, per kind: what the comparand's year is, - * what it would do past this door, and the forms that carry a year the field - * can compare. Only `date` and `datetime` have a year; `time` is never in - * this class. + * [#20240] [#20264] A year class: what the comparand's year is, what it would + * do past this door (on `where` and on `having`), and the forms that carry a + * year the field can compare. */ -const YEAR_CLASS: Record<'date' | 'datetime', { year: string; misorder: string; remedy: string }> = { +interface YearClass { + year: string; + where: string; + having: string; + remedy: string; +} + +/** The two consequence sentences of a comparand whose text orders as no value of its kind does. */ +function misorders(misorder: string): Pick { + return { + where: `It would reach the driver in a form that ${misorder} and answer the wrong rows, or a database error.`, + having: `Compared with each group, it ${misorder} and would keep the wrong groups.`, + }; +} + +const DATETIME_YEARS = 'an instant whose UTC year falls outside the years 1000 to 9999'; +const DATETIME_REMEDY = 'Write an ISO-8601 instant, epoch milliseconds or a Date whose UTC year falls in the ' + + 'years 1000 to 9999.'; + +/** + * The year class, per kind. Only `date` and `datetime` have a year; `time` is + * never in this class. [#20280] A `datetime` names a year from 1000, so its + * range is 1000..9999; a `date` keeps 0001..9999. + */ +const YEAR_CLASS: Record<'date' | 'datetime', YearClass> = { date: { year: 'whose calendar day falls outside the years 0001 to 9999', - misorder: 'does not sort as a day', + ...misorders('does not sort as a day'), remedy: 'Write a "YYYY-MM-DD" calendar day in the years 0001 to 9999, or an epoch-millisecond ' + 'number or Date whose UTC calendar day falls in those years.', }, datetime: { - year: 'an instant whose UTC year falls outside the years 0001 to 9999', - misorder: 'does not sort as an instant', - remedy: 'Write an ISO-8601 instant, epoch milliseconds or a Date whose UTC year falls in the ' - + 'years 0001 to 9999.', + year: DATETIME_YEARS, + ...misorders('does not sort as an instant'), + remedy: DATETIME_REMEDY, }, }; +/** + * [#20280] A `datetime` in the years 0001..0999: outside its range, but a + * four-digit instant that sorts and compares as the instant it names on every + * backend. It is refused because 1000 is MySQL's documented `DATETIME` floor, + * and its words say so rather than claim a misorder it does not have. + */ +const BEFORE_YEAR_1000 = 'Before year 1000 a datetime is not held alike by every backend: MySQL documents ' + + 'its DATETIME from year 1000 only, and reads one stored in the years 0001 to 0099 back a century late.'; +const DATETIME_BEFORE_YEAR_1000: YearClass = { + year: DATETIME_YEARS, + where: BEFORE_YEAR_1000, + having: BEFORE_YEAR_1000, + remedy: DATETIME_REMEDY, +}; + +/** + * [#20280] Is `value` an instant whose UTC year falls outside 0001..9999 — the + * four-digit years — read the way the `datetime` rule reads it? For the + * message only: it tells a `datetime` below its 1000 floor from one past the + * four-digit years, and it is the [#20480] class of a `time` comparand, which + * that floor does not move (a `time` column keeps the time of day of an + * instant in 0001..0999). + * + * Core's range is asked, never re-derived: the `date` range IS 0001..9999, and + * it reads a number or a `Date` exactly as the `datetime` rule does (the UTC + * year of the instant). A string is read into that instant by the `datetime` + * rule first, because the `date` rule would read its leading day instead + * (`9999-12-31T23:00:00-02:00` names year 10000 in UTC). + */ +function isInstantOutsideFourDigitYears(value: unknown): boolean { + const instant = typeof value === 'string' ? Date.parse(String(temporalStorageForm(value, 'datetime'))) : value; + return isOutsideTemporalYearRange(instant, 'date'); +} + /** * [#20264] The year class of a hit, or `undefined` when the comparand is * refused for being unreadable at all — the range itself is core's * `isOutsideTemporalYearRange`, never re-derived here. */ -function yearClassOf(hit: UninterpretableTemporalComparand): (typeof YEAR_CLASS)['date'] | undefined { +function yearClassOf(hit: UninterpretableTemporalComparand): YearClass | undefined { if (hit.kind === 'time' || !isOutsideTemporalYearRange(hit.value, hit.kind)) return undefined; + if (hit.kind === 'datetime' && !isInstantOutsideFourDigitYears(hit.value)) return DATETIME_BEFORE_YEAR_1000; return YEAR_CLASS[hit.kind]; } @@ -471,7 +535,10 @@ const TIME_OUTSIDE_FOUR_DIGIT_YEARS: MisreadClass = { function misreadClassOf(hit: UninterpretableTemporalComparand): MisreadClass | undefined { // [#20480] The range is core's, asked of the instant the `datetime` rule // reads — the reading a `time` column takes of every non-wall-clock value. - if (hit.kind === 'time' && isOutsideTemporalYearRange(hit.value, 'datetime')) return TIME_OUTSIDE_FOUR_DIGIT_YEARS; + // [#20280] The four-digit years, never the `datetime` range: that range + // starts at 1000, and a `time` column keeps the time of day of an instant in + // 0001..0999, so such a comparand is refused, if at all, for another reason. + if (hit.kind === 'time' && isInstantOutsideFourDigitYears(hit.value)) return TIME_OUTSIDE_FOUR_DIGIT_YEARS; if (typeof hit.value !== 'string') return undefined; const s = hit.value.trim(); const day = /^\d{4}-\d{2}-\d{2}/.exec(s)?.[0]; @@ -506,18 +573,18 @@ export function assertTemporalComparandsInterpretable( ): void { const hit = findUninterpretableTemporalComparand(schema, where, path); if (!hit) return; - // [#20240] [#20264] A comparand whose year falls outside 0001..9999 gets - // words that say so. It does not compare false for every row as junk does: - // its text orders as no day or instant does, so it answers the WRONG rows - // (or, on PostgreSQL, a database error). + // [#20240] [#20264] A comparand whose year falls outside the kind's years + // gets words that say so. It does not compare false for every row as junk + // does: its text orders as no day or instant does, so it answers the WRONG + // rows (or, on PostgreSQL, a database error) — or [#20280], for a `datetime` + // in 0001..0999, it names a year MySQL's `DATETIME` does not document. const yearClass = yearClassOf(hit); if (yearClass) { throw invalidFilterError( `${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field ` + `against ${preview(hit.value)} at ${hit.path}, ${yearClass.year}, the years a ${hit.kind} ` - + `value may name, so it is not a ${hit.kind} value this platform can interpret. It ` - + `would reach the driver in a form that ${yearClass.misorder} and answer the wrong rows, or a ` - + `database error. The filter was NOT applied. ${yearClass.remedy}`, + + `value may name, so it is not a ${hit.kind} value this platform can interpret. ` + + `${yearClass.where} The filter was NOT applied. ${yearClass.remedy}`, ); } // [#20549] A comparand the rule reads as another value than it names. @@ -591,14 +658,13 @@ export function assertHavingTemporalComparandsInterpretable( if (!hit) return; const column = `\`having\` on '${hit.field}' (${havingColumnSource(hit.field, query.groupBy, query.aggregations)}, ` + `a ${hit.kind} column)`; - // The year class, in its own words, as on `where` (#20240, #20264). + // The year class, in its own words, as on `where` (#20240, #20264, #20280). const yearClass = yearClassOf(hit); if (yearClass) { throw invalidFilterError( `aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, ` + `${yearClass.year}, the years a ${hit.kind} value may name, so it is not a ${hit.kind} ` - + 'value this platform can interpret. Compared with each group, it ' - + `${yearClass.misorder} and would keep the wrong groups. The \`having\` was NOT applied. ` + + `value this platform can interpret. ${yearClass.having} The \`having\` was NOT applied. ` + yearClass.remedy, ); } diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index eb565fe32fe..6ce850ae006 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -54,7 +54,8 @@ * - format email / url / phone (lightweight RFC-aware regex) * - select / multiselect: value must appear in `options` * - boolean / toggle: must coerce to boolean - * - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999; + * - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999 + * for a `date` and from 1000 to 9999 for a `datetime` (#20280); * a `date` string also carries a leading `YYYY-MM-DD` (#20481); * a string's leading day exists, and a `datetime` string is * an ISO 8601 spelling (#20525) — refused, never rolled over @@ -1240,8 +1241,11 @@ function validateOne( // written value exactly when it is refused as a comparand. What that one // rule holds a `date` / `datetime` to: // - // - [#20264] a year from 0001 to 9999, whatever the spelling (a `Date` or a - // string; `isOutsideTemporalYearRange`). A date written as + // - [#20264] a year from 0001 to 9999 for a `date` and [#20280] from 1000 + // to 9999 for a `datetime`, MySQL's documented `DATETIME` floor, whatever + // the spelling (a `Date` or a string; `isOutsideTemporalYearRange`). A + // `datetime` stored below year 1000 before that floor is read as stored + // and is not rewritten; a write that carries one is refused. A date written as // `+010000-01-01T00:00:00.000Z` has no leading `YYYY-MM-DD`, so the // storage rule kept it verbatim (a stored non-day, 201 on memory and // SQLite) and PostgreSQL refused it with a 500; year 0 is a 500 on diff --git a/packages/rest/src/data-date-read-year-below-100.test.ts b/packages/rest/src/data-date-read-year-below-100.test.ts index 859f739af2e..96446029f6e 100644 --- a/packages/rest/src/data-date-read-year-below-100.test.ts +++ b/packages/rest/src/data-date-read-year-below-100.test.ts @@ -31,10 +31,16 @@ * `sql-driver-20280-mysql-date-read.test.ts` under `Temporal Conformance (live * PG + MySQL)`, and every door here hands the driver's value through. * - * `datetime` is outside the change: a MySQL `DATETIME` in years 0..99 still - * reads a century late, which waits on a decision about ADR-0053 D-F2 (the - * client parser keeps an instant's `Date`). Its MySQL cells are pinned as - * OBSERVED, so a decision that moves them has to move this file. + * `datetime` was outside that change: a MySQL `DATETIME` in years 0..99 still + * reads a century late (ADR-0053 D-F2 keeps the client parser's `Date` for an + * instant). Its MySQL cells are pinned as OBSERVED, so a decision that moves + * them has to move this file — and the card's second half did: a `datetime` + * begins at year 1000, MySQL's documented `DATETIME` floor, and both doors + * refuse one below it. The rows' `datetime` values below 1000 are therefore + * written straight through the driver, which no door fronts: they are what a + * row stored before that floor holds. Such a row still presents as before, is + * found by `$lt` on the floor's first instant (the operator's census), and + * takes a PATCH of another field or of that field onto the range. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -146,8 +152,13 @@ function measure(cell: Cell, config: Record): void { }; for (const row of ROWS) { - const res = await call('POST', '/api/v1/data/:object', { params: { object: OBJECT }, body: { ...row } }); + // [#20280] A datetime below 1000 is refused at the create door now; it + // is written the way a row stored before the floor holds it. + const beforeTheFloor = row.opened_at < '1000'; + const body = beforeTheFloor ? { id: row.id, placed_on: row.placed_on } : { ...row }; + const res = await call('POST', '/api/v1/data/:object', { params: { object: OBJECT }, body }); expect(res._status, JSON.stringify(res._json)).toBe(201); + if (beforeTheFloor) await driver.update(OBJECT, row.id, { opened_at: row.opened_at }); } }, 60_000); @@ -214,6 +225,43 @@ function measure(cell: Cell, config: Record): void { const q = await call('POST', '/api/v1/data/:object/query', { params: { object: OBJECT }, body: {} }); expect(byId(q._json.records, 'opened_at')).toEqual(expected); }); + + // [#20280] Last, because it edits two rows. The census and the remedy the + // changeset gives an operator for a datetime stored before the floor. + it('[#20280] a datetime below 1000 is refused at create; a row stored before the floor is found by $lt 1000 and takes a PATCH', async () => { + const created = await call('POST', '/api/v1/data/:object', { + params: { object: OBJECT }, body: { id: 'n1', placed_on: '2026-03-04', opened_at: '0500-01-01T00:00:00.000Z' }, + }); + expect(created._status, JSON.stringify(created._json)).toBe(400); + expect(created._json.fields.map((f: any) => [f.field, f.code])).toEqual([['opened_at', 'invalid_date']]); + // The census: the floor's first instant is a comparand the door admits. + const census = await call('POST', '/api/v1/data/:object/query', { + params: { object: OBJECT }, body: { where: { opened_at: { $lt: '1000-01-01T00:00:00.000Z' } } }, + }); + expect(census._status ?? 200, JSON.stringify(census._json)).toBe(200); + expect(census._json.records.map((r: any) => r.id).sort()).toEqual(['y9', 'y99', 'y999']); + // A comparand below the floor is refused, as every one is. + const below = await call('POST', '/api/v1/data/:object/query', { + params: { object: OBJECT }, body: { where: { opened_at: { $lt: '0500-01-01T00:00:00.000Z' } } }, + }); + expect(below._status, JSON.stringify(below._json)).toBe(400); + expect(below._json.code).toBe('INVALID_FILTER'); + // Such a row still takes a PATCH of another field — the stored value is not re-judged — + const other = await call('PATCH', '/api/v1/data/:object/:id', { + params: { object: OBJECT, id: 'y999' }, body: { placed_on: '0999-06-16' }, + }); + expect(other._status ?? 200, JSON.stringify(other._json)).toBe(200); + // — and a PATCH that moves the instant onto the range; one that keeps it below is refused. + const kept = await call('PATCH', '/api/v1/data/:object/:id', { + params: { object: OBJECT, id: 'y9' }, body: { opened_at: '0009-03-05T10:00:00.000Z' }, + }); + expect(kept._status, JSON.stringify(kept._json)).toBe(400); + const moved = await call('PATCH', '/api/v1/data/:object/:id', { + params: { object: OBJECT, id: 'y9' }, body: { opened_at: '1000-01-01T00:00:00.000Z' }, + }); + expect(moved._status ?? 200, JSON.stringify(moved._json)).toBe(200); + expect((await engine.findOne(OBJECT, { where: { id: 'y9' } }))?.opened_at).toBe('1000-01-01T00:00:00.000Z'); + }); }); } diff --git a/packages/rest/src/data-query-calendar-day-year-below-100.test.ts b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts index 1f77e20c9fc..014dd3d6f6b 100644 --- a/packages/rest/src/data-query-calendar-day-year-below-100.test.ts +++ b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts @@ -33,6 +33,17 @@ * `data-temporal-write-real-day-iso.test.ts` gives: `@objectstack/driver-memory` * has no binding in this package. The helper's own pins, every year edge and * the impossible days included, are `packages/spec/src/data/calendar-day.test.ts`. + * + * ## [#20280] A `datetime` begins at year 1000 + * + * MySQL documents its `DATETIME` from year 1000 only, and reads one stored in + * 0001..0099 back a century late, so a `datetime` in 0001..0999 is refused at + * the public door now, as a written value and as a comparand: the rows and the + * bounds of the table above are a 400 there. The whole-day reading is pinned + * at the floor's first day instead (`1000-01-01`), beside the 2026 control, + * and the card's bounds on `0050-01-01` are pinned as the refusal they are + * now. The helper's two-digit-year pins stay in `calendar-day.test.ts`, where + * a `date`'s years 0001..0099 still reach it. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -53,24 +64,34 @@ const LEDGER = { }, }; -/** id · the instant written through the door. */ +/** + * id · the instant written through the door. [#20280] The card's rows sat in + * year 0050; a `datetime` there is refused now, so the day measured is the + * floor's first, `1000-01-01`, with the day before it out of reach. + */ const ROWS: ReadonlyArray = [ - ['y49', '0049-12-31T10:00:00.000Z'], - ['y50', '0050-01-01T10:00:00.000Z'], // the card's row - ['y50_last', '0050-01-01T23:59:59.999Z'], // the day's last millisecond - ['y50_next', '0050-01-02T00:00:00.000Z'], // the next day's first instant: out + ['y1000', '1000-01-01T10:00:00.000Z'], + ['y1000_last', '1000-01-01T23:59:59.999Z'], // the day's last millisecond + ['y1000_next', '1000-01-02T00:00:00.000Z'], // the next day's first instant: out ['c26', '2026-07-15T14:00:00.000Z'], // the card's control row ['c26_next', '2026-07-16T00:00:00.000Z'], ]; /** `where` on `opened_at` · the ids it answers, sorted. */ const QUERIES: ReadonlyArray, readonly string[]]> = [ - ["$lte '0050-01-01'", { $lte: '0050-01-01' }, ['y49', 'y50', 'y50_last']], - ["$between max '0050-01-01'", { $between: ['0050-01-01', '0050-01-01'] }, ['y50', 'y50_last']], - ["$lte '2026-07-15' (control)", { $lte: '2026-07-15' }, ['c26', 'y49', 'y50', 'y50_last', 'y50_next']], + ["$lte '1000-01-01'", { $lte: '1000-01-01' }, ['y1000', 'y1000_last']], + ["$between max '1000-01-01'", { $between: ['1000-01-01', '1000-01-01'] }, ['y1000', 'y1000_last']], + ["$lte '2026-07-15' (control)", { $lte: '2026-07-15' }, ['c26', 'y1000', 'y1000_last', 'y1000_next']], ["$between max '2026-07-15' (control)", { $between: ['2026-07-15', '2026-07-15'] }, ['c26']], ]; +/** [#20280] The card's bounds, and its row as a written value: refused now — a `datetime` begins at 1000. */ +const REFUSED_BOUNDS: ReadonlyArray]> = [ + ["$lte '0050-01-01'", { $lte: '0050-01-01' }], + ["$between max '0050-01-01'", { $between: ['0050-01-01', '0050-01-01'] }], + ["$lte '0999-12-31'", { $lte: '0999-12-31' }], +]; + interface Cell { id: 'sqlite' | 'pg'; label: string; @@ -108,7 +129,7 @@ const originalTz = process.env.TZ; for (const cell of CELLS) { const config = cell.config(); describe.skipIf(!config)( - `[#20550] a bare-day upper bound on a datetime includes the whole day in years 0001..0099, at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + `[#20550] a bare-day upper bound on a datetime includes the whole day, at the floor and in 2026, and [#20280] a bound in 0001..0999 is refused, at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, () => { let engine: ObjectQL; let driver: any; @@ -165,12 +186,23 @@ for (const cell of CELLS) { expect(stored).toEqual(Object.fromEntries(ROWS)); }); - it('$lte and the $between maximum on 0050-01-01 include that whole day, and not the next midnight — as the 2026 control does', async () => { + it('$lte and the $between maximum on 1000-01-01 include that whole day, and not the next midnight — as the 2026 control does', async () => { // Every reading first, then one comparison, so a red run shows all four cells. const got: Record = {}; for (const [name, bound] of QUERIES) got[name] = await idsWhere(bound); expect(got).toEqual(Object.fromEntries(QUERIES.map(([name, , want]) => [name, want]))); }); + + it('[#20280] a bound in 0001..0999 is 400 INVALID_FILTER, and the card\'s row is 400 VALIDATION_FAILED — a datetime begins at 1000', async () => { + for (const [name, bound] of REFUSED_BOUNDS) { + const res = await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { opened_at: bound } }); + expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body.code, name).toBe('INVALID_FILTER'); + } + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 'y50', memo: 'm', opened_at: '0050-01-01T10:00:00.000Z' }); + expect(created.status, JSON.stringify(created.body)).toBe(400); + expect(created.body.fields.map((x: any) => [x.field, x.code])).toEqual([['opened_at', 'invalid_date']]); + }); }, ); } diff --git a/packages/rest/src/data-query-date-year-range.test.ts b/packages/rest/src/data-query-date-year-range.test.ts index 03108b2919b..6dd199f5de5 100644 --- a/packages/rest/src/data-query-date-year-range.test.ts +++ b/packages/rest/src/data-query-date-year-range.test.ts @@ -200,8 +200,9 @@ describe('[#20240] a year outside the four-digit years — the number and the Da } expect(reads.n, 'no read of the object — the refusal precedes the driver').toBe(0); - // [#20264] The same numbers on a `datetime` field are refused too — the - // supported years 0001..9999 hold for both kinds. + // [#20264] The same numbers on a `datetime` field are refused too — they + // fall outside both kinds' years (a `date` 0001..9999, [#20280] a + // `datetime` 1000..9999). for (const comparand of [ms, new Date(ms)]) { const err = await refusalOf(engine.find(OBJECT, { where: { opened_at: { $gt: comparand } } })); expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); diff --git a/packages/rest/src/data-temporal-year-range.test.ts b/packages/rest/src/data-temporal-year-range.test.ts index 9284f2ec811..22b51886daa 100644 --- a/packages/rest/src/data-temporal-year-range.test.ts +++ b/packages/rest/src/data-temporal-year-range.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20264] A `date` or `datetime` value names a year from 0001 to 9999, or it - * is refused at the public door — `POST /api/v1/data/:object/query` answers + * [#20264] A `date` value names a year from 0001 to 9999, and [#20280] a + * `datetime` value a year from 1000 to 9999, or it is refused at the public door — `POST /api/v1/data/:object/query` answers * `400 INVALID_FILTER` for a comparand (`where`, a per-aggregation `filter`, * `having`), and `POST /api/v1/data/:object` / `PATCH …/:id` answer * `400 VALIDATION_FAILED` / `invalid_date` for a written value — over a real @@ -32,11 +32,13 @@ * dialect in `sql-driver-20264-temporal-year-range.test.ts`). Each live cell * owns one table, dropped before and after. * - * One cell is not asserted on read-back: a MySQL `DATETIME` in years - * 0001..0099 is stored right and read back a century late through mysql2's - * instant parser, which ADR-0053 D-F2 keeps. That year is inside the range, so - * the write door accepts it; the MySQL cell below checks its STORED text, and - * the misread is a decision returned to the maintainer, not a verdict here. + * [#20280] A MySQL `DATETIME` in years 0001..0099 is stored right and read + * back a century late through mysql2's instant parser, which ADR-0053 D-F2 + * keeps, and MySQL documents its `DATETIME` from year 1000 only. So a + * `datetime` begins at 1000: a comparand or a written value in 0001..0999 — + * each read or written at the base on every cell — is refused here now, beside + * the floor's edge (1000) and a `date` in those years (the control, still read + * and written). No `datetime` below 1000 reaches a cell's read-back any more. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -104,15 +106,21 @@ const REFUSED: ReadonlyArray = [ ['date, year 0, a number', 'placed_on', at('0000-06-15T00:00:00.000Z')], ['date, year 0, its ISO string', 'placed_on', '0000-06-15T00:00:00.000Z'], ['date, year 0, a bare day', 'placed_on', '0000-06-15'], + // [#20280] A datetime before year 1000, in every spelling — each read at the base. + ['datetime, the first instant of year 1, its ISO string', 'opened_at', '0001-01-01T00:00:00.000Z'], + ['datetime, year 99, a number', 'opened_at', at('0099-03-04T10:00:00.000Z')], + ['datetime, year 999, a bare day (midnight UTC)', 'opened_at', '0999-12-31'], + ['datetime, 1000 in its zone, 999 in UTC', 'opened_at', '1000-01-01T00:00:00+08:00'], ]; /** field · comparand · `where` counts for `$gt` / `$lt` / `$eq` — the edges and the 2026 control */ const READ: ReadonlyArray = [ - ['datetime, the first instant of year 1', 'opened_at', '0001-01-01T00:00:00.000Z', [7, 0, 0]], + ['datetime, the first instant of year 1000 (the floor)', 'opened_at', '1000-01-01T00:00:00.000Z', [7, 0, 0]], ['datetime, the last instant of year 9999, a number', 'opened_at', at('9999-12-31T23:59:59.999Z'), [0, 7, 0]], ['datetime, 2026-02-01T10:00Z (control)', 'opened_at', '2026-02-01T10:00:00.000Z', [4, 2, 1]], ['datetime, 2026-02-01T10:00Z as a number (control)', 'opened_at', at('2026-02-01T10:00:00.000Z'), [4, 2, 1]], ['date, 0001-01-01', 'placed_on', '0001-01-01', [7, 0, 0]], + ['date, 0999-12-31 (a date keeps the years before 1000)', 'placed_on', '0999-12-31', [7, 0, 0]], ['date, 9999-12-31', 'placed_on', '9999-12-31', [0, 7, 0]], ['date, 2026-02-01 (control)', 'placed_on', '2026-02-01', [2, 3, 2]], ]; @@ -125,6 +133,10 @@ const WRITE_REFUSED: ReadonlyArray = [ ['opened_at', '+010000-01-01T00:00:00.000Z'], ['opened_at', '0000-06-15T10:00:00.000Z'], ['opened_at', '9999-12-31T23:59:59-01:00'], + // [#20280] Each a 201 at the base. + ['opened_at', '0001-01-01T00:00:00.000Z'], + ['opened_at', '0100-03-04T10:00:00.000Z'], + ['opened_at', '0999-12-31T23:59:59.999Z'], ]; /** field · written value · what reads back — the edges and the 2026 control */ @@ -132,9 +144,9 @@ const WRITE_ACCEPTED: ReadonlyArray = [ ['placed_on', '0001-01-01', '0001-01-01'], ['placed_on', '9999-12-31', '9999-12-31'], ['placed_on', '2026-02-01', '2026-02-01'], - ['opened_at', '0001-01-01T00:00:00.000Z', '0001-01-01T00:00:00.000Z'], + ['placed_on', '0999-12-31', '0999-12-31'], + ['opened_at', '1000-01-01T00:00:00.000Z', '1000-01-01T00:00:00.000Z'], ['opened_at', '9999-12-31T23:59:59.999Z', '9999-12-31T23:59:59.999Z'], - ['opened_at', '0100-03-04T10:00:00.000Z', '0100-03-04T10:00:00.000Z'], ['opened_at', '2026-02-01T10:00:00.000Z', '2026-02-01T10:00:00.000Z'], ]; @@ -165,7 +177,7 @@ const grouped = (field: string, having: FilterCondition): EngineAggregateOptions for (const cell of CELLS) { const config = cell.config(); describe.skipIf(!config)( - `[#20264] the supported years 0001..9999 at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + `[#20264] the supported years, a date 0001..9999 and a datetime 1000..9999, at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, () => { let engine: ObjectQL; let driver: any; @@ -214,7 +226,7 @@ for (const cell of CELLS) { try { await engine?.destroy(); } catch { /* noop */ } }); - it('a comparand outside 0001..9999 is 400 INVALID_FILTER at where, the per-aggregation filter and having — no read', async () => { + it('a comparand outside its kind\'s years is 400 INVALID_FILTER at where, the per-aggregation filter and having — no read', async () => { const before = reads.n; for (const [name, field, comparand] of REFUSED) { for (const op of ['$gt', '$lt', '$eq'] as const) { @@ -249,7 +261,7 @@ for (const cell of CELLS) { } }); - it('a written value outside 0001..9999 is 400 VALIDATION_FAILED / invalid_date on create and on PATCH — nothing written', async () => { + it('a written value outside its kind\'s years is 400 VALIDATION_FAILED / invalid_date on create and on PATCH — nothing written', async () => { const before = writes.n; for (const [field, value] of WRITE_REFUSED) { const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 'refused', customer_id: 'cw', [field]: value }); @@ -271,12 +283,6 @@ for (const cell of CELLS) { const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id, customer_id: 'cw', [field]: value }); expect(created.status, `create ${field} ${value}: ${JSON.stringify(created.body)}`).toBe(201); const got = (await query({ where: { id } })).body.records[0]?.[field]; - // The one MySQL cell the module note sets aside: stored right, read a century late. - if (cell.id === 'mysql' && field === 'opened_at' && Number(value.slice(0, 4)) < 100) { - const [rows] = await driver.execute(`select cast(opened_at as char) as t from ${OBJECT} where id = ?`, [id]); - expect(rows[0].t, `stored ${field} ${value}`).toBe(`${value.slice(0, 10)} ${value.slice(11, 23)}`); - continue; - } expect(got, `read back ${field} ${value}`).toBe(readBack); } }); diff --git a/packages/rest/src/import-date-cell-iso-real-day.test.ts b/packages/rest/src/import-date-cell-iso-real-day.test.ts index 1cee021d483..85aad3bec34 100644 --- a/packages/rest/src/import-date-cell-iso-real-day.test.ts +++ b/packages/rest/src/import-date-cell-iso-real-day.test.ts @@ -37,6 +37,11 @@ * * The reader's own case table is `import-coerce.test.ts`'s `[#20534]` block; * this file pins the door. + * + * [#20280] The `datetime` cell `0001-01-01` in the table above is read right + * by the reader and is refused now by the write door behind it: a `datetime` + * begins at year 1000 (MySQL's documented `DATETIME` floor). The floor's own + * day is the admitted `datetime` control; a `date` keeps 0001..0999. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -74,6 +79,8 @@ const REFUSED: ReadonlyArray = [ ['t', '07/15/2026 10:00'], ['dt', '2026-07-15 24:00'], ['d', '2026/2/30'], + // [#20280] Read right, and refused by the write door: a datetime begins at 1000. + ['dt', '0001-01-01'], ]; /** [#20722] The write door's field code for a refused cell: `invalid_time` for a `time`. */ @@ -83,7 +90,7 @@ const codeOf = (field: Field) => (field === 't' ? 'invalid_time' : 'invalid_date const ADMITTED: ReadonlyArray = [ ['d', '0500-01-01', '0500-01-01'], ['d', '0001-01-01', '0001-01-01'], - ['dt', '0001-01-01', '0001-01-01T00:00:00.000Z'], + ['dt', '1000-01-01', '1000-01-01T00:00:00.000Z'], ['d', '2026-07-15', '2026-07-15'], ['dt', '2026-07-15T10:00:00Z', '2026-07-15T10:00:00.000Z'], ['dt', '2026-07-15T10:00:00+08:00', '2026-07-15T02:00:00.000Z'], diff --git a/packages/rest/src/import-datetime-year-below-100.test.ts b/packages/rest/src/import-datetime-year-below-100.test.ts index 468e0fff3d5..2fb67778c73 100644 --- a/packages/rest/src/import-datetime-year-below-100.test.ts +++ b/packages/rest/src/import-datetime-year-below-100.test.ts @@ -29,6 +29,18 @@ * changes nothing. Before 1901 the tz database gives Asia/Shanghai its local * mean time, +08:05:43, and America/New_York −04:56:02, which is the offset * both directions read those years at. + * + * ## [#20280] A `datetime` begins at year 1000 + * + * MySQL documents its `DATETIME` from year 1000 only, and reads one stored in + * 0001..0099 back a century late, so the write door behind the import refuses + * a `datetime` in 0001..0999 now. The reader layer is unchanged: it still reads + * each cell in its own year, which is what #20599 fixed. The import door + * refuses each such row as `invalid_date` and stores nothing for it, beside the + * floor's first day (`1000-01-01 10:00`, still before 1901, so the tz + * database's local mean time still reads it) and the 2026 control, which it + * stores. The round trip runs at 1000 and 2026: the create door refuses the + * earlier years, so they have no row to export. */ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; @@ -48,6 +60,8 @@ const CELLS = [ { id: 'c0050s', cell: '0050/1/1 10:00', utc: '0050-01-01T10:00:00.000Z', shanghai: '0050-01-01T01:54:17.000Z' }, { id: 'c0099', cell: '0099-12-31 23:59:59', utc: '0099-12-31T23:59:59.000Z', shanghai: '0099-12-31T15:54:16.000Z' }, { id: 'c0100', cell: '0100-01-01 10:00', utc: '0100-01-01T10:00:00.000Z', shanghai: '0100-01-01T01:54:17.000Z' }, + // [#20280] The `datetime` floor's first day. + { id: 'c1000', cell: '1000-01-01 10:00', utc: '1000-01-01T10:00:00.000Z', shanghai: '1000-01-01T01:54:17.000Z' }, { id: 'c2026', cell: '2026-07-15 10:00', utc: '2026-07-15T10:00:00.000Z', shanghai: '2026-07-15T02:00:00.000Z' }, // A bare day is midnight UTC in every zone (#20534 already read it so). { id: 'd0050', cell: '0050-01-01', utc: '0050-01-01T00:00:00.000Z', shanghai: '0050-01-01T00:00:00.000Z' }, @@ -77,6 +91,9 @@ describe.each(['UTC', SHANGHAI])('[#20599] parseDateCell on a %s host', (host) = // The routes, over a real engine. // --------------------------------------------------------------------------- +/** [#20280] The cells the import door stores: a `datetime` from year 1000 on. */ +const STORED: ReadonlyArray<(typeof CELLS)[number]> = CELLS.filter((c) => c.utc >= '1000'); + const OBJECT = 'import_year_below_100_20599'; const LEDGER = { @@ -173,14 +190,24 @@ describe.each([ } }); - it('imports every row', () => { - expect(summary).toMatchObject({ total: CELLS.length, ok: CELLS.length, errors: 0 }); + it('[#20280] imports the rows from year 1000 on, and refuses each row below it as invalid_date', () => { + expect(summary).toMatchObject({ total: CELLS.length, ok: STORED.length, errors: CELLS.length - STORED.length }); + for (const [i, c] of CELLS.entries()) { + if (STORED.includes(c)) continue; + expect(summary.results[i], c.cell).toMatchObject({ ok: false, action: 'failed', field: 'dt', code: 'invalid_date' }); + } }); - it.each(CELLS)('stores $cell as the instant it names', async (c) => { + it.each(STORED)('stores $cell as the instant it names', async (c) => { const back = await stack.engine.findOne(OBJECT, { where: { id: c.id } }); expect(storedInstant(back?.dt)).toBe(c[column]); }); + + it('[#20280] stores nothing for a row below year 1000', async () => { + for (const c of CELLS.filter((x) => !STORED.includes(x))) { + expect(await stack.engine.findOne(OBJECT, { where: { id: c.id } }), c.cell).toBeNull(); + } + }); }); /** @@ -190,7 +217,8 @@ describe.each([ */ const padExportYear = (cell: string) => cell.replace(/^(\d{1,3})-/, (_m, y: string) => `${y.padStart(4, '0')}-`); -const ROUND_TRIP_YEARS = ['0001', '0050', '0099', '0100', '2026'] as const; +// [#20280] From the `datetime` floor on: the create door refuses the earlier years. +const ROUND_TRIP_YEARS = ['1000', '2026'] as const; const ROWS = ROUND_TRIP_YEARS.map((year) => ({ id: `y${year}`, dt: `${year}-01-01T10:00:00.000Z` })); describe.each([undefined, SHANGHAI, NEW_YORK] as const)('[#20599] GET /export then POST /import, business timezone %s', (zone) => { diff --git a/packages/spec/src/data/calendar-day.ts b/packages/spec/src/data/calendar-day.ts index d573a71e4ce..10e6a6daeb8 100644 --- a/packages/spec/src/data/calendar-day.ts +++ b/packages/spec/src/data/calendar-day.ts @@ -28,7 +28,8 @@ * that cannot see the column type apply it unconditionally. * * The last row is the one day with no next day to stop before. The supported - * years are 0001..9999 (the comparand and write doors refuse the rest), so + * years end at 9999 — 0001..9999 for a `date`, 1000..9999 for a `datetime` + * (the comparand and write doors refuse the rest) — so * every supported value is at most the last millisecond of `9999-12-31`, and * the whole-day bound of that day bounds nothing: `$lte '9999-12-31'` holds for * every value there is, and a `$between` whose maximum is that day keeps only