From d3a7458637218404597659fd225acb8276625b5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:07:57 +0000 Subject: [PATCH 1/6] fix(core,objectql): pad a date comparand's year to four digits; refuse a number or Date whose UTC year falls outside 0..9999 on a date field WIP: source change only; pins follow. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- packages/core/src/utils/temporal-comparand.ts | 63 ++++++++++++++++--- .../core/src/utils/temporal-storage-form.ts | 26 ++++++-- .../objectql/src/temporal-comparand-door.ts | 57 +++++++++++++---- 3 files changed, 121 insertions(+), 25 deletions(-) diff --git a/packages/core/src/utils/temporal-comparand.ts b/packages/core/src/utils/temporal-comparand.ts index 6c4996d480c..912a0609ab3 100644 --- a/packages/core/src/utils/temporal-comparand.ts +++ b/packages/core/src/utils/temporal-comparand.ts @@ -44,9 +44,19 @@ * if the door let it through — `temporalStorageForm` (`temporal-storage-form.ts`, * the one rule both drivers read since #20176; before it, a copy in each). * That function is total on purpose: an input it cannot interpret is returned - * UNCHANGED rather than becoming an invented instant. So "the driver would - * return it unchanged" IS the definition of uninterpretable, and defining it - * any other way would refuse comparands that work today. + * UNCHANGED rather than becoming an invented instant. So, for a STRING, "the + * driver would return it unchanged" IS the definition of uninterpretable, and + * defining it any other way would refuse comparands that work today. + * + * [#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 + * `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. * * That is why this is not `utcInstantMs` (`@objectstack/spec/data`), which is * the stricter canonical reader: it rejects a bare epoch-millisecond string and @@ -56,8 +66,18 @@ * * ## Two things it deliberately does NOT judge * - * - **Non-string comparands.** A number is epoch milliseconds, a `Date` is an - * instant, `null` is a null test. The refusal scopes to strings by ruling. + * - **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 + * 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. + * `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`). * - **The EMPTY string.** Measured, `$gte ""` binds as `''` and every canonical * UTC text sorts at or above it, so it returns every non-null row — a third * behaviour again, and one the maintainer ruled stays its own card: "B and C @@ -130,13 +150,35 @@ 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` ONLY for a non-empty, non-placeholder STRING that the kind's rule - * would hand back unchanged. Everything else — a number, a `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. + * `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 + * string, a `{token}` — answers `false`, each for a reason recorded in the + * module note or below. * * A `{placeholder}` is stepped around rather than judged because it is another * layer's vocabulary and that layer already refuses the unknown ones loudly @@ -149,6 +191,9 @@ export function isUninterpretableTemporalComparand( kind: TemporalComparandKind, value: unknown, ): boolean { + if (kind === 'date' && (typeof value === 'number' || value instanceof Date)) { + return isOutsideCalendarDayYears(value); + } if (typeof value !== 'string') return false; const s = value.trim(); // The empty-string cell is its own card — see the module note. diff --git a/packages/core/src/utils/temporal-storage-form.ts b/packages/core/src/utils/temporal-storage-form.ts index 61a6c0861ae..123c85a117b 100644 --- a/packages/core/src/utils/temporal-storage-form.ts +++ b/packages/core/src/utils/temporal-storage-form.ts @@ -50,9 +50,12 @@ * `null` / `undefined`, the empty string, an out-of-range wall clock * (`'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 strings are - * uninterpretable is the question `isUninterpretableTemporalComparand` - * (`temporal-comparand.ts`) answers for the doors that refuse them. + * 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 + * `isUninterpretableTemporalComparand` (`temporal-comparand.ts`) answers for + * the doors that refuse them. */ import type { TemporalComparandKind } from './temporal-comparand.js'; @@ -72,7 +75,12 @@ import type { TemporalComparandKind } from './temporal-comparand.js'; * - `date`: a `Date` → its UTC calendar day (the UTC clock, never the host's — * `SqlDriver.toDateOnly` records why); a finite number → epoch milliseconds, * 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`. + * 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. * - `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. @@ -124,10 +132,18 @@ function canonicalCalendarDay(value: unknown): unknown { const instant = typeof value === 'number' && Number.isFinite(value) ? new Date(value) : value; if (instant instanceof Date) { if (Number.isNaN(instant.getTime())) return value; + // [#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`). const y = instant.getUTCFullYear(); + const yyyy = y >= 0 ? 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 `${y}-${m}-${d}`; + return `${yyyy}-${m}-${d}`; } if (typeof value === 'string') { const trimmed = value.trim(); diff --git a/packages/objectql/src/temporal-comparand-door.ts b/packages/objectql/src/temporal-comparand-door.ts index c8c3fcfe294..6826ab17418 100644 --- a/packages/objectql/src/temporal-comparand-door.ts +++ b/packages/objectql/src/temporal-comparand-door.ts @@ -85,18 +85,23 @@ * * ## Scope — three boundaries, each ruled rather than chosen here * - * - **Non-empty strings only.** The empty-string cell stays its own card by - * ruling ("B and C scope to non-empty strings and must not decide it in - * passing"); measured, `$gte ""` binds as `''` and returns every non-null row - * — 51 of 51, not the 38 the card's table records, which is a transcription - * error its own prose corrects. + * - **Non-empty strings only, among strings.** The empty-string cell stays its + * own card by ruling ("B and C scope to non-empty strings and must not decide + * it in passing"); measured, `$gte ""` binds as `''` and returns every + * non-null row — 51 of 51, not the 38 the card's table records, which is a + * transcription error its own prose corrects. * - **`{placeholder}` strings are stepped around**, not judged. This gate runs * BEFORE `resolveWhereTokens` (which is where it must run — the refusal has * to precede the driver), so judging one would refuse `{30_days_ago}`, the * platform's own correct spelling. Unknown tokens keep their existing loud * refusal one layer down. - * - **Non-string comparands are not judged.** A number is epoch milliseconds - * and a `Date` is an instant; both are read correctly today. + * - **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. * * @see `@objectstack/core`'s `temporal-comparand.ts` — the value-half predicate, * shared with the analytics raw-SQL decline so one rule cannot exist twice. @@ -114,7 +119,11 @@ import { invalidFilterError } from './filter-comparand-shape.js'; export interface UninterpretableTemporalComparand { field: string; kind: TemporalComparandKind; - value: string; + /** + * A non-empty string — or [#20240], on a `date` field, a number or `Date` + * whose UTC year falls outside 0..9999. + */ + value: unknown; /** The `where.…` key path the offending comparand sits at. */ path: string; } @@ -233,12 +242,14 @@ function judgeComparand( ): UninterpretableTemporalComparand | null { if (isFieldReference(value)) return null; if (!isUninterpretableTemporalComparand(kind, value)) return null; - return { field, kind, value: value as string, path }; + return { field, kind, value, path }; } /** A short, bounded rendering — the comparand came off the wire (#5869's bound). */ -function preview(value: string): string { - const text = JSON.stringify(value); +function preview(value: unknown): string { + const text = value instanceof Date + ? `Date ${Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString()}` + : typeof value === 'string' ? JSON.stringify(value) : String(value); return text.length > 60 ? `${text.slice(0, 59)}…` : text; } @@ -261,6 +272,15 @@ const REMEDY: Record = { 'Write an "HH:MM" / "HH:MM:SS" wall clock (timezone-naive, ADR-0053 D-C1).', }; +/** + * [#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. + */ +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.'; + /** * Refuse every comparand a declared temporal field's storage rule cannot read. * @@ -278,6 +298,21 @@ export function assertTemporalComparandsInterpretable( ): void { const hit = findUninterpretableTemporalComparand(schema, where); 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') { + 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, + ); + } throw invalidFilterError( `${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} ` + `field against ${preview(hit.value)} at ${hit.path}, which is not a ${hit.kind} value ` From 4ca1ca5ce2d0695fb47bbb330bbf2d36732b9366 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:16:27 +0000 Subject: [PATCH 2/6] test(core,objectql,drivers,rest): pin the four-digit date year and the out-of-range refusal on memory, SQLite, PostgreSQL, engine and REST Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../core/src/utils/temporal-comparand.test.ts | 105 +++++++++ .../src/utils/temporal-storage-form.test.ts | 42 ++++ .../memory-20240-date-year-spelling.test.ts | 95 ++++++++ ...ql-driver-20240-date-year-spelling.test.ts | 114 ++++++++++ .../src/engine-date-year-range-door.test.ts | 210 +++++++++++++++++ .../src/data-query-date-year-range.test.ts | 212 ++++++++++++++++++ 6 files changed, 778 insertions(+) create mode 100644 packages/core/src/utils/temporal-comparand.test.ts create mode 100644 packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts create mode 100644 packages/objectql/src/engine-date-year-range-door.test.ts create mode 100644 packages/rest/src/data-query-date-year-range.test.ts diff --git a/packages/core/src/utils/temporal-comparand.test.ts b/packages/core/src/utils/temporal-comparand.test.ts new file mode 100644 index 00000000000..c284c1b098f --- /dev/null +++ b/packages/core/src/utils/temporal-comparand.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20240] `isUninterpretableTemporalComparand` on a `date` column judges one +// non-string class: a finite number or a `Date` whose UTC calendar day falls in +// a year below 0 or above 9999. Such a day has no `YYYY-MM-DD` spelling, so the +// storage rule's text for it (`10000-01-01`, `-1-01-01`) orders as no day does. +// Measured over REST before this, the number for 10000-01-01 counted `$gt` 6 / +// `$lt` 1 on driver-memory and SQLite (the day's answer is 0 / 7) and the +// number for -1-01-01 answered 500 on PostgreSQL, while the ISO string of either +// 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. + +import { describe, it, expect } from 'vitest'; +import { isUninterpretableTemporalComparand } from './temporal-comparand.js'; +import { temporalStorageForm } from './temporal-storage-form.js'; + +const at = (iso: string) => Date.parse(iso); + +/** Finite numbers whose UTC calendar day falls outside the four-digit years. */ +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')], + ['the Date range maximum', 8.64e15], + ['the Date range minimum', -8.64e15], +]; + +/** Numbers past the `Date` range — an instant with a year past ±271821. */ +const PAST_THE_DATE_RANGE: ReadonlyArray = [ + ['one past the maximum', 8.64e15 + 1], + ['one before the minimum', -8.64e15 - 1], + ['9e15', 9e15], + ['MAX_SAFE_INTEGER', Number.MAX_SAFE_INTEGER], +]; + +/** 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')], + ['0999-06-15', -30627504000000], + ['1000-01-01', at('1000-01-01T00:00:00.000Z')], + ['2026-02-01T10:00Z', 1769940000000], + ['the epoch', 0], + ['the last millisecond of year 9999', at('9999-12-31T23:59:59.999Z')], +]; + +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', () => { + 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); + } + }); + + it('refuses a finite number past the Date range — its year is past ±271821', () => { + for (const [name, ms] of PAST_THE_DATE_RANGE) { + expect(isUninterpretableTemporalComparand('date', ms), name).toBe(true); + } + }); + + it('reads every number and Date whose year has four digits, as before — the discriminating half', () => { + for (const [name, ms] of IN_RANGE) { + expect(isUninterpretableTemporalComparand('date', ms), `number, ${name}`).toBe(false); + expect(isUninterpretableTemporalComparand('date', new Date(ms)), `Date, ${name}`).toBe(false); + } + }); + + it('agrees with the rule: a finite number is refused exactly when the rule cannot spell it YYYY-MM-DD', () => { + for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) { + const spelled = temporalStorageForm(ms, 'date'); + const isDay = typeof spelled === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(spelled); + expect(isUninterpretableTemporalComparand('date', ms), name).toBe(!isDay); + } + }); + + it('joins the verdict the ISO string of the same instant already had', () => { + // The string arm refused these before this card; the number and the Date + // were the two spellings of the same instant it let through. + expect(isUninterpretableTemporalComparand('date', '+010000-01-01T00:00:00.000Z')).toBe(true); + 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); + }); + + 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('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); + } + }); + + it('judges nothing else that is not a string, as before', () => { + for (const value of [null, undefined, true, 1769940000000n, Object(1769940000000), {}, ['2026-02-01']]) { + expect(isUninterpretableTemporalComparand('date', value), String(value)).toBe(false); + } + }); +}); diff --git a/packages/core/src/utils/temporal-storage-form.test.ts b/packages/core/src/utils/temporal-storage-form.test.ts index a1c47354ac7..c714fdfff77 100644 --- a/packages/core/src/utils/temporal-storage-form.test.ts +++ b/packages/core/src/utils/temporal-storage-form.test.ts @@ -93,6 +93,48 @@ describe('temporalStorageForm — date: an epoch-ms number is the UTC calendar d } }); +// [#20240] A `Date` or a number whose UTC year is 0..999 spelled that year +// unpadded — `999-06-15` — while the ISO-string and bare-day arms spelled the +// 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. +describe('temporalStorageForm — date: the year of a Date or number is four digits', () => { + const at = (iso: string) => Date.parse(iso); + const cases: ReadonlyArray = [ + ['0999-06-15', -30627504000000], + ['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 + ]; + for (const [day, ms] of cases) { + it(`${day}: the number, its Date and its ISO string spell one day`, () => { + expect(temporalStorageForm(ms, 'date')).toBe(day); + expect(temporalStorageForm(new Date(ms), 'date')).toBe(day); + expect(temporalStorageForm(new Date(ms).toISOString(), 'date')).toBe(day); + }); + } + + it('the spellings sort as the days they name', () => { + const spelled = cases.map(([, ms]) => temporalStorageForm(ms, 'date') as string); + const chronological = [...cases].sort((a, b) => a[1] - b[1]).map(([day]) => day); + expect([...spelled].sort()).toEqual(chronological); + }); + + it('a year outside 0..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'); + }); +}); + describe('temporalStorageForm — time: the UTC wall clock, .fff only when non-zero', () => { const cases: ReadonlyArray = [ ['a short wall clock', '09:00', '09:00:00'], diff --git a/packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts b/packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts new file mode 100644 index 00000000000..05b12baa6cf --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20240] A `Date` or an epoch-millisecond number whose UTC year is 0..999 is + * put in a `date` column's form with a four-digit year — `0999-06-15` — by + * `@objectstack/core`'s `temporalStorageForm`, the rule `coerceTemporalValue` + * calls on `where` and on write. That is the spelling the ISO string and the + * bare day of the same instant already took. + * + * Measured on the base through this driver's `find()`, and through the engine + * and `POST /api/v1/data/:object/query` over it (which reach the same + * coercion), on six 2026 days plus 0999-06-15: the number and the `Date` for + * 0999-06-15 spelled `999-06-15`, which sorts above every padded day as text, + * so `$gt` / `$lt` / `$eq` counted 0 / 7 / 0 where the ISO string counted + * 6 / 0 / 1. A direct `create` / `update` of either stored `999-06-15`, which no + * `0999-06-15` comparand then matched. + * + * A year below 0 or above 9999 is refused as a comparand one layer up, at the + * engine's temporal-comparand door (`@objectstack/objectql`); this driver's + * `where` is not that door, so it is not pinned here. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; + +const OBJECT = 'ledger_year'; +const WRITES = 'ledger_year_writes'; +const FIELDS = { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } }; + +const ROWS = [ + { id: 'o1', placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z' }, + { id: 'o2', placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z' }, + { id: 'o3', placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z' }, + { id: 'o4', placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z' }, + { id: 'o5', placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z' }, + { id: 'o6', placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z' }, + { id: 'o7', placed_on: '0999-06-15', opened_at: '2026-04-01T10:00:00.000Z' }, +]; + +const Y0999 = -30627504000000; // 0999-06-15T00:00:00.000Z +const N = 1769940000000; // 2026-02-01T10:00:00.000Z — the control + +const ALL = ['o1', 'o2', 'o3', 'o4', 'o5', 'o6', 'o7']; + +/** day · operator · ids — one answer for the number, its `Date` and its ISO string */ +const CELLS: ReadonlyArray = [ + ['0999-06-15', Y0999, '$gt', ['o1', 'o2', 'o3', 'o4', 'o5', 'o6']], + ['0999-06-15', Y0999, '$lt', []], + ['0999-06-15', Y0999, '$eq', ['o7']], + ['0999-06-15', Y0999, '$gte', ALL], + ['0999-06-15', Y0999, '$ne', ['o1', 'o2', 'o3', 'o4', 'o5', 'o6']], + ['2026-02-01 (control)', N, '$gt', ['o3']], + ['2026-02-01 (control)', N, '$lt', ['o1', 'o2', 'o5', 'o7']], + ['2026-02-01 (control)', N, '$eq', ['o4', 'o6']], +]; + +describe('[#20240] a date field\'s year is four digits — the number, the Date and the ISO string agree', () => { + let driver: InMemoryDriver; + const ids = async (object: string, where: FilterCondition) => + (await driver.find(object, { where })).map((r) => r.id).sort(); + + beforeAll(async () => { + driver = new InMemoryDriver({}); + await driver.connect(); + for (const name of [OBJECT, WRITES]) await driver.syncSchema(name, { name, fields: FIELDS }); + for (const row of ROWS) await driver.create(OBJECT, { ...row }); + }); + + for (const [day, ms, op, expected] of CELLS) { + it(`${day} ${op}: ${expected.join(', ') || 'no row'}, for the number, its Date and its ISO string`, async () => { + expect(await ids(OBJECT, { placed_on: { [op]: ms } }), 'number').toEqual(expected); + expect(await ids(OBJECT, { placed_on: { [op]: new Date(ms) } }), 'Date').toEqual(expected); + expect(await ids(OBJECT, { placed_on: { [op]: new Date(ms).toISOString() } }), 'ISO string').toEqual(expected); + }); + } + + it('$in and $between read each member the same way', async () => { + expect(await ids(OBJECT, { placed_on: { $in: [Y0999, N] } })).toEqual(['o4', 'o6', 'o7']); + expect(await ids(OBJECT, { placed_on: { $between: [new Date(Y0999), N] } })) + .toEqual(['o1', 'o2', 'o4', 'o5', 'o6', 'o7']); + }); + + it('the write path stores the four-digit year — create and update, a number and a Date', async () => { + const read = async (id: string) => (await driver.findOne(WRITES, { where: { id } }))?.placed_on; + await driver.create(WRITES, { id: 'w1', placed_on: Y0999 }); + await driver.create(WRITES, { id: 'w2', placed_on: new Date(Y0999) }); + expect(await read('w1')).toBe('0999-06-15'); + expect(await read('w2')).toBe('0999-06-15'); + await driver.update(WRITES, 'w1', { placed_on: Date.parse('0009-03-04T00:00:00.000Z') }); + expect(await read('w1')).toBe('0009-03-04'); + // …so the stored day and a comparand for it are one text. + expect(await ids(WRITES, { placed_on: { $eq: '0999-06-15' } })).toEqual(['w2']); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts new file mode 100644 index 00000000000..497b7829fa9 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20240] A `Date` or an epoch-millisecond number whose UTC year is 0..999 is + * put in a `date` column's form with a four-digit year — `0999-06-15` — by + * `@objectstack/core`'s `temporalStorageForm`, the rule `toDateOnly` / + * `temporalFilterValue` call on `where` and on write. That is the spelling the + * ISO string and the bare day of the same instant already took. + * + * Measured on the base through `find()` (and through the engine and + * `POST /api/v1/data/:object/query`, which reach the same coercion), on six + * 2026 days plus 0999-06-15, for the number and the `Date` of 0999-06-15: + * + * | dialect | `$gt` / `$lt` / `$eq` | the ISO string | why | + * |:--|:--|:--|:--| + * | SQLite | 0 / 7 / 0 | 6 / 0 / 1 | `999-06-15` sorts above every padded day as TEXT (`'9' > '0'`) | + * | PostgreSQL | 6 / 0 / 1 | 6 / 0 / 1 | the server's `DATE` parser reads `999-06-15` as year 999 | + * + * A direct `create` / `update` stored `999-06-15` on SQLite (PostgreSQL stored + * the day). Each row below asserts one answer for the number, its `Date` and + * its ISO string, plus a 2026 control. A year below 0 or above 9999 is refused + * as a comparand one layer up, at the engine's temporal-comparand door + * (`@objectstack/objectql`); this driver's `where` is not that door. + */ + +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 = 'os20240_ledger'; +const WRITES = 'os20240_ledger_writes'; + +const shape = (name: string) => ({ + name, + fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } }, +}) as any; + +const ROWS = [ + { id: 'o1', placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z' }, + { id: 'o2', placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z' }, + { id: 'o3', placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z' }, + { id: 'o4', placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z' }, + { id: 'o5', placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z' }, + { id: 'o6', placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z' }, + { id: 'o7', placed_on: '0999-06-15', opened_at: '2026-04-01T10:00:00.000Z' }, +]; + +const Y0999 = -30627504000000; // 0999-06-15T00:00:00.000Z +const N = 1769940000000; // 2026-02-01T10:00:00.000Z — the control + +const ALL = ['o1', 'o2', 'o3', 'o4', 'o5', 'o6', 'o7']; + +/** day · operator · ids — one answer for the number, its `Date` and its ISO string */ +const CELLS: ReadonlyArray = [ + ['0999-06-15', Y0999, '$gt', ['o1', 'o2', 'o3', 'o4', 'o5', 'o6']], + ['0999-06-15', Y0999, '$lt', []], + ['0999-06-15', Y0999, '$eq', ['o7']], + ['0999-06-15', Y0999, '$gte', ALL], + ['0999-06-15', Y0999, '$ne', ['o1', 'o2', 'o3', 'o4', 'o5', 'o6']], + ['2026-02-01 (control)', N, '$gt', ['o3']], + ['2026-02-01 (control)', N, '$lt', ['o1', 'o2', 'o5', 'o7']], + ['2026-02-01 (control)', N, '$eq', ['o4', 'o6']], +]; + +const NO_AUDIT = { bypassTenantAudit: true }; + +function measure(cell: DialectCell): void { + describe(`[#20240] a date field's year is four digits — ${cell.label}`, () => { + let driver: SqlDriver; + const ids = async (table: string, where: FilterCondition) => + (await driver.find(table, { where }, NO_AUDIT)).map((r) => r.id).sort(); + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + for (const t of [TABLE, WRITES]) await driver.execute(`drop table if exists ${t}`).catch(() => {}); + await driver.initObjects([shape(TABLE), shape(WRITES)]); + for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT); + }); + + afterAll(async () => { + for (const t of [TABLE, WRITES]) await driver.execute(`drop table if exists ${t}`).catch(() => {}); + await driver.disconnect(); + }); + + for (const [day, ms, op, expected] of CELLS) { + it(`${day} ${op}: ${expected.join(', ') || 'no row'}, for the number, its Date and its ISO string`, async () => { + expect(await ids(TABLE, { placed_on: { [op]: ms } }), 'number').toEqual(expected); + expect(await ids(TABLE, { placed_on: { [op]: new Date(ms) } }), 'Date').toEqual(expected); + expect(await ids(TABLE, { placed_on: { [op]: new Date(ms).toISOString() } }), 'ISO string').toEqual(expected); + }); + } + + it('$in and $between read each member the same way', async () => { + expect(await ids(TABLE, { placed_on: { $in: [Y0999, N] } })).toEqual(['o4', 'o6', 'o7']); + expect(await ids(TABLE, { placed_on: { $between: [new Date(Y0999), N] } })) + .toEqual(['o1', 'o2', 'o4', 'o5', 'o6', 'o7']); + }); + + it('the write path stores the four-digit year — create and update, a number and a Date', async () => { + const read = async (id: string) => (await driver.findOne(WRITES, { where: { id } }, NO_AUDIT))?.placed_on; + await driver.create(WRITES, { id: 'w1', placed_on: Y0999 }, NO_AUDIT); + await driver.create(WRITES, { id: 'w2', placed_on: new Date(Y0999) }, NO_AUDIT); + expect(await read('w1')).toBe('0999-06-15'); + expect(await read('w2')).toBe('0999-06-15'); + await driver.update(WRITES, 'w1', { placed_on: Date.parse('0009-03-04T00:00:00.000Z') }, NO_AUDIT); + expect(await read('w1')).toBe('0009-03-04'); + // …so the stored day and a comparand for it are one text. + expect(await ids(WRITES, { placed_on: { $eq: '0999-06-15' } })).toEqual(['w2']); + }); + }); +} + +for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'date year spelling', measure); diff --git a/packages/objectql/src/engine-date-year-range-door.test.ts b/packages/objectql/src/engine-date-year-range-door.test.ts new file mode 100644 index 00000000000..2b62753c500 --- /dev/null +++ b/packages/objectql/src/engine-date-year-range-door.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20240] The temporal-comparand door refuses a NUMBER or a `Date` on a + * declared `date` field when its UTC calendar day falls in a year below 0 or + * above 9999 — the one non-string class the door judges. + * + * Such a day has no `YYYY-MM-DD` spelling. Measured on the base over REST, on + * a `date` field of six 2026 days plus 0999-06-15: + * + * | comparand | `$gt` / `$lt` / `$eq` | memory | SQLite | PostgreSQL | the day's answer | + * |:--|:--|:--|:--|:--|:--| + * | number for 10000-01-01 | | 6 / 1 / 0 | 6 / 1 / 0 | 0 / 7 / 0 | 0 / 7 / 0 | + * | number for -1-01-01 | | 7 / 0 / 0 | 7 / 0 / 0 | 500 / 500 / 500 | 7 / 0 / 0 | + * | ISO string of either instant | | 400 | 400 | 400 | — | + * + * The storage rule spells those days `10000-01-01` / `-1-01-01`, which sort as + * no day does, and PostgreSQL refuses `-1-01-01` as a date. The ISO string of + * the same instant was already refused `INVALID_FILTER` / 400 here; the number + * and the `Date` now take the same refusal, before any driver read, at every + * position the door covers: `where` on every verb and both spellings, and a + * 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. + */ + +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 }, + memo: { name: 'memo', 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); + +/** Numbers whose UTC calendar day falls outside the four-digit years. */ +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')], +]; + +/** Four-digit years, the edges included — every one reaches the driver. */ +const IN_RANGE: ReadonlyArray = [ + ['0000-01-01', at('0000-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')], +]; + +/** A driver that records every read and answers none. Its answers are not this suite's subject. */ +function makeRecordingDriver() { + const reads: 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 null; }, + 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) { return { ...data }; }, + async update(_o: string, id: string, data: Record) { return { ...data, id }; }, + async updateMany(_o: string, ast: unknown) { reads.push(ast); return 0; }, + async delete() { return true; }, + async deleteMany(_o: string, ast: unknown) { reads.push(ast); return 0; }, + async bulkCreate(_o: string, batch: Record[]) { return batch; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads }; +} + +describe('[#20240] a date field\'s number or Date outside the four-digit years is refused at the door', () => { + 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 refusalOf = async (p: Promise) => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); + + it('refuses the number and its Date on where, with code AND status and no driver read — while four-digit years reach the driver', async () => { + for (const [name, ms] of OUT_OF_RANGE) { + for (const [form, comparand] of [['number', ms], ['Date', new Date(ms)]] as const) { + for (const op of ['$gt', '$lt', '$eq'] as const) { + const err = await refusalOf(engine.find('ledger', { where: { placed_on: { [op]: comparand } } })); + expect(err, `${form} ${name} ${op}`).not.toBeNull(); + expect(err!.code).toBe('INVALID_FILTER'); + expect(err!.status).toBe(400); + expect(err!.message).toContain("'placed_on'"); + expect(err!.message).toContain(`where.placed_on.${op}`); + } + } + } + expect(reads).toHaveLength(0); + + // ── the POSITIVE CONTROL: the same operators, a four-digit year ────────── + for (const [name, ms] of IN_RANGE) { + for (const comparand of [ms, new Date(ms), new Date(ms).toISOString()]) { + for (const op of ['$gt', '$lt', '$eq'] as const) { + const before = reads.length; + await expect( + engine.find('ledger', { where: { placed_on: { [op]: comparand } } }), + `${name} ${op} ${String(comparand)}`, + ).resolves.toEqual([]); + expect(reads.length, `${name} reached the driver`).toBe(before + 1); + } + } + } + }); + + it('refuses at every comparand position a date field can carry, and on both spellings', async () => { + const out = 253402300800000; + const inside = 1769940000000; + for (const where of [ + { placed_on: out }, // implicit equality + { placed_on: new Date(out) }, + { placed_on: { $in: [inside, out] } }, // a list MEMBER + { placed_on: { $between: [inside, out] } }, // a range bound + { placed_on: { $ne: -62198755200000 } }, + { $and: [{ memo: 'x' }, { placed_on: { $lte: out } }] }, + { $or: [{ memo: 'x' }, { $not: { placed_on: { $gte: new Date(out) } } }] }, + [['placed_on', '>', out]], // the authored array sugar + ]) { + const err = await refusalOf(engine.find('ledger', { where: where as never })); + expect(err, JSON.stringify(where)).not.toBeNull(); + expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + } + expect(reads).toHaveLength(0); + + // Control: the same positions with a four-digit year reach the driver. + for (const where of [ + { placed_on: inside }, + { placed_on: { $in: [inside, -30627504000000] } }, + { placed_on: { $between: [-30627504000000, inside] } }, + [['placed_on', '>', inside]], + ]) { + await expect(engine.find('ledger', { where: where as never }), JSON.stringify(where)).resolves.toEqual([]); + } + expect(reads).toHaveLength(4); + }); + + it('covers every verb that collects a filter, read and write sides', async () => { + const where = { placed_on: { $gt: 253402300800000 } }; + for (const call of [ + () => engine.find('ledger', { where }), + () => engine.findOne('ledger', { where }), + () => engine.count('ledger', { where }), + () => engine.aggregate('ledger', { where, groupBy: ['memo'] } as never), + () => engine.update('ledger', { memo: 'x' }, { where, multi: true }), + () => engine.delete('ledger', { where, multi: true }), + ]) { + const err = await refusalOf(call()); + expect(err).not.toBeNull(); + expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + } + expect(reads).toHaveLength(0); + }); + + it('refuses in a per-aggregation filter too, before any driver read — while a four-digit year is counted', async () => { + const perAggregation = (filter: unknown) => ({ + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter }], + }); + for (const [name, ms] of OUT_OF_RANGE) { + for (const comparand of [ms, new Date(ms)]) { + const err = await refusalOf(engine.aggregate('ledger', perAggregation({ placed_on: { $gt: comparand } }) as never)); + expect(err, name).not.toBeNull(); + expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + } + } + expect(reads).toHaveLength(0); + + await expect( + engine.aggregate('ledger', perAggregation({ placed_on: { $gt: -30627504000000 } }) as never), + ).resolves.toBeDefined(); + 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) { + for (const comparand of [ms, new Date(ms)]) { + await expect(engine.find('ledger', { where: { opened_at: { $gt: comparand } } })).resolves.toEqual([]); + await expect(engine.find('ledger', { where: { opens_at: { $gt: comparand } } })).resolves.toEqual([]); + } + } + expect(reads).toHaveLength(OUT_OF_RANGE.length * 4); + }); + + it('does not judge NaN, ±Infinity or an Invalid Date — no instant, no year', async () => { + for (const comparand of [Number.NaN, Number.POSITIVE_INFINITY, new Date(Number.NaN)]) { + await expect(engine.find('ledger', { where: { placed_on: { $gt: comparand } } }), String(comparand)).resolves.toEqual([]); + } + }); +}); diff --git a/packages/rest/src/data-query-date-year-range.test.ts b/packages/rest/src/data-query-date-year-range.test.ts new file mode 100644 index 00000000000..3db2b9f4fe0 --- /dev/null +++ b/packages/rest/src/data-query-date-year-range.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20240] A `date` field's comparand has a four-digit year, or it is refused — + * through `engine.find` / `engine.aggregate` and through + * `POST /api/v1/data/:object/query`, over a real sqlite `SqlDriver`. + * + * Measured on the base through this door, on six 2026 days plus 0999-06-15 + * (`$gt` / `$lt` / `$eq`): + * + * | comparand | `where` | per-aggregation `filter` | the day's answer | + * |:--|:--|:--|:--| + * | number (or `Date`) for 0999-06-15 | 0 / 7 / 0 | 0 / 7 / 0 | 6 / 0 / 1 | + * | its ISO string | 6 / 0 / 1 | 6 / 0 / 1 | 6 / 0 / 1 | + * | number (or `Date`) for 10000-01-01 | 6 / 1 / 0 | 6 / 1 / 0 | no `YYYY-MM-DD` form | + * | number (or `Date`) for -1-01-01 | 7 / 0 / 0 | 7 / 0 / 0 | no `YYYY-MM-DD` form | + * | ISO string for 10000-01-01 or -1-01-01 | 400 | 400 | — | + * + * and `having` on `max(date)` kept no group for `$gt` 0999-06-15 where its + * ISO string kept three. The storage rule spelled the year unpadded + * (`999-06-15`, `10000-01-01`, `-1-01-01`), which sorts as no day does. + * driver-memory answered as sqlite; PostgreSQL answered the 0999 `where` cells + * right and the -1 cells with a 500 (`DATABASE_ERROR`) — each driver's own + * suite pins its door. + * + * Now the year is padded to four digits, so the number, the `Date` and the ISO + * string for 0999-06-15 answer one count at every position; and a number or + * `Date` whose day falls in a year below 0 or above 9999 is refused + * `INVALID_FILTER` / 400 before any read, as its ISO string already was, at + * `where` and the per-aggregation `filter`. `having` does not reach the + * temporal-comparand door for any comparand, a string included, so its + * out-of-range cells are not this file's. + */ + +import { describe, it, expect, 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 = 'ledger_year'; + +const LEDGER_YEAR = { + name: OBJECT, + label: 'Ledger Year', + 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: '0999-06-15', opened_at: '2026-04-01T10:00:00.000Z' }, +]; + +const Y0999 = -30627504000000; // 0999-06-15T00:00:00.000Z +const Y10000 = 253402300800000; // +010000-01-01T00:00:00.000Z +const YNEG1 = -62198755200000; // -000001-01-01T00:00:00.000Z +const N = 1769940000000; // 2026-02-01T10:00:00.000Z — the control + +/** day · operator · `where` ids · `having` groups on max(date) — one answer for every spelling */ +const READ: ReadonlyArray = [ + ['0999-06-15', Y0999, '$gt', ['o1', 'o2', 'o3', 'o4', 'o5', 'o6'], ['c1', 'c2', 'c3']], + ['0999-06-15', Y0999, '$lt', [], []], + ['0999-06-15', Y0999, '$eq', ['o7'], ['c4']], + ['2026-02-01 (control)', N, '$gt', ['o3'], ['c2']], + ['2026-02-01 (control)', N, '$lt', ['o1', 'o2', 'o5', 'o7'], ['c1', 'c4']], + ['2026-02-01 (control)', N, '$eq', ['o4', 'o6'], ['c3']], +]; + +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 engines: ObjectQL[] = []; +afterAll(async () => { + for (const e of engines) { + try { await e.destroy(); } catch { /* noop */ } + } +}); + +async function boot() { + const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER_YEAR as any); + await engine.syncSchemas(); + await engine.insert(OBJECT, ROWS as any); + + // Reads of THIS object — the protocol's own metadata reads are not the question. + const reads = { n: 0 }; + 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); }; + } + + 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(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query'); + expect(route).toBeDefined(); + const post = async (body: Record) => { + const res = makeRes(); + // What the wire carries: JSON, so a `Date` arrives as its ISO text. + await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)) } as any, res); + return res; + }; + return { engine, post, reads }; +} + +const sortedIds = (rows: any[]) => rows.map((r) => r.id).sort(); +const perAggregation = (filter: FilterCondition): EngineAggregateOptions => ({ + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter }], +}); +const grouped = (having: FilterCondition): EngineAggregateOptions => ({ + groupBy: ['customer_id'], + aggregations: [{ function: 'max', field: 'placed_on', alias: 'last_placed' }], + having, +}); +const refusalOf = async (p: Promise) => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); + +describe('[#20240] a four-digit year — the number, the Date and the ISO string give one answer, engine and REST', () => { + for (const [day, ms, op, whereIds, havingGroups] of READ) { + it(`${day} ${op}: where ${whereIds.length}, the per-aggregation filter ${whereIds.length}, having ${havingGroups.join(', ') || 'no group'}`, async () => { + const { engine, post } = await boot(); + // Over REST a `Date` IS its ISO string (JSON), so the `Date` spelling is the engine's. + const spellings: ReadonlyArray = [ + ['number', ms, true], + ['Date', new Date(ms), false], + ['ISO string', new Date(ms).toISOString(), true], + ]; + for (const [form, comparand, overRest] of spellings) { + const where = { placed_on: { [op]: comparand } } as FilterCondition; + expect(sortedIds(await engine.find(OBJECT, { where })), `where, engine, ${form}`).toEqual(whereIds); + expect((await engine.aggregate(OBJECT, perAggregation(where)))[0]?.m, `filter, engine, ${form}`).toBe(whereIds.length); + expect((await engine.aggregate(OBJECT, grouped({ last_placed: { [op]: comparand } } as FilterCondition))) + .map((r: any) => r.customer_id).sort(), `having, engine, ${form}`).toEqual(havingGroups); + if (!overRest) continue; + const w = await post({ where }); + expect(w._status ?? 200, JSON.stringify(w._json)).toBe(200); + expect(sortedIds(w._json.records), `where, REST, ${form}`).toEqual(whereIds); + const f = await post(perAggregation(where) as Record); + expect(f._json.records[0]?.m, `filter, REST, ${form}`).toBe(whereIds.length); + const h = await post(grouped({ last_placed: { [op]: comparand } } as FilterCondition) as Record); + expect(h._json.records.map((r: any) => r.customer_id).sort(), `having, REST, ${form}`).toEqual(havingGroups); + } + }); + } +}); + +describe('[#20240] a year outside 0..9999 — 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 () => { + 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) { + const where = { placed_on: { [op]: comparand } } as FilterCondition; + for (const [position, call] of [ + ['where', () => engine.find(OBJECT, { where })], + ['filter', () => engine.aggregate(OBJECT, perAggregation(where))], + ] as const) { + const err = await refusalOf(call()); + expect(err, `${position}, engine, ${form} ${op}`).not.toBeNull(); + expect(err!.code).toBe('INVALID_FILTER'); + expect(err!.status).toBe(400); + } + // Over REST the number is the number and the `Date` is its ISO string. + for (const [position, body] of [ + ['where', { where }], + ['filter', perAggregation(where)], + ] as const) { + const res = await post(body as Record); + expect(res._status, `${position}, REST, ${form} ${op}`).toBe(400); + expect(res._json.code, `${position}, REST, ${form} ${op}`).toBe('INVALID_FILTER'); + } + } + } + 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. + for (const comparand of [ms, new Date(ms)]) { + await expect(engine.find(OBJECT, { where: { opened_at: { $gt: comparand } } })).resolves.toBeDefined(); + } + const control = await post({ where: { opened_at: { $gt: ms } } }); + expect(control._status ?? 200, JSON.stringify(control._json)).toBe(200); + expect(reads.n).toBeGreaterThan(0); + }); + } +}); From 9f007c525095e57c1cb6281c12920905c8bd89b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:19:02 +0000 Subject: [PATCH 3/6] chore(changeset): declare the date year padding and the out-of-range refusal; one-clause correction to the pending 20203 note Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/20203-epoch-ms-date-comparand.md | 2 +- .changeset/20240-date-year-four-digits.md | 38 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .changeset/20240-date-year-four-digits.md diff --git a/.changeset/20203-epoch-ms-date-comparand.md b/.changeset/20203-epoch-ms-date-comparand.md index 840404d228d..872a33edd48 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, 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), 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 new file mode 100644 index 00000000000..92e9dd6edf9 --- /dev/null +++ b/.changeset/20240-date-year-four-digits.md @@ -0,0 +1,38 @@ +--- +"@objectstack/core": minor +"@objectstack/objectql": minor +"@objectstack/driver-sql": patch +"@objectstack/driver-memory": patch +--- + +fix(core,objectql)!: a number or `Date` compared against a `date` field spells its year with four digits, and one whose UTC year falls outside 0..9999 is refused `INVALID_FILTER` / 400, as its ISO string already was (#20240) + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what a filter on a `date` field accepts. A number or `Date` whose UTC calendar day falls in a year below 0 or above 9999 used to answer 200 with the wrong rows, or a 500 on PostgreSQL; it now answers `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. + +`temporalStorageForm(value, 'date')` in `@objectstack/core` spelled the year of a `Date` or an epoch-millisecond number unpadded: `999-06-15`, `10000-01-01`, `-1-01-01`. The ISO string and the bare day of the same instant spelled `0999-06-15`, and as text an unpadded year sorts as no day does. Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), on a `date` field holding six 2026 days and 0999-06-15, `$gt` / `$lt` / `$eq`: + +| position | comparand | before: memory · SQLite · PostgreSQL | now, on all three | +|:--|:--|:--|:--| +| `where` | a number (or, in-process, a `Date`) for 0999-06-15 | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 | +| per-aggregation `filter` | the same | 0/7/0 on all three | 6/0/1 | +| `having` on `max(date)` | the same | no group / every group / no group | the three 2026 groups / none / the 0999 group | +| `where` | a number (or `Date`) for 10000-01-01 | 6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400 | +| `where` | a number (or `Date`) for -1-01-01 | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 | +| per-aggregation `filter` | either of those two | 6/1/0 and 7/0/0 on all three | `INVALID_FILTER` / 400 | +| `where`, per-aggregation `filter` | the ISO string of either | `INVALID_FILTER` / 400 | unchanged | + +What changes: + +- The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it. +- `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door. +- The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL already stored the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. + +**Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door. + +**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. `having` does not reach the temporal-comparand door for any comparand, so a number or `Date` outside 0..9999 there is still compared as written. `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. From 8d0f8148d29ca202c4ff4d8df0fa4aa87666bcd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:39:06 +0000 Subject: [PATCH 4/6] =?UTF-8?q?test(driver-sql,driver-memory):=20pin=20a?= =?UTF-8?q?=20one-=20and=20two-digit=20date=20year=20on=20every=20dialect?= =?UTF-8?q?=20=E2=80=94=20PostgreSQL=20read=20the=20unpadded=209-03-04=20a?= =?UTF-8?q?s=202004-09-03?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/memory-20240-date-year-spelling.test.ts | 4 ++++ .../src/sql-driver-20240-date-year-spelling.test.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts b/packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts index 05b12baa6cf..e1a1b5b78c5 100644 --- a/packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts +++ b/packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts @@ -39,6 +39,8 @@ const ROWS = [ ]; const Y0999 = -30627504000000; // 0999-06-15T00:00:00.000Z +const Y0099 = Date.parse('0099-03-04T00:00:00.000Z'); +const Y0009 = Date.parse('0009-03-04T00:00:00.000Z'); const N = 1769940000000; // 2026-02-01T10:00:00.000Z — the control const ALL = ['o1', 'o2', 'o3', 'o4', 'o5', 'o6', 'o7']; @@ -50,6 +52,8 @@ const CELLS: ReadonlyArray = [ ['0999-06-15', Y0999, '$eq', ['o7']], ['0999-06-15', Y0999, '$gte', ALL], ['0999-06-15', Y0999, '$ne', ['o1', 'o2', 'o3', 'o4', 'o5', 'o6']], + ['0099-03-04', Y0099, '$lt', []], + ['0009-03-04', Y0009, '$gt', ALL], ['2026-02-01 (control)', N, '$gt', ['o3']], ['2026-02-01 (control)', N, '$lt', ['o1', 'o2', 'o5', 'o7']], ['2026-02-01 (control)', N, '$eq', ['o4', 'o6']], diff --git a/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts index 497b7829fa9..1a5688a8ecd 100644 --- a/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts @@ -17,8 +17,13 @@ * | PostgreSQL | 6 / 0 / 1 | 6 / 0 / 1 | the server's `DATE` parser reads `999-06-15` as year 999 | * * A direct `create` / `update` stored `999-06-15` on SQLite (PostgreSQL stored - * the day). Each row below asserts one answer for the number, its `Date` and - * its ISO string, plus a 2026 control. A year below 0 or above 9999 is refused + * the day). A shorter year is worse on PostgreSQL: under its default + * `DateStyle` (`ISO, MDY`) the server reads `9-03-04` as 2004-09-03 — a + * different day, stored or compared silently — and refuses `99-03-04` + * (`22008`). The unpadded spelling was restored by ablation to measure those + * two, since this file's 0999 fixture reads right there. Each row below asserts + * one answer for the number, its `Date` and its ISO string, plus a 2026 + * control. A year below 0 or above 9999 is refused * as a comparand one layer up, at the engine's temporal-comparand door * (`@objectstack/objectql`); this driver's `where` is not that door. */ @@ -47,6 +52,8 @@ const ROWS = [ ]; const Y0999 = -30627504000000; // 0999-06-15T00:00:00.000Z +const Y0099 = Date.parse('0099-03-04T00:00:00.000Z'); +const Y0009 = Date.parse('0009-03-04T00:00:00.000Z'); const N = 1769940000000; // 2026-02-01T10:00:00.000Z — the control const ALL = ['o1', 'o2', 'o3', 'o4', 'o5', 'o6', 'o7']; @@ -58,6 +65,8 @@ const CELLS: ReadonlyArray = [ ['0999-06-15', Y0999, '$eq', ['o7']], ['0999-06-15', Y0999, '$gte', ALL], ['0999-06-15', Y0999, '$ne', ['o1', 'o2', 'o3', 'o4', 'o5', 'o6']], + ['0099-03-04', Y0099, '$lt', []], + ['0009-03-04', Y0009, '$gt', ALL], ['2026-02-01 (control)', N, '$gt', ['o3']], ['2026-02-01 (control)', N, '$lt', ['o1', 'o2', 'o5', 'o7']], ['2026-02-01 (control)', N, '$eq', ['o4', 'o6']], From 677aaeb40b4a5ec029da9c5ae23006424f4f4bc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:06:45 +0000 Subject: [PATCH 5/6] =?UTF-8?q?test(driver-sql):=20assert=20the=20date=20w?= =?UTF-8?q?rite=20path=20on=20the=20stored=20text=20=E2=80=94=20mysql2=20r?= =?UTF-8?q?eads=20a=20year=20below=20100=20back=20a=20century=20late?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live MySQL cell read year 9 back through the driver as 1909-03-04: mysql2's parseDate rebuilds a DATE with Date.UTC(y, m - 1, d), which maps years 0..99 to 1900..1999. The server stored 0009-03-04. The write-path cell now reads the stored text by a raw per-dialect cast on every dialect, and adds the year-99 write. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- ...ql-driver-20240-date-year-spelling.test.ts | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts index 1a5688a8ecd..c833ad28927 100644 --- a/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts @@ -21,7 +21,16 @@ * `DateStyle` (`ISO, MDY`) the server reads `9-03-04` as 2004-09-03 — a * different day, stored or compared silently — and refuses `99-03-04` * (`22008`). The unpadded spelling was restored by ablation to measure those - * two, since this file's 0999 fixture reads right there. Each row below asserts + * two, since this file's 0999 fixture reads right there. MySQL 8.0 read + * `9-03-04` as year 9 but stored `99-03-04` as 1999-03-04. + * + * The write path is asserted on the STORED text, read by a raw cast + * (`storedText`), never through the driver's read path. On MySQL that path + * hands a year below 100 back a century late: mysql2 rebuilds a `DATE` as + * `new Date(Date.UTC(y, m - 1, d))` (`parseDate`, with the driver's + * `timezone: 'Z'`), and `Date.UTC` maps years 0..99 to 1900..1999, so a stored + * `0009-03-04` is presented as `1909-03-04`. That is a read-path defect of its + * own, unchanged by this card and reported beside it. Each row below asserts * one answer for the number, its `Date` and its ISO string, plus a 2026 * control. A year below 0 or above 9999 is refused * as a comparand one layer up, at the engine's temporal-comparand door @@ -74,6 +83,26 @@ const CELLS: ReadonlyArray = [ 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's `date` — a raw cast, not the driver's read path. */ +async function storedText(driver: SqlDriver, cell: DialectCell, id: string): Promise { + const sql = + cell.id === 'pg' + ? `select "placed_on"::text as t from "${WRITES}" where "id" = ?` + : cell.id === 'mysql' + ? `select cast(\`placed_on\` as char) as t from \`${WRITES}\` where \`id\` = ?` + : `select cast("placed_on" as text) as t from "${WRITES}" 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; +} + function measure(cell: DialectCell): void { describe(`[#20240] a date field's year is four digits — ${cell.label}`, () => { let driver: SqlDriver; @@ -107,15 +136,20 @@ function measure(cell: DialectCell): void { }); it('the write path stores the four-digit year — create and update, a number and a Date', async () => { - const read = async (id: string) => (await driver.findOne(WRITES, { where: { id } }, NO_AUDIT))?.placed_on; + const stored = (id: string) => storedText(driver, cell, id); await driver.create(WRITES, { id: 'w1', placed_on: Y0999 }, NO_AUDIT); await driver.create(WRITES, { id: 'w2', placed_on: new Date(Y0999) }, NO_AUDIT); - expect(await read('w1')).toBe('0999-06-15'); - expect(await read('w2')).toBe('0999-06-15'); - await driver.update(WRITES, 'w1', { placed_on: Date.parse('0009-03-04T00:00:00.000Z') }, NO_AUDIT); - expect(await read('w1')).toBe('0009-03-04'); - // …so the stored day and a comparand for it are one text. - expect(await ids(WRITES, { placed_on: { $eq: '0999-06-15' } })).toEqual(['w2']); + expect(await stored('w1')).toBe('0999-06-15'); + expect(await stored('w2')).toBe('0999-06-15'); + // A year from 100 up also reads back as written through the driver. + expect((await driver.findOne(WRITES, { where: { id: 'w1' } }, NO_AUDIT))?.placed_on).toBe('0999-06-15'); + await driver.update(WRITES, 'w1', { placed_on: Y0009 }, NO_AUDIT); + await driver.update(WRITES, 'w2', { placed_on: new Date(Y0099) }, NO_AUDIT); + expect(await stored('w1')).toBe('0009-03-04'); + expect(await stored('w2')).toBe('0099-03-04'); + // …so the stored day and a comparand for it are one day. + expect(await ids(WRITES, { placed_on: { $eq: '0009-03-04' } })).toEqual(['w1']); + expect(await ids(WRITES, { placed_on: { $eq: new Date(Y0099) } })).toEqual(['w2']); }); }); } From e83b248a39ee8da5b98b20a41ac8c254332d0291 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:11:42 +0000 Subject: [PATCH 6/6] chore(changeset): state what PostgreSQL and MySQL stored for a short year at base, and MySQL's read-path cells Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/20240-date-year-four-digits.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/20240-date-year-four-digits.md b/.changeset/20240-date-year-four-digits.md index 92e9dd6edf9..7ec207b61e3 100644 --- a/.changeset/20240-date-year-four-digits.md +++ b/.changeset/20240-date-year-four-digits.md @@ -29,10 +29,10 @@ What changes: - The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it. - `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door. -- The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL already stored the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. +- The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL and MySQL already stored a three-digit year's day, but not a shorter one: under its default `DateStyle` (`ISO, MDY`) PostgreSQL stored the unpadded `9-03-04` as 2004-09-03 and refused `99-03-04` (`22008`), and MySQL 8.0 stored `99-03-04` as 1999-03-04. All three dialects now store the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it. **Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door. **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. `having` does not reach the temporal-comparand door for any comparand, so a number or `Date` outside 0..9999 there is still compared as written. `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; 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 still reads 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. `having` does not reach the temporal-comparand door for any comparand, so a number or `Date` outside 0..9999 there is still compared as written. `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.