From ba5c0de774159009f331726f3463f8fe67889e45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:03:02 +0000 Subject: [PATCH 1/4] 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 --- .../src/utils/temporal-storage-form.test.ts | 53 ++++++++++++++++++- .../core/src/utils/temporal-storage-form.ts | 20 ++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/temporal-storage-form.test.ts b/packages/core/src/utils/temporal-storage-form.test.ts index 8311d95c2a1..a1c47354ac7 100644 --- a/packages/core/src/utils/temporal-storage-form.test.ts +++ b/packages/core/src/utils/temporal-storage-form.test.ts @@ -39,9 +39,58 @@ describe('temporalStorageForm — date: the UTC calendar day', () => { it(name, () => expect(temporalStorageForm(input, 'date')).toBe(expected)); } - it('epoch milliseconds are not a calendar day — returned as they came', () => { - expect(temporalStorageForm(1769940000000, 'date')).toBe(1769940000000); +}); + +// [#20203] An epoch-millisecond NUMBER on a `date` column used to come back +// unchanged, so each face compared it by its own type rules: driver-memory +// matched no row, SQLite ordered it below every date text (6 of 6 for `$gt`) +// and PostgreSQL refused the bind (`22008`, a 500 at REST). It is now read as +// the `datetime` rule reads it — an instant — and takes that instant's UTC +// calendar day, the reading a `Date` of the same value already had. +describe('temporalStorageForm — date: an epoch-ms number is the UTC calendar day of its instant', () => { + const cases: ReadonlyArray = [ + ['a time of day is dropped, never rounded', 1769940000000, '2026-02-01'], // 2026-02-01T10:00Z + ['a UTC midnight', 1769904000000, '2026-02-01'], + ['the last millisecond of a UTC day', 1769990399999, '2026-02-01'], + ['the epoch', 0, '1970-01-01'], + ['-0', -0, '1970-01-01'], + ['a negative number — a day before the epoch', -86400000, '1969-12-31'], + ['one millisecond before the epoch', -1, '1969-12-31'], + ['a fraction is truncated toward zero, as the Date constructor does', 1769990399999.9, '2026-02-01'], + ['a negative fraction truncates toward zero too', -0.5, '1970-01-01'], + ['the Date range maximum', 8.64e15, '275760-09-13'], + ['the Date range minimum', -8.64e15, '-271821-04-20'], + ]; + for (const [name, input, expected] of cases) { + it(`${name}: ${input} → ${expected}`, () => expect(temporalStorageForm(input, 'date')).toBe(expected)); + } + + it('a number and the Date of the same value always agree', () => { + for (const [, input] of cases) { + expect(temporalStorageForm(input, 'date'), String(input)).toBe(temporalStorageForm(new Date(input), 'date')); + } }); + + it('…and name the calendar day of the datetime rule\'s instant for the same number', () => { + for (const input of [1769940000000, 1769904000000, 0, -1, -86400000, 253402300799999]) { + expect(temporalStorageForm(input, 'date'), String(input)) + .toBe((temporalStorageForm(input, 'datetime') as string).slice(0, 10)); + } + }); + + const untouched: ReadonlyArray = [ + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ['past the Date range', 8.64e15 + 1], + ['before the Date range', -8.64e15 - 1], + ['a bigint — not a number', 1769940000000n], + ['a boxed Number — not a number', Object(1769940000000)], + ['an epoch-ms STRING — not a leading calendar day, unchanged as before', '1769940000000'], + ]; + for (const [name, input] of untouched) { + it(`${name} comes back unchanged`, () => expect(temporalStorageForm(input, 'date')).toBe(input)); + } }); describe('temporalStorageForm — time: the UTC wall clock, .fff only when non-zero', () => { diff --git a/packages/core/src/utils/temporal-storage-form.ts b/packages/core/src/utils/temporal-storage-form.ts index b97f195d35b..61a6c0861ae 100644 --- a/packages/core/src/utils/temporal-storage-form.ts +++ b/packages/core/src/utils/temporal-storage-form.ts @@ -70,7 +70,9 @@ import type { TemporalComparandKind } from './temporal-comparand.js'; * everywhere: `'…T12:00+08:00'` and `'…T04:00Z'` are one instant but sort * differently as text. * - `date`: a `Date` → its UTC calendar day (the UTC clock, never the host's — - * `SqlDriver.toDateOnly` records why); a string → its leading `YYYY-MM-DD`. + * `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`. * - `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. @@ -114,11 +116,17 @@ function canonicalUtcDatetime(value: unknown): unknown { function canonicalCalendarDay(value: unknown): unknown { if (value == null) return value; - if (value instanceof Date) { - if (Number.isNaN(value.getTime())) return value; - const y = value.getUTCFullYear(); - const m = String(value.getUTCMonth() + 1).padStart(2, '0'); - const d = String(value.getUTCDate()).padStart(2, '0'); + // [#20203] A finite number is epoch milliseconds — the reading the + // `datetime` rule above gives it — so it names the instant `new Date(value)` + // names and takes that `Date`'s UTC calendar day, through the one conversion + // below. A number and its `Date` therefore always agree; a number the `Date` + // range cannot hold (past ±8.64e15), `NaN` and ±Infinity come back unchanged. + const instant = typeof value === 'number' && Number.isFinite(value) ? new Date(value) : value; + if (instant instanceof Date) { + if (Number.isNaN(instant.getTime())) return value; + const y = instant.getUTCFullYear(); + const m = String(instant.getUTCMonth() + 1).padStart(2, '0'); + const d = String(instant.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } if (typeof value === 'string') { From 6cb41f4200dd7e5ae8ea0be0ccc8f731c7ebccc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:10:33 +0000 Subject: [PATCH 2/4] test: pin an epoch-ms number on a date field on both drivers and the REST door driver-sql over its live-dialect matrix (SQLite always; PostgreSQL and MySQL when provisioned), driver-memory at its driver door, and the REST door over SqlDriver at where, a per-aggregation filter and having, each beside the Date twin and the datetime control, populated and empty. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- ...mory-20203-epoch-ms-date-comparand.test.ts | 96 +++++++++ ...iver-20203-epoch-ms-date-comparand.test.ts | 119 +++++++++++ ...data-query-epoch-ms-date-comparand.test.ts | 184 ++++++++++++++++++ 3 files changed, 399 insertions(+) create mode 100644 packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts create mode 100644 packages/rest/src/data-query-epoch-ms-date-comparand.test.ts diff --git a/packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts b/packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts new file mode 100644 index 00000000000..43b26422017 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20203] An epoch-millisecond NUMBER compared against a `date` field is read + * as the UTC calendar day of its instant — by `@objectstack/core`'s + * `temporalStorageForm`, the rule `coerceTemporalValue` calls — the reading a + * `Date` of the same value already had here. + * + * 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): `where: { placed_on: { $gt: 1769940000000 } }` answered 0 of 6, + * because the number reached mingo unchanged and mingo never matches a number + * against the `YYYY-MM-DD` text the column holds. Every operator answered that + * way — 0, or all 6 for `$ne` / `$nin` — while `driver-sql` answered 6 on + * SQLite and a 500 on PostgreSQL (its suite pins it), and the same number on a + * `datetime` field (the control) was read as its instant everywhere. + * + * Each row asserts three things: the number's answer, the `Date` twin's answer + * (identical), and the `datetime` control. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; + +const OBJECT = 'ledger_order'; +const EMPTY = 'ledger_order_empty'; +const WRITES = 'ledger_order_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' }, +]; + +const N = 1769940000000; // 2026-02-01T10:00:00.000Z — a time of day, not a UTC midnight +const N_JAN10 = 1768057200000; // 2026-01-10T15:00:00.000Z +const N_JAN02 = 1767394800000; // 2026-01-02T23:00:00.000Z — late in its UTC day +const N_MIDNIGHT = 1769904000000; // 2026-02-01T00:00:00.000Z + +type As = (ms: number) => unknown; +const asNumber: As = (ms) => ms; +const asDate: As = (ms) => new Date(ms); + +/** operator · the comparand, built from a number or its `Date` · `date` ids · `datetime` control ids */ +const OPERATORS: ReadonlyArray unknown, string[], string[]]> = [ + ['$gt', (v) => ({ $gt: v(N) }), ['o3'], ['o4', 'o5', 'o6']], + ['$lt', (v) => ({ $lt: v(N) }), ['o1', 'o2', 'o5'], ['o1', 'o2']], + ['$eq', (v) => ({ $eq: v(N) }), ['o4', 'o6'], ['o3']], + ['$in', (v) => ({ $in: [v(N), v(N_JAN10)] }), ['o1', 'o4', 'o6'], ['o3']], + ['$between', (v) => ({ $between: [v(N_JAN02), v(N)] }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o3']], + ['$gte', (v) => ({ $gte: v(N) }), ['o3', 'o4', 'o6'], ['o3', 'o4', 'o5', 'o6']], + ['$lte', (v) => ({ $lte: v(N) }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o1', 'o2', 'o3']], + ['$ne', (v) => ({ $ne: v(N) }), ['o1', 'o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']], + ['$nin', (v) => ({ $nin: [v(N), v(N_JAN10)] }), ['o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']], + ['implicit equality', (v) => v(N), ['o4', 'o6'], ['o3']], + ['$eq on a UTC midnight', (v) => ({ $eq: v(N_MIDNIGHT) }), ['o4', 'o6'], []], +]; + +describe('[#20203] an epoch-ms number on a date field reads as the UTC calendar day of its instant', () => { + let driver: InMemoryDriver; + const ids = async (object: string, where: Record) => + ((await driver.find(object, { where } as any)) as any[]).map((r) => r.id).sort(); + + beforeAll(async () => { + driver = new InMemoryDriver({}); + await driver.connect(); + for (const name of [OBJECT, EMPTY, WRITES]) await driver.syncSchema(name, { name, fields: FIELDS }); + for (const row of ROWS) await driver.create(OBJECT, { ...row }); + }); + + for (const [op, comparand, onDate, onDatetime] of OPERATORS) { + it(`${op}: ${onDate.join(', ')}; its Date agrees; the datetime control reads the instant`, async () => { + expect(await ids(OBJECT, { placed_on: comparand(asNumber) }), 'number on date').toEqual(onDate); + expect(await ids(OBJECT, { placed_on: comparand(asDate) }), 'the Date twin on date').toEqual(onDate); + expect(await ids(OBJECT, { opened_at: comparand(asNumber) }), 'datetime control').toEqual(onDatetime); + }); + + it(`${op}: an empty object answers no row`, async () => { + expect(await ids(EMPTY, { placed_on: comparand(asNumber) })).toEqual([]); + }); + } + + it('the write path stores the same UTC calendar day — create and update', async () => { + await driver.create(WRITES, { id: 'w1', placed_on: N }); + await driver.create(WRITES, { id: 'w2', placed_on: -1 }); // one ms before the epoch + const read = async (id: string) => ((await driver.findOne(WRITES, { where: { id } } as any)) as any)?.placed_on; + expect(await read('w1')).toBe('2026-02-01'); + expect(await read('w2')).toBe('1969-12-31'); + await driver.update(WRITES, 'w1', { placed_on: N_JAN10 }); + expect(await read('w1')).toBe('2026-01-10'); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts b/packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts new file mode 100644 index 00000000000..d99ad08c029 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20203] An epoch-millisecond NUMBER compared against a `date` field is read + * as the UTC calendar day of its instant — by `@objectstack/core`'s + * `temporalStorageForm`, the rule `toDateOnly` / `temporalFilterValue` call — + * on every dialect this driver speaks. + * + * Measured on the base through `find()` (and through the engine and + * `POST /api/v1/data/:object/query`, which reach the same coercion), on this + * file's fixture, with `where: { placed_on: { $gt: 1769940000000 } }` + * (2026-02-01T10:00:00.000Z): + * + * | dialect | base | why | + * |:--|:--|:--| + * | SQLite | 6 of 6 | the number was bound as INTEGER, and SQLite orders every INTEGER below every TEXT | + * | PostgreSQL | `DATABASE_ERROR` (500 at REST) | the server's date input parser refused the bind, `22008` "date/time field value out of range" | + * + * and every other operator split the same way (SQLite: `$gte` / `$ne` / `$nin` + * 6, the rest 0; PostgreSQL: a refusal on each, on an empty table too), while + * the same number on a `datetime` field — the control — was read as its + * instant on both. `driver-memory` answered 0 (its own suite pins it). + * + * The number now names the same day a `Date` of the same value names, so each + * row below asserts three things on one cell: the number's answer, the `Date` + * twin's answer (identical), and the `datetime` control. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'os20203_ledger'; +const EMPTY = 'os20203_ledger_empty'; +const WRITES = 'os20203_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' }, +]; + +const N = 1769940000000; // 2026-02-01T10:00:00.000Z — a time of day, not a UTC midnight +const N_JAN10 = 1768057200000; // 2026-01-10T15:00:00.000Z +const N_JAN02 = 1767394800000; // 2026-01-02T23:00:00.000Z — late in its UTC day +const N_MIDNIGHT = 1769904000000; // 2026-02-01T00:00:00.000Z + +type As = (ms: number) => unknown; +const asNumber: As = (ms) => ms; +const asDate: As = (ms) => new Date(ms); + +/** operator · the comparand, built from a number or its `Date` · `date` ids · `datetime` control ids */ +const OPERATORS: ReadonlyArray unknown, string[], string[]]> = [ + ['$gt', (v) => ({ $gt: v(N) }), ['o3'], ['o4', 'o5', 'o6']], + ['$lt', (v) => ({ $lt: v(N) }), ['o1', 'o2', 'o5'], ['o1', 'o2']], + ['$eq', (v) => ({ $eq: v(N) }), ['o4', 'o6'], ['o3']], + ['$in', (v) => ({ $in: [v(N), v(N_JAN10)] }), ['o1', 'o4', 'o6'], ['o3']], + ['$between', (v) => ({ $between: [v(N_JAN02), v(N)] }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o3']], + ['$gte', (v) => ({ $gte: v(N) }), ['o3', 'o4', 'o6'], ['o3', 'o4', 'o5', 'o6']], + ['$lte', (v) => ({ $lte: v(N) }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o1', 'o2', 'o3']], + ['$ne', (v) => ({ $ne: v(N) }), ['o1', 'o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']], + ['$nin', (v) => ({ $nin: [v(N), v(N_JAN10)] }), ['o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']], + ['implicit equality', (v) => v(N), ['o4', 'o6'], ['o3']], + ['$eq on a UTC midnight', (v) => ({ $eq: v(N_MIDNIGHT) }), ['o4', 'o6'], []], +]; + +const NO_AUDIT = { bypassTenantAudit: true } as any; + +function measure(cell: DialectCell): void { + describe(`[#20203] an epoch-ms number on a date field — ${cell.label}`, () => { + let driver: SqlDriver; + const ids = async (table: string, where: Record) => + ((await driver.find(table, { where }, NO_AUDIT)) as any[]).map((r) => r.id).sort(); + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + for (const t of [TABLE, EMPTY, WRITES]) await driver.execute(`drop table if exists ${t}`).catch(() => {}); + await driver.initObjects([shape(TABLE), shape(EMPTY), shape(WRITES)]); + for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT); + }); + + afterAll(async () => { + for (const t of [TABLE, EMPTY, WRITES]) await driver.execute(`drop table if exists ${t}`).catch(() => {}); + await driver.disconnect(); + }); + + for (const [op, comparand, onDate, onDatetime] of OPERATORS) { + it(`${op}: the UTC calendar day of the instant — ${onDate.join(', ')}; its Date agrees; the datetime control reads the instant`, async () => { + expect(await ids(TABLE, { placed_on: comparand(asNumber) }), 'number on date').toEqual(onDate); + expect(await ids(TABLE, { placed_on: comparand(asDate) }), 'the Date twin on date').toEqual(onDate); + expect(await ids(TABLE, { opened_at: comparand(asNumber) }), 'datetime control').toEqual(onDatetime); + }); + + it(`${op}: an empty table answers no row, not a refusal`, async () => { + expect(await ids(EMPTY, { placed_on: comparand(asNumber) })).toEqual([]); + }); + } + + it('the write path stores the same UTC calendar day — create and update', async () => { + await driver.create(WRITES, { id: 'w1', placed_on: N }, NO_AUDIT); + await driver.create(WRITES, { id: 'w2', placed_on: -1 }, NO_AUDIT); // one ms before the epoch + const read = async (id: string) => ((await driver.findOne(WRITES, { where: { id } }, NO_AUDIT)) as any)?.placed_on; + expect(await read('w1')).toBe('2026-02-01'); + expect(await read('w2')).toBe('1969-12-31'); + await driver.update(WRITES, 'w1', { placed_on: N_JAN10 }, NO_AUDIT); + expect(await read('w1')).toBe('2026-01-10'); + }); + }); +} + +for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'epoch-ms date comparand', measure); diff --git a/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts b/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts new file mode 100644 index 00000000000..e77b2aa1492 --- /dev/null +++ b/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20203] An epoch-millisecond NUMBER against a `date` field is read as the + * UTC calendar day of its instant — through `engine.find` and through + * `POST /api/v1/data/:object/query`, at every position that compares it: the + * `where`, a per-aggregation `filter` and `having`. One rule reads it, + * `@objectstack/core`'s `temporalStorageForm`: the driver's `where` coercion + * and the engine's per-aggregation / `having` evaluation both call it. + * + * Measured on the base through this door on a real sqlite `SqlDriver`, over + * this file's six rows, with `1769940000000` (2026-02-01T10:00:00.000Z): + * + * | position | `$gt` | `$lt` | `$eq` | `$in` | `$between` | + * |:--|:--|:--|:--|:--|:--| + * | `where` (engine and REST) | 6 | 0 | 0 | 0 | 0 | + * | per-aggregation `filter` | 0 | 0 | 0 | 0 | 6 | + * | the date's own answer | 1 | 3 | 2 | 3 | 5 | + * + * and `having` on `max(date)` kept no group for `$gt`, `$eq` or `$in` — the + * positions disagreed with each other and with the date. The same number + * on a `datetime` field (the control) was read as its instant at every + * position. `driver-memory` answered 0 at `where` and PostgreSQL a 500 + * (`DATABASE_ERROR`); those drivers' suites pin their `where` cells. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +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_order'; + +const LEDGER_ORDER = { + name: OBJECT, + label: 'Ledger Order', + 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' }, +]; + +const N = 1769940000000; // 2026-02-01T10:00:00.000Z — a time of day, not a UTC midnight +const N_JAN10 = 1768057200000; // 2026-01-10T15:00:00.000Z +const N_JAN02 = 1767394800000; // 2026-01-02T23:00:00.000Z — late in its UTC day + +/** operator · comparand · `date` ids (the UTC day 2026-02-01) · `datetime` control ids (the instant) */ +const OPERATORS: ReadonlyArray = [ + ['$gt', { $gt: N }, ['o3'], ['o4', 'o5', 'o6']], + ['$lt', { $lt: N }, ['o1', 'o2', 'o5'], ['o1', 'o2']], + ['$eq', { $eq: N }, ['o4', 'o6'], ['o3']], + ['$in', { $in: [N, N_JAN10] }, ['o1', 'o4', 'o6'], ['o3']], + ['$between', { $between: [N_JAN02, N] }, ['o1', 'o2', 'o4', 'o5', 'o6'], ['o3']], + ['$ne', { $ne: N }, ['o1', 'o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']], + ['$nin', { $nin: [N, N_JAN10] }, ['o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']], +]; + +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; +} + +interface Door { + engine: ObjectQL; + post: (body: Record) => Promise; +} + +/** A booted door over a real sqlite table, populated or empty. */ +async function boot(populated: boolean): Promise { + const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER_ORDER as any); + await engine.syncSchemas(); + if (populated) await engine.insert(OBJECT, ROWS as any); + + 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(); + await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)) } as any, res); + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); + return res._json.records as any[]; + }; + return { engine, post }; +} + +const doors: Record<'populated' | 'empty', Door> = {} as any; +beforeAll(async () => { + doors.populated = await boot(true); + doors.empty = await boot(false); +}); +afterAll(async () => { + for (const d of Object.values(doors)) { + try { await d.engine.destroy(); } catch { /* noop */ } + } +}); + +const sortedIds = (rows: any[]) => rows.map((r) => r.id).sort(); + +describe('[#20203] where — an epoch-ms number on a date field, engine and REST', () => { + for (const [op, comparand, onDate, onDatetime] of OPERATORS) { + it(`${op}: ${onDate.join(', ')} on both doors; nothing on an empty table; the datetime control reads the instant`, async () => { + for (const [state, expectDate, expectDatetime] of [['populated', onDate, onDatetime], ['empty', [], []]] as const) { + const { engine, post } = doors[state]; + expect(sortedIds(await engine.find(OBJECT, { where: { placed_on: comparand } } as any)), `engine, ${state}`).toEqual(expectDate); + expect(sortedIds(await post({ where: { placed_on: comparand } })), `REST, ${state}`).toEqual(expectDate); + expect(sortedIds(await post({ where: { opened_at: comparand } })), `datetime control, ${state}`).toEqual(expectDatetime); + } + }); + } +}); + +describe('[#20203] a per-aggregation filter counts what its where twin counts', () => { + const perAggregation = (filter: unknown) => ({ + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter }], + }); + for (const [op, comparand, onDate] of OPERATORS) { + it(`${op}: ${onDate.length} of 6 on both doors, 0 on an empty table`, async () => { + for (const state of ['populated', 'empty'] as const) { + const { engine, post } = doors[state]; + const expected = state === 'populated' ? onDate.length : 0; + const query = perAggregation({ placed_on: comparand }); + expect((await engine.aggregate(OBJECT, query as any))[0]?.m, `engine, ${state}`).toBe(expected); + expect((await post(query))[0]?.m, `REST, ${state}`).toBe(expected); + } + }); + } +}); + +describe('[#20203] having on max(date) reads the number as a calendar day', () => { + const grouped = (having: unknown) => ({ + groupBy: ['customer_id'], + aggregations: [ + { function: 'max', field: 'placed_on', alias: 'last_placed' }, + { function: 'min', field: 'opened_at', alias: 'first_opened' }, + ], + having, + }); + // c1: last_placed 2026-01-10 · c2: 2026-03-01 · c3: 2026-02-01 + const HAVING: ReadonlyArray, string[]]> = [ + ['$gt on max(date)', { last_placed: { $gt: N } }, ['c2']], + ['$eq on max(date)', { last_placed: { $eq: N } }, ['c3']], + ['$in on max(date)', { last_placed: { $in: [N, N_JAN10] } }, ['c1', 'c3']], + ['control — the same number $gte on min(datetime)', { first_opened: { $gte: N } }, ['c2', 'c3']], + ]; + for (const [name, having, kept] of HAVING) { + it(`${name}: keeps ${kept.join(', ')} on both doors; nothing on an empty table`, async () => { + for (const state of ['populated', 'empty'] as const) { + const { engine, post } = doors[state]; + const expected = state === 'populated' ? kept : []; + const q = grouped(having); + expect((await engine.aggregate(OBJECT, q as any)).map((r: any) => r.customer_id).sort(), `engine, ${state}`).toEqual(expected); + expect((await post(q)).map((r) => r.customer_id).sort(), `REST, ${state}`).toEqual(expected); + } + }); + } +}); From 4c8b92e0220ad470a256ebbe7a146efd2bb173e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:11:30 +0000 Subject: [PATCH 3/4] chore(changeset): record the epoch-ms date comparand reading; correct one clause of the pending 20176 note The 20176 note said no where, write or read answer of either driver changes; in the same release this change moves the number-on-date cells, so the sentence now scopes itself to the lift. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- ...20176-aggregation-temporal-storage-rule.md | 2 +- .changeset/20203-epoch-ms-date-comparand.md | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .changeset/20203-epoch-ms-date-comparand.md diff --git a/.changeset/20176-aggregation-temporal-storage-rule.md b/.changeset/20176-aggregation-temporal-storage-rule.md index fa7304ef6d4..6b9d78d447f 100644 --- a/.changeset/20176-aggregation-temporal-storage-rule.md +++ b/.changeset/20176-aggregation-temporal-storage-rule.md @@ -28,7 +28,7 @@ What the rule does, now in one place: - A value the rule cannot read is compared as written, and so is every non-temporal column, presence tests (`$exists`, `$null`), the text operators and a `{ $field }` reference. - An object whose declared fields the engine cannot see keeps the previous comparison. -`@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so no `where`, write or read answer of either driver changes. MySQL still binds a `datetime` in its own literal spelling. +`@objectstack/core` exports the rule as `temporalStorageForm(value, kind)`, `kind` being `'datetime' | 'date' | 'time'`. `driver-sql` (`canonicalUtcDatetime`, `toDateOnly`, `canonicalTimeOfDay`) and `driver-memory` (`coerceTemporalValue`) each carried a copy of it; both now call it. The copies agreed on every shape measured when they were lifted, so the lift itself changes no `where`, write or read answer of either driver (#20203, in the same release, then reads an epoch-millisecond number on a `date` field as its UTC calendar day). MySQL still binds a `datetime` in its own literal spelling. `@objectstack/objectql`'s `applyInMemoryAggregation(rows, ast, timezone?, fields?)` takes the object's declared field map as an optional fourth argument, and a per-aggregation `filter` reads a temporal comparand by the rule only when it is given. Called without it, the function answers as before. diff --git a/.changeset/20203-epoch-ms-date-comparand.md b/.changeset/20203-epoch-ms-date-comparand.md new file mode 100644 index 00000000000..840404d228d --- /dev/null +++ b/.changeset/20203-epoch-ms-date-comparand.md @@ -0,0 +1,31 @@ +--- +"@objectstack/core": patch +"@objectstack/driver-sql": patch +"@objectstack/driver-memory": patch +"@objectstack/objectql": patch +--- + +fix(core): an epoch-millisecond number compared against a `date` field is read as the UTC calendar day of its instant, by every driver and at every position that compares it (#20203) + +Clause-②: no — no key, export or operator moves, and no comparand that was accepted is now refused: a number was already an accepted comparand on every `date` position, and its answer moves to the storage rule's reading. + +`temporalStorageForm(value, 'date')` in `@objectstack/core` returned a finite number unchanged, so each face compared it by its own type rules and they disagreed. Over six rows, with `1769940000000` (2026-02-01T10:00:00.000Z) against a `date` field: + +| face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) | +|:--|:--|:--|:--|:--|:--| +| `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 | +| `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 | +| `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same | +| a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 | +| **now, on every face above** | **1** | **3** | **2** | **3** | **5** | + +The same holds through `engine.find` and `POST /data/:object/query`, and for `$gte`, `$lte`, `$ne`, `$nin` and implicit equality. PostgreSQL's server refused the bound number itself (`22008`, date/time field value out of range), on an empty table too. `having` over `max` of a `date` field kept no group for `$gt`, `$eq` or `$in`; it now keeps the groups whose day compares. + +A finite number is now read as the `datetime` rule already reads it, as epoch milliseconds. It takes the UTC calendar day of that instant: the day `new Date(value)` names, through the same conversion a `Date` takes. So a number and its `Date` always answer alike. A time of day is dropped, never rounded, a negative number is a day before 1970, and a fraction truncates toward zero as the `Date` constructor does. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call this rule, so they now agree. + +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. From 1c8c5ce75672323192ebaa9656e0aeed202ab9fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:49:40 +0000 Subject: [PATCH 4/4] test: type the new pins' query options instead of casting them The query-options-erasure ratchet counted five new casts on the test surface; the options are on-contract, so they are typed as FilterCondition / EngineAggregateOptions. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../memory-20203-epoch-ms-date-comparand.test.ts | 7 ++++--- ...sql-driver-20203-epoch-ms-date-comparand.test.ts | 9 +++++---- .../src/data-query-epoch-ms-date-comparand.test.ts | 13 +++++++------ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts b/packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts index 43b26422017..8da5612069e 100644 --- a/packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts +++ b/packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts @@ -20,6 +20,7 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; const OBJECT = 'ledger_order'; @@ -62,8 +63,8 @@ const OPERATORS: ReadonlyArray unknown, string[], s describe('[#20203] an epoch-ms number on a date field reads as the UTC calendar day of its instant', () => { let driver: InMemoryDriver; - const ids = async (object: string, where: Record) => - ((await driver.find(object, { where } as any)) as any[]).map((r) => r.id).sort(); + const ids = async (object: string, where: FilterCondition) => + (await driver.find(object, { where })).map((r) => r.id).sort(); beforeAll(async () => { driver = new InMemoryDriver({}); @@ -87,7 +88,7 @@ describe('[#20203] an epoch-ms number on a date field reads as the UTC calendar it('the write path stores the same UTC calendar day — create and update', async () => { await driver.create(WRITES, { id: 'w1', placed_on: N }); await driver.create(WRITES, { id: 'w2', placed_on: -1 }); // one ms before the epoch - const read = async (id: string) => ((await driver.findOne(WRITES, { where: { id } } as any)) as any)?.placed_on; + const read = async (id: string) => (await driver.findOne(WRITES, { where: { id } }))?.placed_on; expect(await read('w1')).toBe('2026-02-01'); expect(await read('w2')).toBe('1969-12-31'); await driver.update(WRITES, 'w1', { placed_on: N_JAN10 }); diff --git a/packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts b/packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts index d99ad08c029..c08d30f6ef6 100644 --- a/packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts @@ -27,6 +27,7 @@ */ 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'; @@ -72,13 +73,13 @@ const OPERATORS: ReadonlyArray unknown, string[], s ['$eq on a UTC midnight', (v) => ({ $eq: v(N_MIDNIGHT) }), ['o4', 'o6'], []], ]; -const NO_AUDIT = { bypassTenantAudit: true } as any; +const NO_AUDIT = { bypassTenantAudit: true }; function measure(cell: DialectCell): void { describe(`[#20203] an epoch-ms number on a date field — ${cell.label}`, () => { let driver: SqlDriver; - const ids = async (table: string, where: Record) => - ((await driver.find(table, { where }, NO_AUDIT)) as any[]).map((r) => r.id).sort(); + 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()); @@ -107,7 +108,7 @@ function measure(cell: DialectCell): void { it('the write path stores the same UTC calendar day — create and update', async () => { await driver.create(WRITES, { id: 'w1', placed_on: N }, NO_AUDIT); await driver.create(WRITES, { id: 'w2', placed_on: -1 }, NO_AUDIT); // one ms before the epoch - const read = async (id: string) => ((await driver.findOne(WRITES, { where: { id } }, NO_AUDIT)) as any)?.placed_on; + const read = async (id: string) => (await driver.findOne(WRITES, { where: { id } }, NO_AUDIT))?.placed_on; expect(await read('w1')).toBe('2026-02-01'); expect(await read('w2')).toBe('1969-12-31'); await driver.update(WRITES, 'w1', { placed_on: N_JAN10 }, NO_AUDIT); diff --git a/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts b/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts index e77b2aa1492..e5137ba441c 100644 --- a/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts +++ b/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts @@ -25,6 +25,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; @@ -129,7 +130,7 @@ describe('[#20203] where — an epoch-ms number on a date field, engine and REST it(`${op}: ${onDate.join(', ')} on both doors; nothing on an empty table; the datetime control reads the instant`, async () => { for (const [state, expectDate, expectDatetime] of [['populated', onDate, onDatetime], ['empty', [], []]] as const) { const { engine, post } = doors[state]; - expect(sortedIds(await engine.find(OBJECT, { where: { placed_on: comparand } } as any)), `engine, ${state}`).toEqual(expectDate); + expect(sortedIds(await engine.find(OBJECT, { where: { placed_on: comparand } })), `engine, ${state}`).toEqual(expectDate); expect(sortedIds(await post({ where: { placed_on: comparand } })), `REST, ${state}`).toEqual(expectDate); expect(sortedIds(await post({ where: { opened_at: comparand } })), `datetime control, ${state}`).toEqual(expectDatetime); } @@ -138,7 +139,7 @@ describe('[#20203] where — an epoch-ms number on a date field, engine and REST }); describe('[#20203] a per-aggregation filter counts what its where twin counts', () => { - const perAggregation = (filter: unknown) => ({ + const perAggregation = (filter: FilterCondition): EngineAggregateOptions => ({ aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter }], }); for (const [op, comparand, onDate] of OPERATORS) { @@ -147,7 +148,7 @@ describe('[#20203] a per-aggregation filter counts what its where twin counts', const { engine, post } = doors[state]; const expected = state === 'populated' ? onDate.length : 0; const query = perAggregation({ placed_on: comparand }); - expect((await engine.aggregate(OBJECT, query as any))[0]?.m, `engine, ${state}`).toBe(expected); + expect((await engine.aggregate(OBJECT, query))[0]?.m, `engine, ${state}`).toBe(expected); expect((await post(query))[0]?.m, `REST, ${state}`).toBe(expected); } }); @@ -155,7 +156,7 @@ describe('[#20203] a per-aggregation filter counts what its where twin counts', }); describe('[#20203] having on max(date) reads the number as a calendar day', () => { - const grouped = (having: unknown) => ({ + const grouped = (having: FilterCondition): EngineAggregateOptions => ({ groupBy: ['customer_id'], aggregations: [ { function: 'max', field: 'placed_on', alias: 'last_placed' }, @@ -164,7 +165,7 @@ describe('[#20203] having on max(date) reads the number as a calendar day', () = having, }); // c1: last_placed 2026-01-10 · c2: 2026-03-01 · c3: 2026-02-01 - const HAVING: ReadonlyArray, string[]]> = [ + const HAVING: ReadonlyArray = [ ['$gt on max(date)', { last_placed: { $gt: N } }, ['c2']], ['$eq on max(date)', { last_placed: { $eq: N } }, ['c3']], ['$in on max(date)', { last_placed: { $in: [N, N_JAN10] } }, ['c1', 'c3']], @@ -176,7 +177,7 @@ describe('[#20203] having on max(date) reads the number as a calendar day', () = const { engine, post } = doors[state]; const expected = state === 'populated' ? kept : []; const q = grouped(having); - expect((await engine.aggregate(OBJECT, q as any)).map((r: any) => r.customer_id).sort(), `engine, ${state}`).toEqual(expected); + expect((await engine.aggregate(OBJECT, q)).map((r: any) => r.customer_id).sort(), `engine, ${state}`).toEqual(expected); expect((await post(q)).map((r) => r.customer_id).sort(), `REST, ${state}`).toEqual(expected); } });