Skip to content

Commit ba5c0de

Browse files
committed
fix(core): read an epoch-ms number on a date column as its UTC calendar day
temporalStorageForm's date arm returned a finite number unchanged, so each face compared it by its own type rules: driver-memory matched no row, SQLite ordered it below every date text, and PostgreSQL refused the bind (22008). A finite number now takes the UTC calendar day of new Date(value), the reading a Date of the same value already had; NaN, +/-Infinity and numbers past the Date range come back unchanged. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3875ae6 commit ba5c0de

2 files changed

Lines changed: 65 additions & 8 deletions

File tree

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

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,58 @@ describe('temporalStorageForm — date: the UTC calendar day', () => {
3939
it(name, () => expect(temporalStorageForm(input, 'date')).toBe(expected));
4040
}
4141

42-
it('epoch milliseconds are not a calendar day — returned as they came', () => {
43-
expect(temporalStorageForm(1769940000000, 'date')).toBe(1769940000000);
42+
});
43+
44+
// [#20203] An epoch-millisecond NUMBER on a `date` column used to come back
45+
// unchanged, so each face compared it by its own type rules: driver-memory
46+
// matched no row, SQLite ordered it below every date text (6 of 6 for `$gt`)
47+
// and PostgreSQL refused the bind (`22008`, a 500 at REST). It is now read as
48+
// the `datetime` rule reads it — an instant — and takes that instant's UTC
49+
// calendar day, the reading a `Date` of the same value already had.
50+
describe('temporalStorageForm — date: an epoch-ms number is the UTC calendar day of its instant', () => {
51+
const cases: ReadonlyArray<readonly [string, number, string]> = [
52+
['a time of day is dropped, never rounded', 1769940000000, '2026-02-01'], // 2026-02-01T10:00Z
53+
['a UTC midnight', 1769904000000, '2026-02-01'],
54+
['the last millisecond of a UTC day', 1769990399999, '2026-02-01'],
55+
['the epoch', 0, '1970-01-01'],
56+
['-0', -0, '1970-01-01'],
57+
['a negative number — a day before the epoch', -86400000, '1969-12-31'],
58+
['one millisecond before the epoch', -1, '1969-12-31'],
59+
['a fraction is truncated toward zero, as the Date constructor does', 1769990399999.9, '2026-02-01'],
60+
['a negative fraction truncates toward zero too', -0.5, '1970-01-01'],
61+
['the Date range maximum', 8.64e15, '275760-09-13'],
62+
['the Date range minimum', -8.64e15, '-271821-04-20'],
63+
];
64+
for (const [name, input, expected] of cases) {
65+
it(`${name}: ${input} → ${expected}`, () => expect(temporalStorageForm(input, 'date')).toBe(expected));
66+
}
67+
68+
it('a number and the Date of the same value always agree', () => {
69+
for (const [, input] of cases) {
70+
expect(temporalStorageForm(input, 'date'), String(input)).toBe(temporalStorageForm(new Date(input), 'date'));
71+
}
4472
});
73+
74+
it('…and name the calendar day of the datetime rule\'s instant for the same number', () => {
75+
for (const input of [1769940000000, 1769904000000, 0, -1, -86400000, 253402300799999]) {
76+
expect(temporalStorageForm(input, 'date'), String(input))
77+
.toBe((temporalStorageForm(input, 'datetime') as string).slice(0, 10));
78+
}
79+
});
80+
81+
const untouched: ReadonlyArray<readonly [string, unknown]> = [
82+
['NaN', Number.NaN],
83+
['Infinity', Number.POSITIVE_INFINITY],
84+
['-Infinity', Number.NEGATIVE_INFINITY],
85+
['past the Date range', 8.64e15 + 1],
86+
['before the Date range', -8.64e15 - 1],
87+
['a bigint — not a number', 1769940000000n],
88+
['a boxed Number — not a number', Object(1769940000000)],
89+
['an epoch-ms STRING — not a leading calendar day, unchanged as before', '1769940000000'],
90+
];
91+
for (const [name, input] of untouched) {
92+
it(`${name} comes back unchanged`, () => expect(temporalStorageForm(input, 'date')).toBe(input));
93+
}
4594
});
4695

4796
describe('temporalStorageForm — time: the UTC wall clock, .fff only when non-zero', () => {

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ import type { TemporalComparandKind } from './temporal-comparand.js';
7070
* everywhere: `'…T12:00+08:00'` and `'…T04:00Z'` are one instant but sort
7171
* differently as text.
7272
* - `date`: a `Date` → its UTC calendar day (the UTC clock, never the host's —
73-
* `SqlDriver.toDateOnly` records why); a string → its leading `YYYY-MM-DD`.
73+
* `SqlDriver.toDateOnly` records why); a finite number → epoch milliseconds,
74+
* read as the `Date` of that value and so its UTC calendar day (a time of
75+
* day is dropped, never rounded); a string → its leading `YYYY-MM-DD`.
7476
* - `time`: a bare `HH:MM[:SS[.f…]]` in range → `HH:MM:SS`, `.fff` kept only
7577
* when non-zero (fractions beyond milliseconds truncated); anything else is
7678
* read as an INSTANT by the `datetime` rule and keeps its UTC time of day.
@@ -114,11 +116,17 @@ function canonicalUtcDatetime(value: unknown): unknown {
114116

115117
function canonicalCalendarDay(value: unknown): unknown {
116118
if (value == null) return value;
117-
if (value instanceof Date) {
118-
if (Number.isNaN(value.getTime())) return value;
119-
const y = value.getUTCFullYear();
120-
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
121-
const d = String(value.getUTCDate()).padStart(2, '0');
119+
// [#20203] A finite number is epoch milliseconds — the reading the
120+
// `datetime` rule above gives it — so it names the instant `new Date(value)`
121+
// names and takes that `Date`'s UTC calendar day, through the one conversion
122+
// below. A number and its `Date` therefore always agree; a number the `Date`
123+
// range cannot hold (past ±8.64e15), `NaN` and ±Infinity come back unchanged.
124+
const instant = typeof value === 'number' && Number.isFinite(value) ? new Date(value) : value;
125+
if (instant instanceof Date) {
126+
if (Number.isNaN(instant.getTime())) return value;
127+
const y = instant.getUTCFullYear();
128+
const m = String(instant.getUTCMonth() + 1).padStart(2, '0');
129+
const d = String(instant.getUTCDate()).padStart(2, '0');
122130
return `${y}-${m}-${d}`;
123131
}
124132
if (typeof value === 'string') {

0 commit comments

Comments
 (0)