Skip to content
Merged
26 changes: 26 additions & 0 deletions .changeset/20280-datetime-year-floor.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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). -->

**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.
3 changes: 2 additions & 1 deletion packages/core/src/utils/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 45 additions & 10 deletions packages/core/src/utils/temporal-comparand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,6 +46,13 @@ const PAST_THE_DATE_RANGE: ReadonlyArray<readonly [string, number]> = [
['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<readonly [string, number]> = [
['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<readonly [string, number]> = [
['[#20264] the first millisecond of year 1', at('0001-01-01T00:00:00.000Z')],
Expand Down Expand Up @@ -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
Expand All @@ -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())) {
Expand Down Expand Up @@ -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<readonly [string, string]> = [
['the extended ISO spelling of year 10000', '+010000-01-01T00:00:00.000Z'],
['a bare extended day', '10000-01-01'],
Expand All @@ -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<readonly [string, string]> = [
['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
Expand All @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
17 changes: 11 additions & 6 deletions packages/core/src/utils/temporal-comparand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading