|
| 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