Skip to content

Commit 6cb41f4

Browse files
committed
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 <noreply@anthropic.com>
1 parent ba5c0de commit 6cb41f4

3 files changed

Lines changed: 399 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20203] An epoch-millisecond NUMBER compared against a `date` field is read
5+
* as the UTC calendar day of its instant — by `@objectstack/core`'s
6+
* `temporalStorageForm`, the rule `coerceTemporalValue` calls — the reading a
7+
* `Date` of the same value already had here.
8+
*
9+
* Measured on the base through this driver's `find()`, and through the engine
10+
* and `POST /api/v1/data/:object/query` over it (which reach the same
11+
* coercion): `where: { placed_on: { $gt: 1769940000000 } }` answered 0 of 6,
12+
* because the number reached mingo unchanged and mingo never matches a number
13+
* against the `YYYY-MM-DD` text the column holds. Every operator answered that
14+
* way — 0, or all 6 for `$ne` / `$nin` — while `driver-sql` answered 6 on
15+
* SQLite and a 500 on PostgreSQL (its suite pins it), and the same number on a
16+
* `datetime` field (the control) was read as its instant everywhere.
17+
*
18+
* Each row asserts three things: the number's answer, the `Date` twin's answer
19+
* (identical), and the `datetime` control.
20+
*/
21+
22+
import { beforeAll, describe, expect, it } from 'vitest';
23+
import { InMemoryDriver } from './memory-driver.js';
24+
25+
const OBJECT = 'ledger_order';
26+
const EMPTY = 'ledger_order_empty';
27+
const WRITES = 'ledger_order_writes';
28+
const FIELDS = { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } };
29+
30+
const ROWS = [
31+
{ id: 'o1', placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z' },
32+
{ id: 'o2', placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z' },
33+
{ id: 'o3', placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z' },
34+
{ id: 'o4', placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z' },
35+
{ id: 'o5', placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z' },
36+
{ id: 'o6', placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z' },
37+
];
38+
39+
const N = 1769940000000; // 2026-02-01T10:00:00.000Z — a time of day, not a UTC midnight
40+
const N_JAN10 = 1768057200000; // 2026-01-10T15:00:00.000Z
41+
const N_JAN02 = 1767394800000; // 2026-01-02T23:00:00.000Z — late in its UTC day
42+
const N_MIDNIGHT = 1769904000000; // 2026-02-01T00:00:00.000Z
43+
44+
type As = (ms: number) => unknown;
45+
const asNumber: As = (ms) => ms;
46+
const asDate: As = (ms) => new Date(ms);
47+
48+
/** operator · the comparand, built from a number or its `Date` · `date` ids · `datetime` control ids */
49+
const OPERATORS: ReadonlyArray<readonly [string, (v: As) => unknown, string[], string[]]> = [
50+
['$gt', (v) => ({ $gt: v(N) }), ['o3'], ['o4', 'o5', 'o6']],
51+
['$lt', (v) => ({ $lt: v(N) }), ['o1', 'o2', 'o5'], ['o1', 'o2']],
52+
['$eq', (v) => ({ $eq: v(N) }), ['o4', 'o6'], ['o3']],
53+
['$in', (v) => ({ $in: [v(N), v(N_JAN10)] }), ['o1', 'o4', 'o6'], ['o3']],
54+
['$between', (v) => ({ $between: [v(N_JAN02), v(N)] }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o3']],
55+
['$gte', (v) => ({ $gte: v(N) }), ['o3', 'o4', 'o6'], ['o3', 'o4', 'o5', 'o6']],
56+
['$lte', (v) => ({ $lte: v(N) }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o1', 'o2', 'o3']],
57+
['$ne', (v) => ({ $ne: v(N) }), ['o1', 'o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']],
58+
['$nin', (v) => ({ $nin: [v(N), v(N_JAN10)] }), ['o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']],
59+
['implicit equality', (v) => v(N), ['o4', 'o6'], ['o3']],
60+
['$eq on a UTC midnight', (v) => ({ $eq: v(N_MIDNIGHT) }), ['o4', 'o6'], []],
61+
];
62+
63+
describe('[#20203] an epoch-ms number on a date field reads as the UTC calendar day of its instant', () => {
64+
let driver: InMemoryDriver;
65+
const ids = async (object: string, where: Record<string, unknown>) =>
66+
((await driver.find(object, { where } as any)) as any[]).map((r) => r.id).sort();
67+
68+
beforeAll(async () => {
69+
driver = new InMemoryDriver({});
70+
await driver.connect();
71+
for (const name of [OBJECT, EMPTY, WRITES]) await driver.syncSchema(name, { name, fields: FIELDS });
72+
for (const row of ROWS) await driver.create(OBJECT, { ...row });
73+
});
74+
75+
for (const [op, comparand, onDate, onDatetime] of OPERATORS) {
76+
it(`${op}: ${onDate.join(', ')}; its Date agrees; the datetime control reads the instant`, async () => {
77+
expect(await ids(OBJECT, { placed_on: comparand(asNumber) }), 'number on date').toEqual(onDate);
78+
expect(await ids(OBJECT, { placed_on: comparand(asDate) }), 'the Date twin on date').toEqual(onDate);
79+
expect(await ids(OBJECT, { opened_at: comparand(asNumber) }), 'datetime control').toEqual(onDatetime);
80+
});
81+
82+
it(`${op}: an empty object answers no row`, async () => {
83+
expect(await ids(EMPTY, { placed_on: comparand(asNumber) })).toEqual([]);
84+
});
85+
}
86+
87+
it('the write path stores the same UTC calendar day — create and update', async () => {
88+
await driver.create(WRITES, { id: 'w1', placed_on: N });
89+
await driver.create(WRITES, { id: 'w2', placed_on: -1 }); // one ms before the epoch
90+
const read = async (id: string) => ((await driver.findOne(WRITES, { where: { id } } as any)) as any)?.placed_on;
91+
expect(await read('w1')).toBe('2026-02-01');
92+
expect(await read('w2')).toBe('1969-12-31');
93+
await driver.update(WRITES, 'w1', { placed_on: N_JAN10 });
94+
expect(await read('w1')).toBe('2026-01-10');
95+
});
96+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20203] An epoch-millisecond NUMBER compared against a `date` field is read
5+
* as the UTC calendar day of its instant — by `@objectstack/core`'s
6+
* `temporalStorageForm`, the rule `toDateOnly` / `temporalFilterValue` call —
7+
* on every dialect this driver speaks.
8+
*
9+
* Measured on the base through `find()` (and through the engine and
10+
* `POST /api/v1/data/:object/query`, which reach the same coercion), on this
11+
* file's fixture, with `where: { placed_on: { $gt: 1769940000000 } }`
12+
* (2026-02-01T10:00:00.000Z):
13+
*
14+
* | dialect | base | why |
15+
* |:--|:--|:--|
16+
* | SQLite | 6 of 6 | the number was bound as INTEGER, and SQLite orders every INTEGER below every TEXT |
17+
* | PostgreSQL | `DATABASE_ERROR` (500 at REST) | the server's date input parser refused the bind, `22008` "date/time field value out of range" |
18+
*
19+
* and every other operator split the same way (SQLite: `$gte` / `$ne` / `$nin`
20+
* 6, the rest 0; PostgreSQL: a refusal on each, on an empty table too), while
21+
* the same number on a `datetime` field — the control — was read as its
22+
* instant on both. `driver-memory` answered 0 (its own suite pins it).
23+
*
24+
* The number now names the same day a `Date` of the same value names, so each
25+
* row below asserts three things on one cell: the number's answer, the `Date`
26+
* twin's answer (identical), and the `datetime` control.
27+
*/
28+
29+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
30+
import { SqlDriver } from './sql-driver.js';
31+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
32+
33+
const TABLE = 'os20203_ledger';
34+
const EMPTY = 'os20203_ledger_empty';
35+
const WRITES = 'os20203_ledger_writes';
36+
37+
const shape = (name: string) => ({
38+
name,
39+
fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } },
40+
}) as any;
41+
42+
const ROWS = [
43+
{ id: 'o1', placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z' },
44+
{ id: 'o2', placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z' },
45+
{ id: 'o3', placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z' },
46+
{ id: 'o4', placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z' },
47+
{ id: 'o5', placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z' },
48+
{ id: 'o6', placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z' },
49+
];
50+
51+
const N = 1769940000000; // 2026-02-01T10:00:00.000Z — a time of day, not a UTC midnight
52+
const N_JAN10 = 1768057200000; // 2026-01-10T15:00:00.000Z
53+
const N_JAN02 = 1767394800000; // 2026-01-02T23:00:00.000Z — late in its UTC day
54+
const N_MIDNIGHT = 1769904000000; // 2026-02-01T00:00:00.000Z
55+
56+
type As = (ms: number) => unknown;
57+
const asNumber: As = (ms) => ms;
58+
const asDate: As = (ms) => new Date(ms);
59+
60+
/** operator · the comparand, built from a number or its `Date` · `date` ids · `datetime` control ids */
61+
const OPERATORS: ReadonlyArray<readonly [string, (v: As) => unknown, string[], string[]]> = [
62+
['$gt', (v) => ({ $gt: v(N) }), ['o3'], ['o4', 'o5', 'o6']],
63+
['$lt', (v) => ({ $lt: v(N) }), ['o1', 'o2', 'o5'], ['o1', 'o2']],
64+
['$eq', (v) => ({ $eq: v(N) }), ['o4', 'o6'], ['o3']],
65+
['$in', (v) => ({ $in: [v(N), v(N_JAN10)] }), ['o1', 'o4', 'o6'], ['o3']],
66+
['$between', (v) => ({ $between: [v(N_JAN02), v(N)] }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o3']],
67+
['$gte', (v) => ({ $gte: v(N) }), ['o3', 'o4', 'o6'], ['o3', 'o4', 'o5', 'o6']],
68+
['$lte', (v) => ({ $lte: v(N) }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o1', 'o2', 'o3']],
69+
['$ne', (v) => ({ $ne: v(N) }), ['o1', 'o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']],
70+
['$nin', (v) => ({ $nin: [v(N), v(N_JAN10)] }), ['o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']],
71+
['implicit equality', (v) => v(N), ['o4', 'o6'], ['o3']],
72+
['$eq on a UTC midnight', (v) => ({ $eq: v(N_MIDNIGHT) }), ['o4', 'o6'], []],
73+
];
74+
75+
const NO_AUDIT = { bypassTenantAudit: true } as any;
76+
77+
function measure(cell: DialectCell): void {
78+
describe(`[#20203] an epoch-ms number on a date field — ${cell.label}`, () => {
79+
let driver: SqlDriver;
80+
const ids = async (table: string, where: Record<string, unknown>) =>
81+
((await driver.find(table, { where }, NO_AUDIT)) as any[]).map((r) => r.id).sort();
82+
83+
beforeAll(async () => {
84+
driver = new SqlDriver(cell.config());
85+
for (const t of [TABLE, EMPTY, WRITES]) await driver.execute(`drop table if exists ${t}`).catch(() => {});
86+
await driver.initObjects([shape(TABLE), shape(EMPTY), shape(WRITES)]);
87+
for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT);
88+
});
89+
90+
afterAll(async () => {
91+
for (const t of [TABLE, EMPTY, WRITES]) await driver.execute(`drop table if exists ${t}`).catch(() => {});
92+
await driver.disconnect();
93+
});
94+
95+
for (const [op, comparand, onDate, onDatetime] of OPERATORS) {
96+
it(`${op}: the UTC calendar day of the instant — ${onDate.join(', ')}; its Date agrees; the datetime control reads the instant`, async () => {
97+
expect(await ids(TABLE, { placed_on: comparand(asNumber) }), 'number on date').toEqual(onDate);
98+
expect(await ids(TABLE, { placed_on: comparand(asDate) }), 'the Date twin on date').toEqual(onDate);
99+
expect(await ids(TABLE, { opened_at: comparand(asNumber) }), 'datetime control').toEqual(onDatetime);
100+
});
101+
102+
it(`${op}: an empty table answers no row, not a refusal`, async () => {
103+
expect(await ids(EMPTY, { placed_on: comparand(asNumber) })).toEqual([]);
104+
});
105+
}
106+
107+
it('the write path stores the same UTC calendar day — create and update', async () => {
108+
await driver.create(WRITES, { id: 'w1', placed_on: N }, NO_AUDIT);
109+
await driver.create(WRITES, { id: 'w2', placed_on: -1 }, NO_AUDIT); // one ms before the epoch
110+
const read = async (id: string) => ((await driver.findOne(WRITES, { where: { id } }, NO_AUDIT)) as any)?.placed_on;
111+
expect(await read('w1')).toBe('2026-02-01');
112+
expect(await read('w2')).toBe('1969-12-31');
113+
await driver.update(WRITES, 'w1', { placed_on: N_JAN10 }, NO_AUDIT);
114+
expect(await read('w1')).toBe('2026-01-10');
115+
});
116+
});
117+
}
118+
119+
for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'epoch-ms date comparand', measure);

0 commit comments

Comments
 (0)