Skip to content

Commit f681849

Browse files
committed
test(core,objectql): pin the 0001..9999 range at the rule and at both engine doors, flipping the year-0 pins (#20264)
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf33096 commit f681849

5 files changed

Lines changed: 473 additions & 31 deletions

File tree

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

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
// instant was already refused `INVALID_FILTER` / 400. The door that calls this
1111
// predicate (`@objectstack/objectql`, `temporal-comparand-door.ts`) now refuses
1212
// the number and the `Date` too; its own suite pins the envelope.
13+
//
14+
// [#20264] The range is 0001..9999 (triage's ruling on that card), on `date`
15+
// AND `datetime`: year 0 joins the refused years — PostgreSQL's `DATE` and
16+
// `timestamptz` have no year 0 — and a `datetime` number, `Date` or string
17+
// outside the range is refused as the `date` one is. `time` has no year.
1318

1419
import { describe, it, expect } from 'vitest';
1520
import { isUninterpretableTemporalComparand } from './temporal-comparand.js';
@@ -22,6 +27,8 @@ const OUT_OF_RANGE: ReadonlyArray<readonly [string, number]> = [
2227
['the first day of year 10000', 253402300800000],
2328
['year -1 (the card\'s number)', -62198755200000],
2429
['the last millisecond of year -1', at('-000001-12-31T23:59:59.999Z')],
30+
['[#20264] the first millisecond of year 0', at('0000-01-01T00:00:00.000Z')],
31+
['[#20264] the last millisecond of year 0', at('0000-12-31T23:59:59.999Z')],
2532
['the Date range maximum', 8.64e15],
2633
['the Date range minimum', -8.64e15],
2734
];
@@ -36,7 +43,7 @@ const PAST_THE_DATE_RANGE: ReadonlyArray<readonly [string, number]> = [
3643

3744
/** Finite numbers whose UTC calendar day has a four-digit year — read as before. */
3845
const IN_RANGE: ReadonlyArray<readonly [string, number]> = [
39-
['the first millisecond of year 0', at('0000-01-01T00:00:00.000Z')],
46+
['[#20264] the first millisecond of year 1', at('0001-01-01T00:00:00.000Z')],
4047
['0999-06-15', -30627504000000],
4148
['1000-01-01', at('1000-01-01T00:00:00.000Z')],
4249
['2026-02-01T10:00Z', 1769940000000],
@@ -45,7 +52,7 @@ const IN_RANGE: ReadonlyArray<readonly [string, number]> = [
4552
];
4653

4754
describe('[#20240] isUninterpretableTemporalComparand — a date column\'s number or Date outside the four-digit years', () => {
48-
it('refuses a number and the Date of the same value, for a year below 0 or above 9999', () => {
55+
it('refuses a number and the Date of the same value, for a year below 0001 or above 9999', () => {
4956
for (const [name, ms] of OUT_OF_RANGE) {
5057
expect(isUninterpretableTemporalComparand('date', ms), `number, ${name}`).toBe(true);
5158
expect(isUninterpretableTemporalComparand('date', new Date(ms)), `Date, ${name}`).toBe(true);
@@ -80,20 +87,37 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
8087
expect(isUninterpretableTemporalComparand('date', '-000001-01-01T00:00:00.000Z')).toBe(true);
8188
expect(isUninterpretableTemporalComparand('date', '0999-06-15T00:00:00.000Z')).toBe(false);
8289
expect(isUninterpretableTemporalComparand('date', '0999-06-15')).toBe(false);
90+
// [#20264] Year 0 in its string spellings, beside the first supported day.
91+
expect(isUninterpretableTemporalComparand('date', '0000-06-15')).toBe(true);
92+
expect(isUninterpretableTemporalComparand('date', '0000-06-15T00:00:00.000Z')).toBe(true);
93+
expect(isUninterpretableTemporalComparand('date', '0001-01-01')).toBe(false);
8394
});
8495

85-
it('leaves the datetime and time rules alone — they read an instant', () => {
86-
for (const kind of ['datetime', 'time'] as const) {
87-
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) {
88-
expect(isUninterpretableTemporalComparand(kind, ms), `${kind}, number, ${name}`).toBe(false);
89-
expect(isUninterpretableTemporalComparand(kind, new Date(ms)), `${kind}, Date, ${name}`).toBe(false);
96+
it('[#20264] judges a datetime number or Date by the same years — the rule reads it, but its year is outside', () => {
97+
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE]) {
98+
expect(isUninterpretableTemporalComparand('datetime', ms), `number, ${name}`).toBe(true);
99+
if (Number.isFinite(new Date(ms).getTime())) {
100+
expect(isUninterpretableTemporalComparand('datetime', new Date(ms)), `Date, ${name}`).toBe(true);
90101
}
91102
}
103+
for (const [name, ms] of IN_RANGE) {
104+
expect(isUninterpretableTemporalComparand('datetime', ms), `number, ${name}`).toBe(false);
105+
expect(isUninterpretableTemporalComparand('datetime', new Date(ms)), `Date, ${name}`).toBe(false);
106+
}
107+
});
108+
109+
it('leaves the time rule alone — a wall clock has no year', () => {
110+
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) {
111+
expect(isUninterpretableTemporalComparand('time', ms), `number, ${name}`).toBe(false);
112+
expect(isUninterpretableTemporalComparand('time', new Date(ms)), `Date, ${name}`).toBe(false);
113+
}
92114
});
93115

94116
it('does not judge NaN, ±Infinity or an Invalid Date — they name no instant and no year', () => {
95-
for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, new Date(Number.NaN)]) {
96-
expect(isUninterpretableTemporalComparand('date', value), String(value)).toBe(false);
117+
for (const kind of ['date', 'datetime'] as const) {
118+
for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, new Date(Number.NaN)]) {
119+
expect(isUninterpretableTemporalComparand(kind, value), `${kind} ${String(value)}`).toBe(false);
120+
}
97121
}
98122
});
99123

@@ -103,3 +127,51 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
103127
}
104128
});
105129
});
130+
131+
// [#20264] The `datetime` rule reads an extended-year ISO string, a negative
132+
// year and year 0 because `Date.parse` does, and spells them `+010000-…`,
133+
// `-000001-…` and `0000-…`. The first two sort below every four-digit year as
134+
// text, so a `where` on a `datetime` field counted `$gt` / `$lt` / `$eq`
135+
// 7 / 0 / 0 on driver-memory and SQLite for a bound in year 10000 (the right
136+
// answer is 0 / 7 / 0), and PostgreSQL answered 500 (`22009`, `22007`) for
137+
// them and for year 0 (`22008`). Each is now uninterpretable, beside the same
138+
// instant a millisecond inside the range.
139+
describe('[#20264] isUninterpretableTemporalComparand — a datetime string outside the years 0001 to 9999', () => {
140+
const REFUSED: ReadonlyArray<readonly [string, string]> = [
141+
['the extended ISO spelling of year 10000', '+010000-01-01T00:00:00.000Z'],
142+
['a bare extended day', '10000-01-01'],
143+
['the extended ISO spelling of year -1', '-000001-01-01T00:00:00.000Z'],
144+
['year 0, ISO', '0000-06-15T00:00:00.000Z'],
145+
['year 0, a bare day (read as midnight UTC)', '0000-06-15'],
146+
['year 0, zone-naive (read as UTC)', '0000-06-15 10:00'],
147+
['year 9999 in its zone, year 10000 in UTC', '9999-12-31T23:59:59-01:00'],
148+
['year 1 in its zone, year 0 in UTC', '0001-01-01T00:00:00+08:00'],
149+
['epoch milliseconds for year 10000, as a string', '253402300800000'],
150+
];
151+
const READ: ReadonlyArray<readonly [string, string]> = [
152+
['the first instant of year 1', '0001-01-01T00:00:00.000Z'],
153+
['the last instant of year 9999', '9999-12-31T23:59:59.999Z'],
154+
['year 1, a bare day', '0001-01-01'],
155+
['year 0099 — inside the range', '0099-03-04T10:00:00.000Z'],
156+
['a 2026 instant (the control)', '2026-02-01T10:00:00.000Z'],
157+
['a 2026 zone-naive timestamp (the control)', '2026-02-01 10:00'],
158+
['epoch milliseconds for 2026, as a string (the control)', '1769940000000'],
159+
];
160+
it('refuses each, on datetime and on date', () => {
161+
for (const [name, value] of REFUSED) {
162+
expect(isUninterpretableTemporalComparand('datetime', value), name).toBe(true);
163+
}
164+
for (const value of ['+010000-01-01T00:00:00.000Z', '-000001-01-01T00:00:00.000Z', '0000-06-15']) {
165+
expect(isUninterpretableTemporalComparand('date', value), value).toBe(true);
166+
}
167+
});
168+
it('reads every instant inside the range, as before — the discriminating half', () => {
169+
for (const [name, value] of READ) {
170+
expect(isUninterpretableTemporalComparand('datetime', value), name).toBe(false);
171+
}
172+
});
173+
it('a time column judges no year — its rule keeps a time of day', () => {
174+
expect(isUninterpretableTemporalComparand('time', '0000-06-15T10:00:00.000Z')).toBe(false);
175+
expect(isUninterpretableTemporalComparand('time', '10:00')).toBe(false);
176+
});
177+
});

‎packages/core/src/utils/temporal-storage-form.test.ts‎

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// `sql-driver-temporal-storage-form.test.ts`). This file pins the rule itself.
1010

1111
import { describe, it, expect } from 'vitest';
12-
import { temporalStorageForm } from './temporal-storage-form.js';
12+
import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js';
1313

1414
describe('temporalStorageForm — datetime: canonical UTC ISO text', () => {
1515
const cases: ReadonlyArray<readonly [string, unknown, unknown]> = [
@@ -98,18 +98,17 @@ describe('temporalStorageForm — date: an epoch-ms number is the UTC calendar d
9898
// same day `0999-06-15`. As text `999-…` sorts above every padded day
9999
// (`'9' > '0'`), so over REST the number for 0999-06-15 counted `$gt` 0 /
100100
// `$lt` 7 on driver-memory and SQLite where its ISO string counted 6 / 0. The
101-
// year is now four digits. A year below 0 or above 9999 has no `YYYY-MM-DD`
102-
// form: it keeps the spelling it had (no ordered form is invented), and the
103-
// temporal-comparand door refuses it as a comparand.
101+
// year is now four digits. [#20264] The padding covers 0001..0999: a year
102+
// outside 0001..9999 — year 0 included, which this block padded to `0000-…`
103+
// before that card's ruling — has no `YYYY-MM-DD` form: it keeps its unpadded
104+
// spelling (no ordered form is invented), and the doors refuse it.
104105
describe('temporalStorageForm — date: the year of a Date or number is four digits', () => {
105106
const at = (iso: string) => Date.parse(iso);
106107
const cases: ReadonlyArray<readonly [string, number]> = [
107108
['0999-06-15', -30627504000000],
108109
['0099-03-04', at('0099-03-04T12:00:00.000Z')],
109110
['0009-03-04', at('0009-03-04T00:00:00.000Z')],
110111
['0001-01-01', at('0001-01-01T00:00:00.000Z')],
111-
['0000-06-15', at('0000-06-15T00:00:00.000Z')],
112-
['0000-01-01', at('0000-01-01T00:00:00.000Z')],
113112
['1000-01-01', at('1000-01-01T00:00:00.000Z')], // already four digits — unchanged
114113
['9999-12-31', at('9999-12-31T23:59:59.999Z')], // the last millisecond of year 9999
115114
];
@@ -127,11 +126,22 @@ describe('temporalStorageForm — date: the year of a Date or number is four dig
127126
expect([...spelled].sort()).toEqual(chronological);
128127
});
129128

130-
it('a year outside 0..9999 keeps its spelling — no ordered form is invented', () => {
129+
it('a year outside 0001..9999 keeps its spelling — no ordered form is invented', () => {
131130
expect(temporalStorageForm(253402300800000, 'date')).toBe('10000-01-01');
132131
expect(temporalStorageForm(new Date(253402300800000), 'date')).toBe('10000-01-01');
133132
expect(temporalStorageForm(-62198755200000, 'date')).toBe('-1-01-01');
134133
expect(temporalStorageForm(at('-000001-12-31T23:59:59.999Z'), 'date')).toBe('-1-12-31');
134+
// [#20264] Year 0 is outside too: unpadded, where #20240 padded it `0000-…`.
135+
expect(temporalStorageForm(at('0000-06-15T00:00:00.000Z'), 'date')).toBe('0-06-15');
136+
expect(temporalStorageForm(new Date(at('0000-01-01T00:00:00.000Z')), 'date')).toBe('0-01-01');
137+
});
138+
139+
it('[#20264] the datetime rule stays total — it spells a year outside the range as toISOString does', () => {
140+
// The doors refuse these; the write and read paths behind them are unchanged.
141+
expect(temporalStorageForm(253402300800000, 'datetime')).toBe('+010000-01-01T00:00:00.000Z');
142+
expect(temporalStorageForm('+010000-01-01T00:00:00.000Z', 'datetime')).toBe('+010000-01-01T00:00:00.000Z');
143+
expect(temporalStorageForm(-62198755200000, 'datetime')).toBe('-000001-01-01T00:00:00.000Z');
144+
expect(temporalStorageForm('0000-06-15', 'datetime')).toBe('0000-06-15T00:00:00.000Z');
135145
});
136146
});
137147

@@ -178,3 +188,70 @@ describe('temporalStorageForm — total: what the rule cannot read comes back un
178188
expect(temporalStorageForm(list, 'datetime')).toBe(list);
179189
});
180190
});
191+
192+
// [#20264] The supported years, 0001..9999, on `date` and `datetime`: the one
193+
// range the temporal-comparand door (a comparand) and the record validator (a
194+
// written value) both ask, so they cannot disagree about a year. The year is
195+
// the one the kind's rule reads — a `date` string's leading day, otherwise the
196+
// UTC year of the instant.
197+
describe('[#20264] isOutsideTemporalYearRange — the years a date or datetime value may name', () => {
198+
const at = (iso: string) => Date.parse(iso);
199+
const OUTSIDE: ReadonlyArray<readonly [string, unknown, 'date' | 'datetime' | 'both']> = [
200+
['year 10000, a number', at('+010000-01-01T00:00:00.000Z'), 'both'],
201+
['year 10000, a Date', new Date(at('+010000-01-01T00:00:00.000Z')), 'both'],
202+
['year 10000, the extended ISO string', '+010000-01-01T00:00:00.000Z', 'both'],
203+
['year -1, the extended ISO string', '-000001-01-01T00:00:00.000Z', 'both'],
204+
['year 0, a number', at('0000-06-15T00:00:00.000Z'), 'both'],
205+
['year 0, a bare day', '0000-06-15', 'both'],
206+
['year 0, an ISO instant', '0000-06-15T10:00:00.000Z', 'both'],
207+
['a number past the Date range', 8.64e15 + 1, 'both'],
208+
['year 10000 in UTC, 9999 in its zone', '9999-12-31T23:59:59-01:00', 'datetime'],
209+
['year 0 in UTC, 1 in its zone', '0001-01-01T00:00:00+08:00', 'datetime'],
210+
['epoch milliseconds for year 10000, as a string', '253402300800000', 'datetime'],
211+
];
212+
const INSIDE: ReadonlyArray<readonly [string, unknown, 'date' | 'datetime' | 'both']> = [
213+
['the first instant of year 1', at('0001-01-01T00:00:00.000Z'), 'both'],
214+
['the last instant of year 9999', new Date(at('9999-12-31T23:59:59.999Z')), 'both'],
215+
['year 1, a bare day', '0001-01-01', 'both'],
216+
['year 9999, a bare day', '9999-12-31', 'both'],
217+
['year 0099', '0099-03-04T10:00:00.000Z', 'both'],
218+
['a 2026 instant (the control)', '2026-02-01T10:00:00.000Z', 'both'],
219+
['a 2026 number (the control)', 1769940000000, 'both'],
220+
// A `date` takes a string's leading day, whatever instant the rest names.
221+
['year 9999 in its leading day, 10000 as an instant', '9999-12-31T23:59:59-01:00', 'date'],
222+
['year 1 in its leading day, 0 as an instant', '0001-01-01T00:00:00+08:00', 'date'],
223+
];
224+
const kindsOf = (k: 'date' | 'datetime' | 'both') => (k === 'both' ? (['date', 'datetime'] as const) : [k]);
225+
226+
it('is true for a year outside 0001..9999, in every spelling the kind reads', () => {
227+
for (const [name, value, kinds] of OUTSIDE) {
228+
for (const kind of kindsOf(kinds)) expect(isOutsideTemporalYearRange(value, kind), `${kind}, ${name}`).toBe(true);
229+
}
230+
});
231+
232+
it('is false for a year inside it, the edges and a 2026 control included', () => {
233+
for (const [name, value, kinds] of INSIDE) {
234+
for (const kind of kindsOf(kinds)) expect(isOutsideTemporalYearRange(value, kind), `${kind}, ${name}`).toBe(false);
235+
}
236+
});
237+
238+
it('names no year for a time, or for a value that names no instant', () => {
239+
for (const value of [at('+010000-01-01T00:00:00.000Z'), '0000-06-15T10:00:00.000Z']) {
240+
expect(isOutsideTemporalYearRange(value, 'time')).toBe(false);
241+
}
242+
for (const kind of ['date', 'datetime'] as const) {
243+
for (const value of [null, undefined, '', ' ', 'not-a-date', '{today}', Number.NaN, Number.POSITIVE_INFINITY, new Date(Number.NaN), true, {}]) {
244+
expect(isOutsideTemporalYearRange(value, kind), `${kind} ${String(value)}`).toBe(false);
245+
}
246+
}
247+
});
248+
249+
it('agrees with the date rule: a number or Date is outside exactly when the rule cannot spell it YYYY-MM-DD', () => {
250+
for (const [, value] of [...OUTSIDE, ...INSIDE]) {
251+
if (typeof value !== 'number' && !(value instanceof Date)) continue;
252+
const spelled = temporalStorageForm(value, 'date');
253+
const isDay = typeof spelled === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(spelled);
254+
expect(isOutsideTemporalYearRange(value, 'date'), String(value)).toBe(!isDay);
255+
}
256+
});
257+
});

‎packages/objectql/src/engine-aggregate-having-temporal-door.test.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,19 @@ describe('[#20263] having — a comparand its column cannot read is refused befo
191191
['"+010000-01-01T00:00:00.000Z"', 'not a date value']],
192192
['the number for 10000-01-01 on max(date), in the year class\'s own words',
193193
() => ({ last_placed: { $gt: Y10000 } }), { placed_on: { $gt: Y10000 } },
194-
['253402300800000', 'outside the years 0000 to 9999', 'keep the wrong groups']],
194+
['253402300800000', 'outside the years 0001 to 9999', 'keep the wrong groups']],
195195
['the Date for 10000-01-01 on max(date)',
196196
() => ({ last_placed: { $lt: new Date(Y10000) } }), { placed_on: { $lt: new Date(Y10000) } },
197-
['Date +010000-01-01T00:00:00.000Z', 'outside the years 0000 to 9999']],
197+
['Date +010000-01-01T00:00:00.000Z', 'outside the years 0001 to 9999']],
198+
// [#20264] A datetime year outside 0001..9999 is the year class too, on
199+
// `having` with no edit here: the predicate asks core's one range. At the
200+
// base this `$lt` kept no group — the extended text sorts below every year.
201+
['an extended-year ISO string on min(datetime), in the year class\'s words',
202+
() => ({ first_opened: { $lt: '+010000-01-01T00:00:00.000Z' } }), { opened_at: { $lt: '+010000-01-01T00:00:00.000Z' } },
203+
["`having` on 'first_opened' (min(opened_at), a datetime column)", 'outside the years 0001 to 9999', 'keep the wrong groups']],
204+
['the number for year 0 on max(date) — [#20264] year 0 joins the refused years',
205+
() => ({ last_placed: { $gt: Date.parse('0000-06-15T00:00:00.000Z') } }), { placed_on: { $gt: Date.parse('0000-06-15T00:00:00.000Z') } },
206+
['outside the years 0001 to 9999']],
198207
['"not-a-date" on min(datetime)',
199208
() => ({ first_opened: { $lt: 'not-a-date' } }), { opened_at: { $lt: 'not-a-date' } },
200209
["`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
317326
['an in-range Date on max(date)', { last_placed: { $gt: new Date(1769940000000) } }, ['c2']],
318327
['a 2026 instant on min(datetime)', { first_opened: { $gt: '2026-02-01T00:00:00.000Z' } }, ['c2', 'c3', 'c4']],
319328
['a bare day as the upper bound of min(datetime)', { first_opened: { $lte: '2026-02-01' } }, ['c1', 'c2']],
320-
// The `datetime` rule reads an extended-year instant, so the predicate calls
321-
// it interpretable and its `where` twin is not refused by the door either.
322-
// Its text orders below every four-digit year, so `$lt` keeps no group, on
323-
// `having` as before; which years a comparand may name is #20264's to
324-
// decide, in the predicate, and `having` follows it with no second edit.
325-
['an extended-year ISO on min(datetime) — read by the datetime rule', { first_opened: { $lt: '+010000-01-01T00:00:00.000Z' } }, []],
329+
// [#20264] An extended-year instant on min(datetime) is refused now (see
330+
// the REFUSED table); the first instant of year 1 is read, as before.
331+
['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']],
326332
['a wall clock on max(time)', { last_slot: { $gte: '12:00' } }, ['c2', 'c3', 'c4']],
327333
['the number for 10000-01-01 on max(time) — not judged on time', { last_slot: { $gt: Y10000 } }, []],
328334
['a string on sum — not temporal', { total: { $gt: 'not-a-date' } }, []],

0 commit comments

Comments
 (0)