diff --git a/.changeset/20203-epoch-ms-date-comparand.md b/.changeset/20203-epoch-ms-date-comparand.md index 872a33edd48..6cf1e0a13cf 100644 --- a/.changeset/20203-epoch-ms-date-comparand.md +++ b/.changeset/20203-epoch-ms-date-comparand.md @@ -28,4 +28,4 @@ The rule is shared by the drivers' write and read paths too: - `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before. - A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there. -Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included; #20264, in the same release, narrows that to 0001..9999, so year 0 is refused rather than padded), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. diff --git a/.changeset/20240-date-year-four-digits.md b/.changeset/20240-date-year-four-digits.md index 3847eb464b5..123bf4ef75e 100644 --- a/.changeset/20240-date-year-four-digits.md +++ b/.changeset/20240-date-year-four-digits.md @@ -35,4 +35,4 @@ What changes: **Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. -**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included (#20264, in the same release, then narrows the range to 0001..9999 on `date` and `datetime` alike: year 0 is refused too, and so is a `datetime` number, `Date` or string outside it, and the padding covers 0001..0999); every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. diff --git a/.changeset/20263-having-temporal-comparand-door.md b/.changeset/20263-having-temporal-comparand-door.md index f5b71c19cb3..ff299a9a2de 100644 --- a/.changeset/20263-having-temporal-comparand-door.md +++ b/.changeset/20263-having-temporal-comparand-door.md @@ -38,4 +38,4 @@ The refusal follows the `where` door's words. It names the `having` path, the co **Fix.** Compare a `date` column with a `YYYY-MM-DD` day, a `datetime` column with an ISO-8601 instant, a bare day or epoch milliseconds, either one with a relative-date placeholder the resolver knows (`{30_days_ago}`, `{current_month_start}`; `having` resolves them from the same release, #20334), and a `time` column with an `HH:MM` or `HH:MM:SS` wall clock. -**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` and per-aggregation `filter` answer; every `having` on a temporal column whose comparand the rule reads (a `YYYY-MM-DD` day, an ISO instant, an epoch-millisecond number or string, an in-range `Date`, a zone-naive instant, a wall clock, an extended-year instant on a `datetime` column, which that rule reads); `{today}`-style placeholders, known or not; the empty and the whitespace-only string; `null`, `$exists`, `$in` / `$nin`, `$between`, `$not` / `$or` / `$and` and `{ $field }` references; `$contains` and `$startsWith`; every `count` / `sum` / `avg` column, a string comparand included; a `month` bucket; and every existing `having` refusal, in its words. +**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` and per-aggregation `filter` answer; every `having` on a temporal column whose comparand the rule reads (a `YYYY-MM-DD` day, an ISO instant, an epoch-millisecond number or string, an in-range `Date`, a zone-naive instant, a wall clock, an extended-year instant on a `datetime` column, which that rule reads, until #20264, in the same release, refuses a `datetime` year outside 0001..9999 through the same predicate); `{today}`-style placeholders, known or not; the empty and the whitespace-only string; `null`, `$exists`, `$in` / `$nin`, `$between`, `$not` / `$or` / `$and` and `{ $field }` references; `$contains` and `$startsWith`; every `count` / `sum` / `avg` column, a string comparand included; a `month` bucket; and every existing `having` refusal, in its words. diff --git a/.changeset/20264-temporal-year-range.md b/.changeset/20264-temporal-year-range.md new file mode 100644 index 00000000000..7f001d0181f --- /dev/null +++ b/.changeset/20264-temporal-year-range.md @@ -0,0 +1,38 @@ +--- +"@objectstack/core": minor +"@objectstack/objectql": minor +--- + +fix(core,objectql)!: a `date` or `datetime` value names a year from 0001 to 9999, or it is refused: `INVALID_FILTER` / 400 as a comparand on `where`, a per-aggregation `filter` and `having`, and `VALIDATION_FAILED` / 400 as a written value (#20264) + +Clause-②: yes (narrowing) + + + +**BREAKING**: this narrows what a `date` or `datetime` field accepts, as a filter comparand and as a written value. A value whose year falls outside 0001..9999 used to answer 200 with the wrong rows, 201 with a non-day stored, or a 500 on PostgreSQL; it now answers 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + +FROM a `date` or `datetime` value in year 0, before it, or after 9999 (`"+010000-01-01T00:00:00.000Z"`, `"-000001-…"`, `"0000-06-15"`, or the epoch-millisecond number or `Date` of such an instant) → TO `INVALID_FILTER` / 400 as a comparand and `VALIDATION_FAILED` / 400 (`invalid_date`) as a written value. The fix is one line: write a year from 0001 to 9999. + +Measured through `engine.find` / `engine.aggregate` / `engine.insert` and `POST /data/:object/query` / `POST /data/:object` (the two doors agree), over seven 2026 rows, `$gt` / `$lt` / `$eq`: + +| position | value | before: memory · SQLite · PostgreSQL 16 | now, on all three | +|:--|:--|:--|:--| +| `where` on a `datetime` | year 10000 or −1: a number, `Date` or ISO string | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | +| per-aggregation `filter`, `having` on `min` of a `datetime` | the same, `$gt` | 7 rows, every group, on all three | `INVALID_FILTER` / 400 | +| `where` on a `datetime` | year 0, every spelling | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | +| `where` on a `date` | year 0: a number, `Date`, ISO string or bare `0000-06-15` | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 | +| create a `date` | `"+010000-01-01T00:00:00.000Z"` | 201, read back verbatim (not a day) · the same · 500 | `VALIDATION_FAILED` / 400 | +| create a `date` or a `datetime` | year 0, year −1, year 10000 | 201 · 201 · 500 | `VALIDATION_FAILED` / 400 | + +MySQL 8.0 answered the year-10000 and year-−1 cells with a 500 and the year-0 cells like SQLite. A `datetime` in year 10000 spells `+010000-…`, which sorts below every four-digit year as text (its `where` answer was 7/0/0 for `$gt` / `$lt` / `$eq`, where the right answer is 0/7/0); PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). Year 0 was answered right on memory and SQLite and a 500 on PostgreSQL; it is refused everywhere now, one answer on every driver. Each refused query or write now reaches no driver. + +What changes: + +- `@objectstack/core` exports `isOutsideTemporalYearRange(value, kind)`, the one range both doors ask. The year is the one the kind's storage rule reads: a `datetime`'s UTC year, a `date` string's leading `YYYY-MM-DD` year (otherwise the UTC year of the instant it names), never a `time`'s. +- `isUninterpretableTemporalComparand` is true for a `date` or `datetime` number, `Date` or readable string whose year falls outside 0001..9999; before, it judged only a `date` number or `Date`, against 0..9999. The engine's temporal-comparand door refuses such a comparand on `where` (every verb, both spellings), in a per-aggregation `filter` and on `having`, before any read, in words that name the year range. `IObjectQLEngine.judgeFilter` and `service-analytics`' raw-SQL decline read the same predicate. +- The record validator's `date` / `datetime` arm refuses a value outside the range on insert, update, a multi-row update and `engine.validate`, with the field's `invalid_date` code and its existing message. +- `temporalStorageForm(value, 'date')` pads a `Date`'s or a number's year to four digits for 0001..0999 only; year 0 keeps its unpadded spelling (`0-06-15`) like every other year outside the range. Only a direct driver write, which bypasses both doors, reaches that arm with year 0. + +**Who is affected.** A caller that filters on or writes a `date` or `datetime` in year 0, before it, or after 9999. No writer that stores or queries such a year has been measured; the reach is the public query and write doors. + +**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every year from 0001 to 9999 (the edges 0001-01-01 and 9999-12-31T23:59:59.999Z included) and every 2026 control; every `time` cell; every string the rules could not read before, refused in its existing words, except a `date`-column string whose instant names a year outside 0001..9999 (`+010000-01-01T00:00:00.000Z`, `-000001-…`, an out-of-range epoch-millisecond string), refused with the same code and status on `where`, the per-aggregation `filter` and `having` but now in the year-class words; `NaN`, ±Infinity and an Invalid Date, which name no year; the `datetime` storage rule's own spelling of any instant on the write and read paths. On MySQL 8.0, a `datetime` in years 0001..0099 is still stored right and read back a century late through mysql2's instant parser (`0009-03-04T10:00Z` as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps and this change does not touch; from year 0100 up it reads back as written. `driver-mongodb` keeps its own copy of the storage rule and is not changed; both doors sit in the engine, in front of it. diff --git a/packages/core/src/utils/temporal-comparand.test.ts b/packages/core/src/utils/temporal-comparand.test.ts index c284c1b098f..fc0911363d1 100644 --- a/packages/core/src/utils/temporal-comparand.test.ts +++ b/packages/core/src/utils/temporal-comparand.test.ts @@ -10,6 +10,11 @@ // instant was already refused `INVALID_FILTER` / 400. The door that calls this // predicate (`@objectstack/objectql`, `temporal-comparand-door.ts`) now refuses // the number and the `Date` too; its own suite pins the envelope. +// +// [#20264] The range is 0001..9999 (triage's ruling on that card), on `date` +// 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. import { describe, it, expect } from 'vitest'; import { isUninterpretableTemporalComparand } from './temporal-comparand.js'; @@ -22,6 +27,8 @@ const OUT_OF_RANGE: ReadonlyArray = [ ['the first day of year 10000', 253402300800000], ['year -1 (the card\'s number)', -62198755200000], ['the last millisecond of year -1', at('-000001-12-31T23:59:59.999Z')], + ['[#20264] the first millisecond of year 0', at('0000-01-01T00:00:00.000Z')], + ['[#20264] the last millisecond of year 0', at('0000-12-31T23:59:59.999Z')], ['the Date range maximum', 8.64e15], ['the Date range minimum', -8.64e15], ]; @@ -36,7 +43,7 @@ const PAST_THE_DATE_RANGE: ReadonlyArray = [ /** Finite numbers whose UTC calendar day has a four-digit year — read as before. */ const IN_RANGE: ReadonlyArray = [ - ['the first millisecond of year 0', at('0000-01-01T00:00:00.000Z')], + ['[#20264] the first millisecond of year 1', at('0001-01-01T00:00:00.000Z')], ['0999-06-15', -30627504000000], ['1000-01-01', at('1000-01-01T00:00:00.000Z')], ['2026-02-01T10:00Z', 1769940000000], @@ -45,7 +52,7 @@ const IN_RANGE: ReadonlyArray = [ ]; describe('[#20240] isUninterpretableTemporalComparand — a date column\'s number or Date outside the four-digit years', () => { - it('refuses a number and the Date of the same value, for a year below 0 or above 9999', () => { + it('refuses a number and the Date of the same value, for a year below 0001 or above 9999', () => { for (const [name, ms] of OUT_OF_RANGE) { expect(isUninterpretableTemporalComparand('date', ms), `number, ${name}`).toBe(true); expect(isUninterpretableTemporalComparand('date', new Date(ms)), `Date, ${name}`).toBe(true); @@ -80,20 +87,37 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe expect(isUninterpretableTemporalComparand('date', '-000001-01-01T00:00:00.000Z')).toBe(true); expect(isUninterpretableTemporalComparand('date', '0999-06-15T00:00:00.000Z')).toBe(false); expect(isUninterpretableTemporalComparand('date', '0999-06-15')).toBe(false); + // [#20264] Year 0 in its string spellings, beside the first supported day. + expect(isUninterpretableTemporalComparand('date', '0000-06-15')).toBe(true); + expect(isUninterpretableTemporalComparand('date', '0000-06-15T00:00:00.000Z')).toBe(true); + expect(isUninterpretableTemporalComparand('date', '0001-01-01')).toBe(false); }); - it('leaves the datetime and time rules alone — they read an instant', () => { - for (const kind of ['datetime', 'time'] as const) { - for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) { - expect(isUninterpretableTemporalComparand(kind, ms), `${kind}, number, ${name}`).toBe(false); - expect(isUninterpretableTemporalComparand(kind, new Date(ms)), `${kind}, Date, ${name}`).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]) { + 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) { + expect(isUninterpretableTemporalComparand('datetime', ms), `number, ${name}`).toBe(false); + expect(isUninterpretableTemporalComparand('datetime', new Date(ms)), `Date, ${name}`).toBe(false); + } + }); + + it('leaves the time rule alone — a wall clock has no year', () => { + for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) { + expect(isUninterpretableTemporalComparand('time', ms), `number, ${name}`).toBe(false); + expect(isUninterpretableTemporalComparand('time', new Date(ms)), `Date, ${name}`).toBe(false); + } }); it('does not judge NaN, ±Infinity or an Invalid Date — they name no instant and no year', () => { - for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, new Date(Number.NaN)]) { - expect(isUninterpretableTemporalComparand('date', value), String(value)).toBe(false); + for (const kind of ['date', 'datetime'] as const) { + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, new Date(Number.NaN)]) { + expect(isUninterpretableTemporalComparand(kind, value), `${kind} ${String(value)}`).toBe(false); + } } }); @@ -103,3 +127,51 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe } }); }); + +// [#20264] The `datetime` rule reads an extended-year ISO string, a negative +// year and year 0 because `Date.parse` does, and spells them `+010000-…`, +// `-000001-…` and `0000-…`. The first two sort below every four-digit year as +// text, so a `where` on a `datetime` field counted `$gt` / `$lt` / `$eq` +// 7 / 0 / 0 on driver-memory and SQLite for a bound in year 10000 (the right +// 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', () => { + const REFUSED: ReadonlyArray = [ + ['the extended ISO spelling of year 10000', '+010000-01-01T00:00:00.000Z'], + ['a bare extended day', '10000-01-01'], + ['the extended ISO spelling of year -1', '-000001-01-01T00:00:00.000Z'], + ['year 0, ISO', '0000-06-15T00:00:00.000Z'], + ['year 0, a bare day (read as midnight UTC)', '0000-06-15'], + ['year 0, zone-naive (read as UTC)', '0000-06-15 10:00'], + ['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'], + ]; + const READ: ReadonlyArray = [ + ['the first instant of year 1', '0001-01-01T00:00:00.000Z'], + ['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'], + ['epoch milliseconds for 2026, as a string (the control)', '1769940000000'], + ]; + it('refuses each, on datetime and on date', () => { + for (const [name, value] of REFUSED) { + expect(isUninterpretableTemporalComparand('datetime', value), name).toBe(true); + } + for (const value of ['+010000-01-01T00:00:00.000Z', '-000001-01-01T00:00:00.000Z', '0000-06-15']) { + expect(isUninterpretableTemporalComparand('date', value), value).toBe(true); + } + }); + 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); + } + }); + it('a time column judges no year — its rule keeps a time of day', () => { + expect(isUninterpretableTemporalComparand('time', '0000-06-15T10:00:00.000Z')).toBe(false); + expect(isUninterpretableTemporalComparand('time', '10:00')).toBe(false); + }); +}); diff --git a/packages/core/src/utils/temporal-comparand.ts b/packages/core/src/utils/temporal-comparand.ts index 912a0609ab3..bf8d1264f11 100644 --- a/packages/core/src/utils/temporal-comparand.ts +++ b/packages/core/src/utils/temporal-comparand.ts @@ -50,14 +50,23 @@ * * [#20240] One non-string class is uninterpretable by the same test's other * half — the rule cannot put it in the column's form. On a `date` column a - * finite number or a `Date` becomes its UTC calendar day, and a year below 0 or - * above 9999 has no `YYYY-MM-DD` spelling: the rule keeps writing + * finite number or a `Date` becomes its UTC calendar day, and a year outside + * the four-digit ones has no `YYYY-MM-DD` spelling: the rule keeps writing * `10000-01-01` / `-1-01-01` for the write and read paths, but as a comparand * that text orders as no day does (`10000-01-01` sorts below `2026-…`), and * PostgreSQL refuses `-1-01-01` outright. So such a comparand is judged * uninterpretable here, exactly as an unparseable string is, and refused by * the same doors. * + * [#20264] That class is now the supported years, 0001..9999, on `date` AND + * `datetime`, and in every spelling the kind's rule reads — a number, a `Date` + * 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 + * `temporal-storage-form.ts`'s `isOutsideTemporalYearRange`, the one the + * record validator asks of a written value too; this predicate only calls it. + * * That is why this is not `utcInstantMs` (`@objectstack/spec/data`), which is * the stricter canonical reader: it rejects a bare epoch-millisecond string and * every non-ISO spelling `Date.parse` accepts, both of which the drivers read @@ -66,15 +75,16 @@ * * ## Two things it deliberately does NOT judge * - * - **Non-string comparands, save the one `date` class above.** A number is - * epoch milliseconds, a `Date` is an instant, `null` is a null test, and the - * `datetime` and `time` rules read every finite one; so does the `date` rule - * for a year from 0 to 9999. The #8690 refusal was scoped to strings by that + * - **Non-string comparands, save the year class above.** A number is epoch + * milliseconds, a `Date` is an instant, `null` is a null test, and the + * `time` rule reads every finite one; so do the `date` and `datetime` rules + * for a year from 0001 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. + * refusal to the `date` class above by the triage direction on that card, + * and [#20264] to `datetime` and the range 0001..9999 by triage's 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`). @@ -87,6 +97,7 @@ */ import { classifyFilterToken } from '@objectstack/spec/data'; +import { isOutsideTemporalYearRange } from './temporal-storage-form.js'; /** Which temporal storage rule a declared field takes. */ export type TemporalComparandKind = 'datetime' | 'date' | 'time'; @@ -150,33 +161,14 @@ function readsAsWallClock(s: string): boolean { return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? '0') <= 59; } -/** - * [#20240] `temporalStorageForm`'s `date` reading of a NUMBER or a `Date` lands - * outside the four-digit years `YYYY-MM-DD` can spell. - * - * Both name an instant, and the rule takes that instant's UTC calendar day. - * Its year must fall from 0 to 9999: `0999-06-15` is a day, `10000-01-01` and - * `-1-01-01` are not. A finite number past ±8.64e15 names an instant the - * `Date` type cannot hold at all — a year past ±271821 — so it is outside the - * range too, and the rule hands it back unchanged. `NaN`, ±Infinity and an - * Invalid Date name no instant and no year, and are not judged here. - */ -function isOutsideCalendarDayYears(value: number | Date): boolean { - if (typeof value === 'number' && !Number.isFinite(value)) return false; - const instant = typeof value === 'number' ? new Date(value) : value; - if (Number.isNaN(instant.getTime())) return typeof value === 'number'; - const year = instant.getUTCFullYear(); - return year < 0 || year > 9999; -} - /** * Is `value` a comparand that a `kind` column's storage rule cannot read? * * `true` for a non-empty, non-placeholder STRING that the kind's rule would - * hand back unchanged, and — on a `date` column only — for a finite number or - * a `Date` whose UTC calendar day falls in a year below 0 or above 9999 - * ({@link isOutsideCalendarDayYears}). Everything else — any other number or - * `Date`, `null`, a `{ $field }` reference, filter structure, the empty + * hand back unchanged, 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`). Everything else — any other number + * or `Date`, `null`, a `{ $field }` reference, filter structure, the empty * string, a `{token}` — answers `false`, each for a reason recorded in the * module note or below. * @@ -191,8 +183,8 @@ export function isUninterpretableTemporalComparand( kind: TemporalComparandKind, value: unknown, ): boolean { - if (kind === 'date' && (typeof value === 'number' || value instanceof Date)) { - return isOutsideCalendarDayYears(value); + if (kind !== 'time' && (typeof value === 'number' || value instanceof Date)) { + return isOutsideTemporalYearRange(value, kind); } if (typeof value !== 'string') return false; const s = value.trim(); @@ -200,7 +192,7 @@ export function isUninterpretableTemporalComparand( if (s === '') return false; // Another layer's vocabulary, and it has its own loud refusal. if (classifyFilterToken(value) !== null) return false; - if (kind === 'datetime') return !readsAsInstant(s); - if (kind === 'date') return !readsAsCalendarDay(s); + if (kind === 'datetime') return !readsAsInstant(s) || isOutsideTemporalYearRange(s, kind); + if (kind === 'date') return !readsAsCalendarDay(s) || isOutsideTemporalYearRange(s, kind); return !(readsAsWallClock(s) || readsAsInstant(s)); } diff --git a/packages/core/src/utils/temporal-storage-form.test.ts b/packages/core/src/utils/temporal-storage-form.test.ts index c714fdfff77..d3992c91da5 100644 --- a/packages/core/src/utils/temporal-storage-form.test.ts +++ b/packages/core/src/utils/temporal-storage-form.test.ts @@ -9,7 +9,7 @@ // `sql-driver-temporal-storage-form.test.ts`). This file pins the rule itself. import { describe, it, expect } from 'vitest'; -import { temporalStorageForm } from './temporal-storage-form.js'; +import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js'; describe('temporalStorageForm — datetime: canonical UTC ISO text', () => { const cases: ReadonlyArray = [ @@ -98,9 +98,10 @@ describe('temporalStorageForm — date: an epoch-ms number is the UTC calendar d // same day `0999-06-15`. As text `999-…` sorts above every padded day // (`'9' > '0'`), so over REST the number for 0999-06-15 counted `$gt` 0 / // `$lt` 7 on driver-memory and SQLite where its ISO string counted 6 / 0. The -// year is now four digits. A year below 0 or above 9999 has no `YYYY-MM-DD` -// form: it keeps the spelling it had (no ordered form is invented), and the -// temporal-comparand door refuses it as a comparand. +// year is now four digits. [#20264] The padding covers 0001..0999: a year +// outside 0001..9999 — year 0 included, which this block padded to `0000-…` +// before that card's ruling — has no `YYYY-MM-DD` form: it keeps its unpadded +// spelling (no ordered form is invented), and the doors refuse it. describe('temporalStorageForm — date: the year of a Date or number is four digits', () => { const at = (iso: string) => Date.parse(iso); const cases: ReadonlyArray = [ @@ -108,8 +109,6 @@ describe('temporalStorageForm — date: the year of a Date or number is four dig ['0099-03-04', at('0099-03-04T12:00:00.000Z')], ['0009-03-04', at('0009-03-04T00:00:00.000Z')], ['0001-01-01', at('0001-01-01T00:00:00.000Z')], - ['0000-06-15', at('0000-06-15T00:00:00.000Z')], - ['0000-01-01', at('0000-01-01T00:00:00.000Z')], ['1000-01-01', at('1000-01-01T00:00:00.000Z')], // already four digits — unchanged ['9999-12-31', at('9999-12-31T23:59:59.999Z')], // the last millisecond of year 9999 ]; @@ -127,11 +126,22 @@ describe('temporalStorageForm — date: the year of a Date or number is four dig expect([...spelled].sort()).toEqual(chronological); }); - it('a year outside 0..9999 keeps its spelling — no ordered form is invented', () => { + it('a year outside 0001..9999 keeps its spelling — no ordered form is invented', () => { expect(temporalStorageForm(253402300800000, 'date')).toBe('10000-01-01'); expect(temporalStorageForm(new Date(253402300800000), 'date')).toBe('10000-01-01'); expect(temporalStorageForm(-62198755200000, 'date')).toBe('-1-01-01'); expect(temporalStorageForm(at('-000001-12-31T23:59:59.999Z'), 'date')).toBe('-1-12-31'); + // [#20264] Year 0 is outside too: unpadded, where #20240 padded it `0000-…`. + expect(temporalStorageForm(at('0000-06-15T00:00:00.000Z'), 'date')).toBe('0-06-15'); + expect(temporalStorageForm(new Date(at('0000-01-01T00:00:00.000Z')), 'date')).toBe('0-01-01'); + }); + + it('[#20264] the datetime rule stays total — it spells a year outside the range as toISOString does', () => { + // The doors refuse these; the write and read paths behind them are unchanged. + expect(temporalStorageForm(253402300800000, 'datetime')).toBe('+010000-01-01T00:00:00.000Z'); + expect(temporalStorageForm('+010000-01-01T00:00:00.000Z', 'datetime')).toBe('+010000-01-01T00:00:00.000Z'); + expect(temporalStorageForm(-62198755200000, 'datetime')).toBe('-000001-01-01T00:00:00.000Z'); + expect(temporalStorageForm('0000-06-15', 'datetime')).toBe('0000-06-15T00:00:00.000Z'); }); }); @@ -178,3 +188,70 @@ describe('temporalStorageForm — total: what the rule cannot read comes back un expect(temporalStorageForm(list, 'datetime')).toBe(list); }); }); + +// [#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. +describe('[#20264] isOutsideTemporalYearRange — the years a date or datetime value may name', () => { + const at = (iso: string) => Date.parse(iso); + const OUTSIDE: ReadonlyArray = [ + ['year 10000, a number', at('+010000-01-01T00:00:00.000Z'), 'both'], + ['year 10000, a Date', new Date(at('+010000-01-01T00:00:00.000Z')), 'both'], + ['year 10000, the extended ISO string', '+010000-01-01T00:00:00.000Z', 'both'], + ['year -1, the extended ISO string', '-000001-01-01T00:00:00.000Z', 'both'], + ['year 0, a number', at('0000-06-15T00:00:00.000Z'), 'both'], + ['year 0, a bare day', '0000-06-15', 'both'], + ['year 0, an ISO instant', '0000-06-15T10:00:00.000Z', 'both'], + ['a number past the Date range', 8.64e15 + 1, 'both'], + ['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'], + ]; + 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'], + // 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'], + ]; + 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', () => { + for (const [name, value, kinds] of OUTSIDE) { + for (const kind of kindsOf(kinds)) expect(isOutsideTemporalYearRange(value, kind), `${kind}, ${name}`).toBe(true); + } + }); + + it('is false for a year inside it, the edges and a 2026 control included', () => { + for (const [name, value, kinds] of INSIDE) { + for (const kind of kindsOf(kinds)) expect(isOutsideTemporalYearRange(value, kind), `${kind}, ${name}`).toBe(false); + } + }); + + it('names no year for a time, or for a value that names no instant', () => { + for (const value of [at('+010000-01-01T00:00:00.000Z'), '0000-06-15T10:00:00.000Z']) { + expect(isOutsideTemporalYearRange(value, 'time')).toBe(false); + } + for (const kind of ['date', 'datetime'] as const) { + for (const value of [null, undefined, '', ' ', 'not-a-date', '{today}', Number.NaN, Number.POSITIVE_INFINITY, new Date(Number.NaN), true, {}]) { + expect(isOutsideTemporalYearRange(value, kind), `${kind} ${String(value)}`).toBe(false); + } + } + }); + + it('agrees with the date rule: a number or Date is outside exactly when the rule cannot spell it YYYY-MM-DD', () => { + for (const [, value] of [...OUTSIDE, ...INSIDE]) { + if (typeof value !== 'number' && !(value instanceof Date)) continue; + const spelled = temporalStorageForm(value, 'date'); + const isDay = typeof spelled === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(spelled); + expect(isOutsideTemporalYearRange(value, 'date'), String(value)).toBe(!isDay); + } + }); +}); diff --git a/packages/core/src/utils/temporal-storage-form.ts b/packages/core/src/utils/temporal-storage-form.ts index 123c85a117b..7801894ecb6 100644 --- a/packages/core/src/utils/temporal-storage-form.ts +++ b/packages/core/src/utils/temporal-storage-form.ts @@ -51,11 +51,36 @@ * (`'25:00'`), an Invalid Date and unparseable junk come back UNCHANGED rather * than as an invented value — a value the rule cannot interpret is never * silently rewritten, so junk keeps failing its comparison. Which comparands - * are uninterpretable — a string this rule hands back unchanged, and [#20240] a - * number or `Date` on a `date` column whose UTC year falls outside 0..9999, the - * four-digit years a `YYYY-MM-DD` day can spell — is the question + * are uninterpretable — a string this rule hands back unchanged, and a value + * outside the supported years below — is the question * `isUninterpretableTemporalComparand` (`temporal-comparand.ts`) answers for * the doors that refuse them. + * + * ## [#20264] The supported years: 0001 to 9999, for `date` and `datetime` + * + * 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. + * + * - 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 + * PostgreSQL refuses it. + * - Below 0001 there is year 0 and before. PostgreSQL's `DATE` and + * `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 + * `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. + * + * 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 + * that call this function — no ordered form is invented — and the doors refuse + * it before it gets there. */ import type { TemporalComparandKind } from './temporal-comparand.js'; @@ -77,10 +102,10 @@ import type { TemporalComparandKind } from './temporal-comparand.js'; * read as the `Date` of that value and so its UTC calendar day (a time of * day is dropped, never rounded); a string → its leading `YYYY-MM-DD`. The * year of a `Date` or a number is padded to four digits (`0999-06-15`); a - * year below 0 or above 9999 has no `YYYY-MM-DD` form, keeps its unpadded - * spelling (`10000-01-01`, `-1-01-01`) for the write and read paths that - * call this rule, and is refused as a comparand by the temporal-comparand - * door. + * year outside 0001..9999 has no `YYYY-MM-DD` form, keeps its unpadded + * spelling (`10000-01-01`, `0-06-15`, `-1-01-01`) for the write and read + * paths that call this rule, and is refused by the doors in front of them + * (see the module note's supported years). * - `time`: a bare `HH:MM[:SS[.f…]]` in range → `HH:MM:SS`, `.fff` kept only * when non-zero (fractions beyond milliseconds truncated); anything else is * read as an INSTANT by the `datetime` rule and keeps its UTC time of day. @@ -95,31 +120,80 @@ export function temporalStorageForm(value: unknown, kind: TemporalComparandKind) } function canonicalUtcDatetime(value: unknown): unknown { - if (value == null) return value; + const ms = instantMs(value); + return ms === undefined ? value : new Date(ms).toISOString(); +} + +/** + * The instant the `datetime` rule reads `value` as, in epoch milliseconds, or + * `undefined` when it reads none — the one reading {@link canonicalUtcDatetime} + * spells and [#20264] {@link isOutsideTemporalYearRange} takes the year of. + */ +function instantMs(value: unknown): number | undefined { + if (value == null) return undefined; if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? value : value.toISOString(); + const t = value.getTime(); + return Number.isNaN(t) ? undefined : t; } if (typeof value === 'number') { - if (!Number.isFinite(value)) return value; - const d = new Date(value); - return Number.isNaN(d.getTime()) ? value : d.toISOString(); + if (!Number.isFinite(value)) return undefined; + const t = new Date(value).getTime(); + return Number.isNaN(t) ? undefined : t; } - if (typeof value !== 'string') return value; + if (typeof value !== 'string') return undefined; const s = value.trim(); - if (s === '') return value; + if (s === '') return undefined; // A bare integer (in either JS or string form) is epoch milliseconds — the // shape better-sqlite3 wrote for every `Date` bound before the canon (#3912). - if (/^-?\d+$/.test(s)) { - const d = new Date(Number(s)); - return Number.isNaN(d.getTime()) ? value : d.toISOString(); - } + if (/^-?\d+$/.test(s)) return instantMs(Number(s)); const iso = /^\d{4}-\d{2}-\d{2}$/.test(s) ? `${s}T00:00:00.000Z` : /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s) ? `${s.replace(' ', 'T')}Z` : s; const ms = Date.parse(iso); - return Number.isFinite(ms) ? new Date(ms).toISOString() : value; + 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; +const LAST_SUPPORTED_YEAR = 9999; + +/** + * [#20264] Does `value` name a year outside 0001..9999 for a column of `kind`? + * The one range both doors ask — the temporal-comparand door on a comparand, + * the record validator on a written value; see the module note. + * + * The year is the one the kind's rule reads: + * + * - `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. + * - `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 + * calendar day the rule takes. + * - `time`: never. A wall clock has no year. + * + * A finite number past ±8.64e15 names an instant the `Date` type cannot hold, + * a year past ±271821, so it is outside. `null`, `NaN`, ±Infinity, an Invalid + * Date and a string that names no instant name no year: `false`, and whether + * such a value is refused is the caller's other question. + */ +export function isOutsideTemporalYearRange(value: unknown, kind: TemporalComparandKind): boolean { + if (kind === 'time') return false; + if (typeof value === 'number' && Number.isFinite(value) && instantMs(value) === undefined) return true; + let year: number | undefined; + if (kind === 'date' && typeof value === 'string') { + const day = /^(\d{4})-\d{2}-\d{2}/.exec(value.trim()); + if (day) year = Number(day[1]); + } + if (year === undefined) { + const ms = instantMs(value); + if (ms === undefined) return false; + year = new Date(ms).getUTCFullYear(); + } + return year < FIRST_SUPPORTED_YEAR || year > LAST_SUPPORTED_YEAR; } function canonicalCalendarDay(value: unknown): unknown { @@ -135,12 +209,14 @@ function canonicalCalendarDay(value: unknown): unknown { // [#20240] The year is padded to four digits, the width `YYYY-MM-DD` // 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'`). A year below 0 or above 9999 has no `YYYY-MM-DD` - // form at all; it keeps the spelling it always had, and the - // temporal-comparand door refuses it as a comparand before it reaches a - // comparison (`isUninterpretableTemporalComparand`, `temporal-comparand.ts`). + // text (`'9' > '0'`). [#20264] The padding covers 0001..0999, the padded + // part of the 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}). const y = instant.getUTCFullYear(); - const yyyy = y >= 0 ? String(y).padStart(4, '0') : String(y); + const yyyy = y >= FIRST_SUPPORTED_YEAR ? 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 new file mode 100644 index 00000000000..49ea1f21d1b --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20264-temporal-year-range.test.ts @@ -0,0 +1,89 @@ +// 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 + * 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 + * driver owes the range is the other half: every year inside it is stored and + * compared as the day or the instant it names, the edges included, beside a + * 2026 control — and it reads the one rule, with no copy of its own. + * + * Measured on the base through this driver and the engine over it: a + * `datetime` bound in year 10000 or −1 counted `$gt` / `$lt` / `$eq` 7 / 0 / 0 + * (its `+010000-…` / `-000001-…` text sorts below every four-digit year), and a + * 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. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { temporalStorageForm } from '@objectstack/core'; +import { InMemoryDriver } from './memory-driver.js'; + +const OBJECT = 'ledger_year_20264'; +const FIELDS = { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } }; + +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' }, + { 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']; + +describe('[#20264] every year in 0001..9999 is stored and compared 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)); + + beforeAll(async () => { + driver = new InMemoryDriver({}); + await driver.connect(); + await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS }); + // Written as a number, a Date and a string — one stored form each. + for (const [i, row] of ROWS.entries()) { + const ms = Date.parse(row.opened_at); + const opened_at = i % 3 === 0 ? ms : i % 3 === 1 ? new Date(ms) : row.opened_at; + await driver.create(OBJECT, { ...row, opened_at }); + } + }); + + it('reads each row back as written — the edges and the 2026 control', async () => { + for (const row of ROWS) { + expect(await driver.findOne(OBJECT, { where: { id: row.id } }), row.id).toMatchObject(row); + } + }); + + for (const [field, kind] of [['placed_on', 'date'], ['opened_at', 'datetime']] as const) { + it(`${kind}: each value is found by $eq, and the range orders chronologically, in every spelling`, async () => { + for (const [i, row] of ROWS.entries()) { + const value = row[field]; + const ms = Date.parse(kind === 'date' ? `${value}T00:00:00.000Z` : value); + for (const comparand of [value, ms, new Date(ms)]) { + expect(await ids({ [field]: { $eq: comparand } }), `${row.id} $eq ${String(comparand)}`).toEqual([row.id]); + expect(await ids({ [field]: { $gt: comparand } }), `${row.id} $gt ${String(comparand)}`).toEqual(ORDER.slice(i + 1)); + expect(await ids({ [field]: { $lt: comparand } }), `${row.id} $lt ${String(comparand)}`).toEqual(ORDER.slice(0, i)); + } + } + }); + } + + it('the driver reads core\'s one rule: a year outside the range keeps the rule\'s spelling here, and the engine refuses it', async () => { + // A direct write bypasses the engine's doors; what it stores is exactly + // what `temporalStorageForm` spells — no copy of the rule in this driver. + const y0 = new Date(Date.parse('0000-06-15T00:00:00.000Z')); + const y10000 = Date.parse('+010000-01-01T00:00:00.000Z'); + await driver.create(OBJECT, { id: 'direct', placed_on: y0, opened_at: y10000 }); + const stored = await driver.findOne(OBJECT, { where: { id: 'direct' } }); + expect(stored?.placed_on).toBe(temporalStorageForm(y0, 'date')); + expect(stored?.placed_on).toBe('0-06-15'); + expect(stored?.opened_at).toBe(temporalStorageForm(y10000, 'datetime')); + await driver.delete(OBJECT, 'direct'); + expect(await ids({})).toEqual(ORDER); + }); +}); 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 new file mode 100644 index 00000000000..e32d21ddd7b --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-20264-temporal-year-range.test.ts @@ -0,0 +1,133 @@ +// 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, + * 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 + * one. What each dialect owes the range is the other half: every year inside + * it is STORED as the day or instant it names, and compared as it, the edges + * included, beside a 2026 control. + * + * Measured on the base through the engine over this driver: a `datetime` bound + * in year 10000 or −1 counted `$gt` / `$lt` / `$eq` 7 / 0 / 0 on SQLite and + * answered 500 on PostgreSQL 16 (`22009` / `22007`) and MySQL 8.0; year 0 + * answered 500 on PostgreSQL on both kinds (`22008` — PostgreSQL's `DATE` and + * `timestamptz` have no year 0); a REST create of a `date` + * `"+010000-01-01T00:00:00.000Z"` was stored verbatim on SQLite and a 500 on + * PostgreSQL and MySQL. The engine refuses each of those now. + * + * ## The one cell read-back does not assert: MySQL `DATETIME` in 0001..0099 + * + * 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. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'os20264_ledger'; + +const shape = (name: string) => ({ + name, + 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. */ +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' }, + { 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 = ROWS.map((r) => r.id); + +const NO_AUDIT = { bypassTenantAudit: true }; + +/** knex's raw result shape differs per client; this is the only place that knows. */ +function rowsOf(cell: DialectCell, res: any): any[] { + if (cell.id === 'pg') return res?.rows ?? []; + if (cell.id === 'mysql') return Array.isArray(res) ? (res[0] ?? []) : []; + return Array.isArray(res) ? res : (res?.rows ?? []); +} + +/** The text the server STORED for one row, by a raw cast — never the driver's read path. */ +async function storedText(driver: SqlDriver, cell: DialectCell, id: string, column: string): Promise { + const sql = + cell.id === 'pg' + ? `select "${column}"::text as t from "${TABLE}" where "id" = ?` + : cell.id === 'mysql' + ? `select cast(\`${column}\` as char) as t from \`${TABLE}\` where \`id\` = ?` + : `select cast("${column}" as text) as t from "${TABLE}" where "id" = ?`; + const rows = rowsOf(cell, await driver.execute(sql, [id])); + expect(rows, `no stored row for ${id}`).toHaveLength(1); + return rows[0].t ?? null; +} + +/** Does this cell read `opened_at` of `row` back as written? Every cell but MySQL below year 100. */ +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}`, () => { + let driver: SqlDriver; + const ids = async (where: FilterCondition) => + (await driver.find(TABLE, { where }, NO_AUDIT)).map((r) => r.id as string) + .sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b)); + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([shape(TABLE)]); + for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT); + }); + + afterAll(async () => { + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.disconnect(); + }); + + it('a date in every year of the range is stored as that day and read back as written', async () => { + for (const row of ROWS) { + expect(await storedText(driver, cell, row.id, 'placed_on'), `stored ${row.id}`).toBe(row.placed_on); + expect((await driver.findOne(TABLE, { where: { id: row.id } }, NO_AUDIT))?.placed_on, `read ${row.id}`).toBe(row.placed_on); + } + }); + + 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 () => { + for (const row of ROWS) { + const read = (await driver.findOne(TABLE, { where: { id: row.id } }, NO_AUDIT))?.opened_at; + if (readsBackAsWritten(cell, row)) { + expect(read, `read ${row.id}`).toBe(row.opened_at); + } else { + // Stored right: the misread is the client parser's (see the module note). + expect(await storedText(driver, cell, row.id, 'opened_at'), `stored ${row.id}`) + .toBe(`${row.opened_at.slice(0, 10)} ${row.opened_at.slice(11, 23)}`); + } + } + }); + + for (const field of ['placed_on', 'opened_at'] as const) { + it(`${field}: each value is found by $eq and the range orders chronologically — the edges and the 2026 control`, async () => { + for (const [i, row] of ROWS.entries()) { + const value = row[field]; + expect(await ids({ [field]: { $eq: value } }), `${row.id} $eq`).toEqual([row.id]); + expect(await ids({ [field]: { $gt: value } }), `${row.id} $gt`).toEqual(ORDER.slice(i + 1)); + expect(await ids({ [field]: { $lt: value } }), `${row.id} $lt`).toEqual(ORDER.slice(0, i)); + } + }); + } + }); +} + +for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'temporal year range', measure); 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 32e70d5ee0e..22d06373b54 100644 --- a/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts +++ b/packages/objectql/src/engine-aggregate-having-temporal-door.test.ts @@ -191,10 +191,19 @@ describe('[#20263] having — a comparand its column cannot read is refused befo ['"+010000-01-01T00:00:00.000Z"', 'not a date value']], ['the number for 10000-01-01 on max(date), in the year class\'s own words', () => ({ last_placed: { $gt: Y10000 } }), { placed_on: { $gt: Y10000 } }, - ['253402300800000', 'outside the years 0000 to 9999', 'keep the wrong groups']], + ['253402300800000', 'outside the years 0001 to 9999', 'keep the wrong groups']], ['the Date for 10000-01-01 on max(date)', () => ({ last_placed: { $lt: new Date(Y10000) } }), { placed_on: { $lt: new Date(Y10000) } }, - ['Date +010000-01-01T00:00:00.000Z', 'outside the years 0000 to 9999']], + ['Date +010000-01-01T00:00:00.000Z', 'outside the years 0001 to 9999']], + // [#20264] A datetime year outside 0001..9999 is the year class too, on + // `having` with no edit here: the predicate asks core's one range. At the + // 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']], + ['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']], ['"not-a-date" on min(datetime)', () => ({ first_opened: { $lt: 'not-a-date' } }), { opened_at: { $lt: 'not-a-date' } }, ["`having` on 'first_opened' (min(opened_at), a datetime column)", 'not a datetime value']], @@ -317,12 +326,9 @@ describe('[#20263] having — what the door leaves alone answers exactly as befo ['an in-range Date on max(date)', { last_placed: { $gt: new Date(1769940000000) } }, ['c2']], ['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']], - // The `datetime` rule reads an extended-year instant, so the predicate calls - // it interpretable and its `where` twin is not refused by the door either. - // Its text orders below every four-digit year, so `$lt` keeps no group, on - // `having` as before; which years a comparand may name is #20264's to - // decide, in the predicate, and `having` follows it with no second edit. - ['an extended-year ISO on min(datetime) — read by the datetime rule', { first_opened: { $lt: '+010000-01-01T00:00:00.000Z' } }, []], + // [#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']], ['a wall clock on max(time)', { last_slot: { $gte: '12:00' } }, ['c2', 'c3', 'c4']], ['the number for 10000-01-01 on max(time) — not judged on time', { last_slot: { $gt: Y10000 } }, []], ['a string on sum — not temporal', { total: { $gt: 'not-a-date' } }, []], diff --git a/packages/objectql/src/engine-date-year-range-door.test.ts b/packages/objectql/src/engine-date-year-range-door.test.ts index 2b62753c500..bc13f3ad9b7 100644 --- a/packages/objectql/src/engine-date-year-range-door.test.ts +++ b/packages/objectql/src/engine-date-year-range-door.test.ts @@ -22,7 +22,12 @@ * per-aggregation `filter`. * * Every refusal pin sits in one `it()` with its POSITIVE CONTROL: the same - * operators with a four-digit year (0000, 0999, 2026, 9999) reach the driver. + * operators with a supported year (0001, 0999, 2026, 9999) reach the driver. + * + * [#20264] The range is 0001..9999: year 0 is refused too, and on a + * `datetime` field a number or `Date` outside the range is refused as it is + * on a `date` field (`engine-temporal-year-range.test.ts` pins that card's + * cells; this file keeps the `date` ones). */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -47,11 +52,13 @@ const OUT_OF_RANGE: ReadonlyArray = [ ['10000-01-01', 253402300800000], ['-1-01-01', -62198755200000], ['the last millisecond of year -1', at('-000001-12-31T23:59:59.999Z')], + // [#20264] Year 0 joins the refused years: PostgreSQL's `DATE` has no year 0. + ['0000-01-01', at('0000-01-01T00:00:00.000Z')], ]; -/** Four-digit years, the edges included — every one reaches the driver. */ +/** The supported years 0001..9999, the edges included — every one reaches the driver. */ const IN_RANGE: ReadonlyArray = [ - ['0000-01-01', at('0000-01-01T00:00:00.000Z')], + ['0001-01-01', at('0001-01-01T00:00:00.000Z')], ['0999-06-15', -30627504000000], ['2026-02-01T10:00Z', 1769940000000], ['the last millisecond of year 9999', at('9999-12-31T23:59:59.999Z')], @@ -192,14 +199,15 @@ describe('[#20240] a date field\'s number or Date outside the four-digit years i expect(reads.length).toBeGreaterThan(0); }); - it('leaves the datetime and time fields alone — their rules read the same numbers as instants', async () => { - for (const [, ms] of OUT_OF_RANGE) { + it('[#20264] refuses the same numbers on a datetime field, and leaves the time field alone — a wall clock has no year', async () => { + for (const [name, ms] of OUT_OF_RANGE) { for (const comparand of [ms, new Date(ms)]) { - await expect(engine.find('ledger', { where: { opened_at: { $gt: comparand } } })).resolves.toEqual([]); + const err = await refusalOf(engine.find('ledger', { where: { opened_at: { $gt: comparand } } })); + expect(err, `datetime ${name}`).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); await expect(engine.find('ledger', { where: { opens_at: { $gt: comparand } } })).resolves.toEqual([]); } } - expect(reads).toHaveLength(OUT_OF_RANGE.length * 4); + expect(reads).toHaveLength(OUT_OF_RANGE.length * 2); }); it('does not judge NaN, ±Infinity or an Invalid Date — no instant, no year', async () => { diff --git a/packages/objectql/src/engine-temporal-year-range.test.ts b/packages/objectql/src/engine-temporal-year-range.test.ts new file mode 100644 index 00000000000..affa00cca76 --- /dev/null +++ b/packages/objectql/src/engine-temporal-year-range.test.ts @@ -0,0 +1,279 @@ +// 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`): + * + * - the temporal-comparand door, `INVALID_FILTER` / 400, on `where`, a + * per-aggregation `filter` and `having`, before any driver read; + * - the write door (the record validator), `VALIDATION_FAILED` with the field's + * `invalid_date` code, on insert, update, a multi-row update and the + * dry-run `validate`, before any driver write. + * + * Measured on the base (`b285508188`) on InMemoryDriver and SqlDriver on + * SQLite and PostgreSQL 16, through the engine and REST, over seven 2026 rows: + * + * | position | input | memory | SQLite | PostgreSQL | now | + * |:--|:--|:--|:--|:--|:--| + * | `where` on a `datetime`, `$gt` / `$lt` / `$eq` | year 10000 or −1: a number, `Date` or ISO string | 7 / 0 / 0 | 7 / 0 / 0 | 500 | 400 | + * | the same, per-aggregation `filter` `$gt` / `having` `$gt` on `min` | the same | 7 / 4 groups | 7 / 4 groups | 7 / 4 groups | 400 | + * | `where` on a `datetime` or a `date` | year 0000, every spelling | 7 / 0 / 0 | 7 / 0 / 0 | 500 | 400 | + * | create a `date` | `"+010000-01-01T00:00:00.000Z"` | 201, stored verbatim | 201, stored verbatim | 500 | 400 | + * | create a `date` / `datetime` | year 0000 | 201 | 201 | 500 | 400 | + * + * 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 + * `packages/rest/src/data-temporal-year-range.test.ts`. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +const ledger = { + name: 'ledger', + label: 'Ledger', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + customer_id: { name: 'customer_id', type: 'text' as const }, + placed_on: { name: 'placed_on', type: 'date' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + opens_at: { name: 'opens_at', type: 'time' as const }, + }, +}; + +const at = (iso: string) => Date.parse(iso); + +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. */ +const DATETIME_OUT: ReadonlyArray = [ + ['year 10000, a number', Y10000], + ['year 10000, a Date', new Date(Y10000)], + ['year 10000, its ISO string', '+010000-01-01T00:00:00.000Z'], + ['year 10000, a bare extended day', '10000-01-01'], + ['year -1, a number', YNEG1], + ['year -1, its ISO string', '-000001-01-01T00:00:00.000Z'], + ['year 0, a number', Y0], + ['year 0, a Date', new Date(Y0)], + ['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'], +]; + +/** `date` comparands in year 0 — every spelling. */ +const DATE_OUT: ReadonlyArray = [ + ['year 0, a number', Y0], + ['year 0, a Date', new Date(Y0)], + ['year 0, its ISO string', '0000-06-15T00:00:00.000Z'], + ['year 0, a bare day', '0000-06-15'], +]; + +/** 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')), + '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', +]; + +/** A driver that records every read and write, and answers none. */ +function makeRecordingDriver() { + const reads: unknown[] = []; + const writes: unknown[] = []; + const driver: any = { + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, ast: unknown) { reads.push(ast); return []; }, + async findOne(_o: string, ast: unknown) { reads.push(ast); return { id: 'r1' }; }, + async count(_o: string, ast: unknown) { reads.push(ast); return 0; }, + async aggregate(_o: string, ast: unknown) { reads.push(ast); return []; }, + async create(_o: string, data: Record) { writes.push(data); return { ...data }; }, + async update(_o: string, id: string, data: Record) { writes.push(data); return { ...data, id }; }, + async updateMany(_o: string, _ast: unknown, data: Record) { writes.push(data); return 0; }, + async delete() { return true; }, + async deleteMany() { return 0; }, + async bulkCreate(_o: string, batch: Record[]) { writes.push(...batch); return batch; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads, writes }; +} + +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', () => { + let engine: ObjectQL; + let reads: unknown[]; + + beforeEach(async () => { + const rec = makeRecordingDriver(); + reads = rec.reads; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject(ledger, 'test'); + }); + + const positions = (field: string, comparand: unknown, op: string) => [ + ['where', () => engine.find('ledger', { where: { [field]: { [op]: comparand } } })], + ['per-aggregation filter', () => engine.aggregate('ledger', { + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { [field]: { [op]: comparand } } }], + } as never)], + ['having', () => engine.aggregate('ledger', { + groupBy: ['customer_id'], + aggregations: [{ function: 'min', field, alias: 'first' }], + having: { first: { [op]: comparand } }, + } as never)], + ] as const; + + for (const [field, kind, OUT, IN] of [ + ['opened_at', 'datetime', DATETIME_OUT, DATETIME_IN], + ['placed_on', 'date', DATE_OUT, DATE_IN], + ] as const) { + it(`${kind}: INVALID_FILTER / 400 with code AND status and no read — while the edges and a 2026 value reach the driver`, async () => { + for (const [name, comparand] of OUT) { + for (const op of ['$gt', '$lt', '$eq'] as const) { + for (const [position, call] of positions(field, comparand, op)) { + const err = await refusalOf(call()); + expect(err, `${position} ${name} ${op}`).not.toBeNull(); + expect(err!.code, `${position} ${name} ${op}`).toBe('INVALID_FILTER'); + expect(err!.status, `${position} ${name} ${op}`).toBe(400); + } + } + } + expect(reads, 'no read — every refusal precedes the driver').toHaveLength(0); + + // ── the POSITIVE CONTROL: the same positions, a supported year ───────── + for (const comparand of IN) { + for (const [position, call] of positions(field, comparand, '$gt')) { + const before = reads.length; + await expect(call(), `${position} ${String(comparand)}`).resolves.toBeDefined(); + expect(reads.length, `${position} ${String(comparand)} reached the driver`).toBeGreaterThan(before); + } + } + }); + } + + it('a list member, a range bound, the implicit-equality slot and a nested branch are judged too', async () => { + const out = '+010000-01-01T00:00:00.000Z'; + const inside = '2026-02-01T10:00:00.000Z'; + for (const where of [ + { opened_at: out }, + { opened_at: { $in: [inside, out] } }, + { opened_at: { $between: [inside, out] } }, + { $or: [{ customer_id: 'x' }, { $not: { opened_at: { $gte: new Date(Y0) } } }] }, + [['opened_at', '>', Y10000]], + ]) { + const err = await refusalOf(engine.find('ledger', { where: where as never })); + 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([]); + expect(reads).toHaveLength(1); + }); + + it('a time field judges no year — its rule keeps a time of day', async () => { + for (const comparand of [Y10000, new Date(Y0), '0000-06-15T10:00:00.000Z']) { + await expect(engine.find('ledger', { where: { opens_at: { $gt: comparand } } }), String(comparand)).resolves.toEqual([]); + } + expect(reads).toHaveLength(3); + }); + + it('the refusal names the year class, not "compares false for every row"', async () => { + 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).not.toContain('compare false for EVERY row'); + }); +}); + +describe('[#20264] the write door — a date or datetime value outside 0001..9999 is VALIDATION_FAILED, before any write', () => { + let engine: ObjectQL; + let writes: unknown[]; + + beforeEach(async () => { + const rec = makeRecordingDriver(); + writes = rec.writes; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject(ledger, 'test'); + }); + + // field · value — each one a 201 on memory and SQLite at the base (the date + // extended ISO stored verbatim, a non-day) and a 500 on PostgreSQL. + const REFUSED: ReadonlyArray = [ + ['placed_on', '+010000-01-01T00:00:00.000Z'], + ['placed_on', '-000001-01-01T00:00:00.000Z'], + ['placed_on', '0000-06-15'], + ['placed_on', new Date(Y10000)], + ['placed_on', new Date(Y0)], + ['opened_at', '+010000-01-01T00:00:00.000Z'], + ['opened_at', '-000001-01-01T00:00:00.000Z'], + ['opened_at', '0000-06-15T10:00:00.000Z'], + ['opened_at', '9999-12-31T23:59:59-01:00'], + ['opened_at', new Date(YNEG1)], + ]; + // The edges, and a 2026 control, on both kinds. + const ACCEPTED: ReadonlyArray = [ + ['placed_on', '0001-01-01'], + ['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'], + ['opened_at', '9999-12-31T23:59:59.999Z'], + ['opened_at', '0099-03-04T10:00:00.000Z'], + ['opened_at', '2026-02-01T10:00:00.000Z'], + ]; + + const doors = (field: string, value: unknown) => [ + ['insert', () => engine.insert('ledger', { id: 'n1', [field]: value })], + ['update', () => engine.update('ledger', { id: 'r1', [field]: value })], + ['multi-row update', () => engine.update('ledger', { [field]: value }, { where: { customer_id: 'c1' }, multi: true })], + ] as const; + + it('refuses each on insert, update and a multi-row update, with the field and invalid_date — and writes nothing', async () => { + for (const [field, value] of REFUSED) { + const label = `${field} ${value instanceof Date ? `Date ${value.toISOString()}` : JSON.stringify(value)}`; + for (const [door, call] of doors(field, value)) { + const err = await refusalOf(call()); + expect(err, `${door}, ${label}`).not.toBeNull(); + expect(err!.code, `${door}, ${label}`).toBe('VALIDATION_FAILED'); + expect(err!.fields, `${door}, ${label}`).toEqual([expect.objectContaining({ field, code: 'invalid_date' })]); + } + } + expect(writes, 'no write — every refusal precedes the driver').toHaveLength(0); + }); + + it('the dry-run validate predicts each refusal — and each accepted value as valid', async () => { + for (const [field, value] of REFUSED) { + const verdict = await engine.validate('ledger', { [field]: value }); + expect(verdict.valid, `${field} ${String(value)}`).toBe(false); + expect(verdict.results[0]!.errors, `${field} ${String(value)}`) + .toEqual([expect.objectContaining({ field, code: 'invalid_date' })]); + } + for (const [field, value] of ACCEPTED) { + expect((await engine.validate('ledger', { [field]: value })).valid, `${field} ${String(value)}`).toBe(true); + } + }); + + it('accepts the edges and a 2026 value on every door — the POSITIVE CONTROL', async () => { + for (const [field, value] of ACCEPTED) { + for (const [door, call] of doors(field, value)) { + const before = writes.length; + await expect(call(), `${door}, ${field} ${String(value)}`).resolves.toBeDefined(); + expect(writes.length, `${door}, ${field} ${String(value)} reached the driver`).toBe(before + 1); + } + } + }); +}); diff --git a/packages/objectql/src/temporal-comparand-door.ts b/packages/objectql/src/temporal-comparand-door.ts index 3ee7abf0c72..10cec7ee0a0 100644 --- a/packages/objectql/src/temporal-comparand-door.ts +++ b/packages/objectql/src/temporal-comparand-door.ts @@ -98,10 +98,24 @@ * - **Non-string comparands are not judged, save one class.** A number is * epoch milliseconds and a `Date` is an instant; the `datetime` and `time` * rules read both. [#20240] On a `date` field, one whose UTC calendar day - * falls in a year below 0 or above 9999 has no `YYYY-MM-DD` form, so it is - * refused here in its own words (below); every other number and `Date` is - * read as before. The #8690 ruling scoped THAT change to strings; it did not - * rule non-strings out of this door. + * falls in a year outside the four-digit ones has no `YYYY-MM-DD` form, so + * it is refused here in its own words (below); every other number and `Date` + * 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. * * ## [#20263] The third position: `having` * @@ -136,6 +150,7 @@ */ import { + isOutsideTemporalYearRange, isUninterpretableTemporalComparand, temporalComparandKind, type TemporalComparandKind, @@ -150,8 +165,8 @@ export interface UninterpretableTemporalComparand { field: string; kind: TemporalComparandKind; /** - * A non-empty string — or [#20240], on a `date` field, a number or `Date` - * whose UTC year falls outside 0..9999. + * A non-empty string — or, on a `date` or `datetime` field, a number or + * `Date` whose year falls outside 0001..9999 ([#20240], [#20264]). */ value: unknown; /** The `where.…` (or `having.…`, `aggregations[i].filter.…`) key path the offending comparand sits at. */ @@ -336,13 +351,35 @@ const REMEDY: Record = { }; /** - * [#20240] The remedy for a number or `Date` on a `date` field whose day has no - * four-digit year: the caller holds an instant, so name the forms that carry - * one the field can compare. + * [#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. */ -const DATE_YEAR_REMEDY = - 'Write a "YYYY-MM-DD" calendar day, or an epoch-millisecond number or Date whose UTC ' - + 'calendar day falls in a four-digit year.'; +const YEAR_CLASS: Record<'date' | 'datetime', { year: string; misorder: string; remedy: string }> = { + date: { + year: 'whose calendar day falls outside the years 0001 to 9999', + misorder: '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.', + }, +}; + +/** + * [#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 { + if (hit.kind === 'time' || !isOutsideTemporalYearRange(hit.value, hit.kind)) return undefined; + return YEAR_CLASS[hit.kind]; +} /** * Refuse every comparand a declared temporal field's storage rule cannot read. @@ -366,19 +403,18 @@ export function assertTemporalComparandsInterpretable( ): void { const hit = findUninterpretableTemporalComparand(schema, where, path); if (!hit) return; - // [#20240] A number or `Date` is judged on a `date` field for one reason - // only — its day's year has no four-digit spelling — so it gets words that - // say so. It does not compare false for every row as junk does: its text - // orders as no day does, so it answers the WRONG rows (or, on PostgreSQL, a - // database error). - if (typeof hit.value !== 'string') { + // [#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). + const yearClass = yearClassOf(hit); + if (yearClass) { throw invalidFilterError( - `${operation}('${object}'): filter on '${hit.field}' compares a declared date field against ` - + `${preview(hit.value)} at ${hit.path}, an instant whose UTC calendar day falls outside the ` - + 'years 0000 to 9999, the only years a "YYYY-MM-DD" day can spell, so it is not a date value ' - + 'this platform can interpret. It would reach the driver in a form that does not sort as a ' - + 'day and answer the wrong rows, or a database error. The filter was NOT applied. ' - + DATE_YEAR_REMEDY, + `${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}`, ); } throw invalidFilterError( @@ -442,14 +478,15 @@ export function assertHavingTemporalComparandsInterpretable( if (!hit) return; const column = `\`having\` on '${hit.field}' (${havingColumnSource(hit.field, query.groupBy, query.aggregations)}, ` + `a ${hit.kind} column)`; - // The `date` year class, in its own words, as on `where` (#20240). - if (typeof hit.value !== 'string') { + // The year class, in its own words, as on `where` (#20240, #20264). + const yearClass = yearClassOf(hit); + if (yearClass) { throw invalidFilterError( - `aggregate('${object}'): ${column} compares against ${preview(hit.value)} at ${hit.path}, an ` - + 'instant whose UTC calendar day falls outside the years 0000 to 9999, the only years a ' - + '"YYYY-MM-DD" day can spell, so it is not a date value this platform can interpret. Compared ' - + 'with each group, it would order as no day does and keep the wrong groups. The `having` was ' - + `NOT applied. ${DATE_YEAR_REMEDY}`, + `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. ` + + yearClass.remedy, ); } throw invalidFilterError( diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 84ef8a2e764..a7fd6b043aa 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -52,7 +52,7 @@ * - 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 + * - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999 * * System-injected fields (`id`, `created_at`, `created_by`, * `updated_at`, `updated_by`, and provenance-flagged `system`/`readonly` @@ -82,6 +82,7 @@ import { percentScaleOf, } from '@objectstack/spec/data'; import type { FieldErrorCode } from '@objectstack/spec/api'; +import { isOutsideTemporalYearRange } from '@objectstack/core'; import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared'; import { renderValidationMessage, @@ -1102,8 +1103,16 @@ function validateOne( // ── date/datetime ─────────────────────────────────────────────── if (t === 'date' || t === 'datetime') { - if (value instanceof Date) return null; - if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return null; + const readable = value instanceof Date || (typeof value === 'string' && !Number.isNaN(Date.parse(value))); + // [#20264] …and names a year from 0001 to 9999, the range the + // temporal-comparand door holds a comparand to — one function, + // `@objectstack/core`'s `isOutsideTemporalYearRange`, answers both doors. + // 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 PostgreSQL on both kinds. Same code and words as any other + // value that is not a valid date. + if (readable && !isOutsideTemporalYearRange(value, t)) return null; // Same wire code, two sentences: "a valid date" vs "a valid datetime". return fail('invalid_date', { type: t }, t === 'datetime' ? 'invalid_datetime' : '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 ae4de6d47dd..03108b2919b 100644 --- a/packages/rest/src/data-query-date-year-range.test.ts +++ b/packages/rest/src/data-query-date-year-range.test.ts @@ -171,9 +171,9 @@ describe('[#20240] a four-digit year — the number, the Date and the ISO string } }); -describe('[#20240] a year outside 0..9999 — the number and the Date are refused as their ISO string is, before any read', () => { +describe('[#20240] a year outside the four-digit years — the number and the Date are refused as their ISO string is, before any read', () => { for (const [day, ms] of [['10000-01-01', Y10000], ['-1-01-01', YNEG1]] as const) { - it(`${day}: INVALID_FILTER / 400 at where and at the per-aggregation filter, on both doors; the datetime control is read`, async () => { + it(`${day}: INVALID_FILTER / 400 at where and at the per-aggregation filter, on both doors — on a datetime too [#20264]; a 2026 instant is read`, async () => { const { engine, post, reads } = await boot(); for (const op of ['$gt', '$lt', '$eq'] as const) { for (const [form, comparand] of [['number', ms], ['Date', new Date(ms)], ['ISO string', new Date(ms).toISOString()]] as const) { @@ -200,11 +200,17 @@ describe('[#20240] a year outside 0..9999 — the number and the Date are refuse } expect(reads.n, 'no read of the object — the refusal precedes the driver').toBe(0); - // Control: the same numbers on a `datetime` field are instants its rule reads. + // [#20264] The same numbers on a `datetime` field are refused too — the + // supported years 0001..9999 hold for both kinds. for (const comparand of [ms, new Date(ms)]) { - await expect(engine.find(OBJECT, { where: { opened_at: { $gt: comparand } } })).resolves.toBeDefined(); + const err = await refusalOf(engine.find(OBJECT, { where: { opened_at: { $gt: comparand } } })); + expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); } - const control = await post({ where: { opened_at: { $gt: ms } } }); + const refused = await post({ where: { opened_at: { $gt: ms } } }); + expect(refused._status).toBe(400); + expect(reads.n).toBe(0); + // Control: a 2026 instant on the same field is read. + const control = await post({ where: { opened_at: { $gt: N } } }); expect(control._status ?? 200, JSON.stringify(control._json)).toBe(200); expect(reads.n).toBeGreaterThan(0); }); diff --git a/packages/rest/src/data-temporal-year-range.test.ts b/packages/rest/src/data-temporal-year-range.test.ts new file mode 100644 index 00000000000..9284f2ec811 --- /dev/null +++ b/packages/rest/src/data-temporal-year-range.test.ts @@ -0,0 +1,285 @@ +// 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 + * `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 + * `SqlDriver`, with the range's edges and a 2026 control read beside them. + * + * Measured on the base (`b285508188`) through this door, seven 2026 rows: + * + * | position | input | SQLite | PostgreSQL 16 | MySQL 8.0 | + * |:--|:--|:--|:--|:--| + * | `where` on a `datetime`, `$gt` / `$lt` / `$eq` | year 10000 or −1, a number or ISO string | 7 / 0 / 0 | 500 | 500 | + * | `where` on a `datetime` | year 0, a number or ISO string | 7 / 0 / 0 | 500 | 7 / 0 / 0 | + * | `where` on a `date` | year 0, a number, ISO string or bare day | 7 / 0 / 0 | 500 | 7 / 0 / 0 | + * | create a `date` | `"+010000-01-01T00:00:00.000Z"` | 201, read back verbatim | 500 | 500 | + * | create a `date` / `datetime` | year 0 | 201 | 500 | 201 | + * + * (InMemoryDriver answered as SQLite. The right `where` answer for year 10000 + * is 0 / 7 / 0, and a stored `date` is a day.) The doors sit in the engine, in + * front of every driver, so one verdict holds on each cell; the engine-level + * pin with a recording driver is `packages/objectql/src/engine-temporal-year-range.test.ts`. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL and MySQL cells run where + * `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set and are a named skip + * otherwise; no CI job provisions them for this package (the live servers are + * attached to `driver-sql`'s suite, where the range's edges are pinned per + * 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. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest_year_20264'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20264', + fields: { + customer_id: { name: 'customer_id', type: 'text' as const }, + placed_on: { name: 'placed_on', type: 'date' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + }, +}; + +const ROWS = [ + { id: 'o1', customer_id: 'c1', placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z' }, + { id: 'o2', customer_id: 'c1', placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z' }, + { id: 'o3', customer_id: 'c2', placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z' }, + { id: 'o4', customer_id: 'c2', placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z' }, + { id: 'o5', customer_id: 'c2', placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z' }, + { id: 'o6', customer_id: 'c3', placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z' }, + { id: 'o7', customer_id: 'c4', placed_on: '2026-04-01', opened_at: '2026-04-01T10:00:00.000Z' }, +]; + +const at = (iso: string) => Date.parse(iso); + +interface Cell { + id: 'sqlite' | 'pg' | 'mysql'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, + { + id: 'mysql', + label: 'live mysql', + env: 'OS_TEST_MYSQL_URL', + config: () => (process.env.OS_TEST_MYSQL_URL ? { client: 'mysql2', connection: process.env.OS_TEST_MYSQL_URL } : null), + }, +]; + +/** field · comparand (as the wire carries it) — refused on every position */ +const REFUSED: ReadonlyArray = [ + ['datetime, year 10000, a number', 'opened_at', at('+010000-01-01T00:00:00.000Z')], + ['datetime, year 10000, its ISO string', 'opened_at', '+010000-01-01T00:00:00.000Z'], + ['datetime, year -1, a number', 'opened_at', at('-000001-01-01T00:00:00.000Z')], + ['datetime, year -1, its ISO string', 'opened_at', '-000001-01-01T00:00:00.000Z'], + ['datetime, year 0, a number', 'opened_at', at('0000-06-15T00:00:00.000Z')], + ['datetime, year 0, its ISO string', 'opened_at', '0000-06-15T00:00:00.000Z'], + ['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'], +]; + +/** 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 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, 9999-12-31', 'placed_on', '9999-12-31', [0, 7, 0]], + ['date, 2026-02-01 (control)', 'placed_on', '2026-02-01', [2, 3, 2]], +]; + +/** field · written value — refused at the write door */ +const WRITE_REFUSED: ReadonlyArray = [ + ['placed_on', '+010000-01-01T00:00:00.000Z'], + ['placed_on', '-000001-01-01T00:00:00.000Z'], + ['placed_on', '0000-06-15'], + ['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'], +]; + +/** field · written value · what reads back — the edges and the 2026 control */ +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'], + ['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'], +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const perAggregation = (filter: FilterCondition): EngineAggregateOptions => ({ + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter }], +}); +const grouped = (field: string, having: FilterCondition): EngineAggregateOptions => ({ + groupBy: ['customer_id'], + aggregations: [{ function: 'min', field, alias: 'first' }], + having, +}); + +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)`}`, + () => { + let engine: ObjectQL; + let driver: any; + const reads = { n: 0 }; + const writes = { n: 0 }; + let call: (method: string, path: string, params: Record, body: unknown) => Promise<{ status: number; body: any }>; + const query = (body: Record) => + call('POST', '/api/v1/data/:object/query', { object: OBJECT }, JSON.parse(JSON.stringify(body))); + + beforeAll(async () => { + driver = new SqlDriver(config as any); + if (cell.id !== 'sqlite') await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + // Reads and writes of THIS object — the protocol's own metadata traffic is not the question. + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) reads.n += 1; return real(o, ...rest); }; + } + for (const verb of ['create', 'update', 'bulkCreate', 'updateMany'] as const) { + if (typeof driver[verb] !== 'function') continue; + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) writes.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + call = async (method, path, params, body) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + if (cell.id !== 'sqlite') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + 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 () => { + const before = reads.n; + for (const [name, field, comparand] of REFUSED) { + for (const op of ['$gt', '$lt', '$eq'] as const) { + const where = { [field]: { [op]: comparand } } as FilterCondition; + for (const [position, body] of [ + ['where', { where }], + ['filter', perAggregation(where)], + ['having', grouped(field, { first: { [op]: comparand } } as FilterCondition)], + ] as const) { + const res = await query(body as Record); + expect(res.status, `${position}, ${name}, ${op}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body.code, `${position}, ${name}, ${op}`).toBe('INVALID_FILTER'); + } + } + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('the edges and the 2026 control are read — the same counts at where and at the per-aggregation filter', async () => { + for (const [name, field, comparand, counts] of READ) { + for (const [i, op] of (['$gt', '$lt', '$eq'] as const).entries()) { + const where = { [field]: { [op]: comparand } } as FilterCondition; + const w = await query({ where }); + expect(w.status, `where, ${name}, ${op}: ${JSON.stringify(w.body)}`).toBe(200); + expect(w.body.records.length, `where, ${name}, ${op}`).toBe(counts[i]); + const f = await query(perAggregation(where) as Record); + expect(f.status, `filter, ${name}, ${op}`).toBe(200); + expect(Number(f.body.records[0]?.m), `filter, ${name}, ${op}`).toBe(counts[i]); + const h = await query(grouped(field, { first: { [op]: comparand } } as FilterCondition) as Record); + expect(h.status, `having, ${name}, ${op}`).toBe(200); + } + } + }); + + it('a written value outside 0001..9999 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 }); + expect(created.status, `create ${field} ${value}: ${JSON.stringify(created.body)}`).toBe(400); + expect(created.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(created.body.fields.map((x: any) => [x.field, x.code]), `create ${field} ${value}`).toEqual([[field, 'invalid_date']]); + const patched = await call('PATCH', '/api/v1/data/:object/:id', { object: OBJECT, id: 'o1' }, { [field]: value }); + expect(patched.status, `PATCH ${field} ${value}: ${JSON.stringify(patched.body)}`).toBe(400); + expect(patched.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + } + expect(writes.n - before, 'no write — every refusal precedes the driver').toBe(0); + const o1 = await query({ where: { id: 'o1' } }); + expect(o1.body.records[0]).toMatchObject({ placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z' }); + }); + + it('the edges and the 2026 control are written and read back as written', async () => { + for (const [i, [field, value, readBack]] of WRITE_ACCEPTED.entries()) { + const id = `w${i}`; + 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); + } + }); + }, + ); +}