Skip to content

Commit 615c468

Browse files
fix(core): read an epoch-ms number on a date field as its UTC calendar day, so driver-memory, SQLite and PostgreSQL give one answer (#20224)
Fixes #20203 Clause-②: no An epoch-millisecond number compared against a `date` field is now read as the UTC calendar day of its instant. The change is in `temporalStorageForm` (`@objectstack/core`), the one storage rule that both drivers and the engine's per-aggregation `filter` / `having` call. One REST request used to get three answers: InMemoryDriver 0 rows, SQLite 6 of 6, and PostgreSQL `DATABASE_ERROR` / 500. All three now give the date's own count, the count a `Date` of the same value already got. ## What changed - `packages/core/src/utils/temporal-storage-form.ts`, the `date` arm: a finite number becomes `new Date(value)` and goes through the same UTC-calendar-day conversion a `Date` takes. `NaN`, ±Infinity and a number outside the `Date` range (past ±8.64e15) come back unchanged. The `datetime` and `time` arms, and every non-number `date` input, are untouched; they measured byte-identical (below). - No driver source changes. `driver-sql` (`toDateOnly`, `temporalFilterValue`) and `driver-memory` (`coerceTemporalValue`) already call this rule, so there is no third copy. - Tests (below), and `.changeset/20203-epoch-ms-date-comparand.md` (patch: core, driver-sql, driver-memory, objectql). - `.changeset/20176-aggregation-temporal-storage-rule.md`: a one-clause deliberate correction (see Deviations). ## Stop valve (ADR-0053): nothing requires a calendar-day string, so the rule reads the number Triage's valve: refuse instead if ADR-0053 states that a `date` comparand must be a calendar-day string. Every sentence found: - ADR-0053, TL;DR: "`date` = a timezone-naive calendar day, represented as a `YYYY-MM-DD` string end-to-end (storage, query, CEL). It is **never** converted to an instant and **never** timezone-shifted." This describes how a date is represented. It does not limit which comparand inputs are accepted. - ADR-0053, Phase 1 decision 1: "A `Date`, a full ISO string, or a `YYYY-MM-DD` string all collapse to the calendar day (UTC calendar day for a `Date`, matching the existing filter coercion)." Inputs that are not a day string are collapsed to one. They are not refused. - ADR-0053, D-C1, the sibling `time` rule: "A `Date` / epoch-ms / full-timestamp value folds to its **UTC** time-of-day". Epoch ms is already read on a temporal column. - `packages/spec/src/data/filter.zod.ts`, the ordering-comparand description: "Comparand is a number, a Date, a string, or a { $field } reference". - `packages/core/src/utils/temporal-comparand.ts` (the door): "A number is epoch milliseconds, a `Date` is an instant, `null` is a null test. The refusal scopes to strings by ruling." The read direction applies. Separately, the engine and REST WRITE doors refuse a number written to a `date` field (`VALIDATION_FAILED`, "must be a valid date (ISO-8601)"). That is the stored-value contract, not a comparand sentence in ADR-0053. It is unchanged here and listed under Acceptance notes. ## The rows Real `InMemoryDriver`, real `SqlDriver` on better-sqlite3 `:memory:`, and live PostgreSQL 16.13 (a private server, server zone `Asia/Shanghai`), schema-synced, over six rows. Each cell was measured through `engine.find` and through `POST /api/v1/data/:object/query` (JSON round-tripped); the two doors agreed in every cell. N = `1769940000000` = 2026-02-01T10:00:00.000Z (a time of day, not a UTC midnight). The base is `3875ae6773`. The head readings were taken at `ba5c0de774` (the fix), and the new pins re-confirm every row at `1c8c5ce756`. | `date` field, operator | memory base → head | SQLite base → head | PostgreSQL base → head | `Date` twin at head | `datetime` control (base = head, all three) | |:--|:--|:--|:--|:--|:--| | `$gt` N | 0 → 1 | 6 → 1 | 500 → 1 | 1 | 3 | | `$lt` N | 0 → 3 | 0 → 3 | 500 → 3 | 3 | 2 | | `$eq` N | 0 → 2 | 0 → 2 | 500 → 2 | 2 | 1 | | `$in` [N, Jan 10 15:00Z] | 0 → 3 | 0 → 3 | 500 → 3 | 3 | 1 | | `$between` [Jan 2 23:00Z, N] | 0 → 5 | 0 → 5 | 500 → 5 | 5 | 1 | | `$gte` N | 0 → 3 | 6 → 3 | 500 → 3 | 3 | 4 | | `$lte` N | 0 → 5 | 0 → 5 | 500 → 5 | 5 | 3 | | `$ne` N | 6 → 4 | 6 → 4 | 500 → 4 | 4 | 5 | | `$nin` [N, Jan 10 15:00Z] | 6 → 3 | 6 → 3 | 500 → 3 | 3 | 5 | | implicit equality | 0 → 2 | 0 → 2 | 500 → 2 | 2 | 1 | | `$eq` on a UTC midnight | 0 → 2 | 0 → 2 | 500 → 2 | 2 | 0 | - **Empty table:** memory and SQLite answered 0 before and after. PostgreSQL answered 500 on every operator before (the bind is refused whether or not rows exist) and 0 after. - **PostgreSQL's 500, root cause.** `toDateOnly` handed the number through unchanged. The statement was `select * from "ledger_order" where "placed_on" > $1` with bindings `[1769940000000]`, and node-pg sent that as text. The PostgreSQL server's own date input parser refused it: SQLSTATE `22008`, routine `DateTimeParseError`, "date/time field value out of range". driver-sql's read-error mapper wrapped that as an unattributed `DATABASE_ERROR`, and REST answered 500. The raw `knex.raw` bind reproduced the same error. - **H3, which day.** The number reads as the UTC day of `new Date(value)`, which is the `toDateOnly` convention. The `Date` twin column above equals the number column on every cell. It was measured through the engine on all three drivers, and on the driver door for all 11 operators in the new pins. Rule-level edge cases: a negative number is a day before 1970 (`-1` → `1969-12-31`); a fraction truncates toward zero, as the `Date` constructor does (`-0.5` → `1970-01-01`); `-0` → `1970-01-01`; the `Date` range edges read `275760-09-13` / `-271821-04-20`. `NaN`, ±Infinity and values past the range are unchanged. ## H4, no collateral - **The rule, over 34 shapes × 3 kinds (102 cells):** 12 moved. All 12 are `date` × a finite number inside the `Date` range. The other 90 are byte-identical base → head: every `datetime` and `time` cell, and on `date` every `Date`, Invalid Date, `NaN`, ±Infinity, past-range number, bigint, boxed `Number`, epoch-ms string, ISO / bare-day / zone-naive / offset string, wall clock, junk, empty string, `null`, `undefined`, boolean, object and array. - **End to end, 466 cells** (`where`, per-aggregation `filter` and `having` through both doors, write and read paths, the rule sweep): 113 moved, and none of them is outside "a number on a `date` field". - **`where` = per-aggregation `filter` at head,** on all 132 keys (3 drivers × populated / empty × `date` / `datetime` × 11 operators), through both doors. At base the per-aggregation `filter` gave yet another answer: 0 on every operator except `$between`, `$ne` and `$nin` (6), on all three drivers. - **`having`, both doors (native and rows):** only `max(date)` moved. `$gt` N: no group → c2. `$eq` N: no group → c3. `$in` [N, Jan 10]: no group → c1, c3. The `min(datetime)` `$gte` N control kept c2, c3 before and after. - **Write paths:** `engine.insert` and REST create of a number on a `date` field answer `VALIDATION_FAILED` / 400 at base and at head. Direct `driver.create` / `driver.update` of a number on a `date` field: memory and SQLite stored the number, PostgreSQL refused (`22008`); all three now store `2026-02-01`. The same number written to a `datetime` or `time` field is unchanged (`2026-02-01T10:00:00.000Z` / `10:00:00`). - **Read path (SQLite, a raw INTEGER already in a `date` column):** `find`, `findOne`, a `groupBy` key and `distinct` presented `1769940000000` before and now present `2026-02-01`. This is ADR-0053 Phase 1's read-repair reading. `max` over a mixed group is unchanged, and so is a `where` day comparand against that raw row (see Acceptance notes). ## Tests New or changed: - `packages/core/src/utils/temporal-storage-form.test.ts`: the old pin "epoch milliseconds are not a calendar day, returned as they came" pinned exactly the branch this changes, so it is replaced whole. 11 number cases, the number = its `Date` agreement, the number's day = the `datetime` rule's instant's day, and 8 unchanged inputs. - `packages/drivers/driver-sql/src/sql-driver-20203-epoch-ms-date-comparand.test.ts`: a live-dialect matrix. SQLite always runs; PostgreSQL and MySQL run when provisioned, as in CI's Temporal Conformance job. It covers 11 operators × (number, `Date` twin, `datetime` control), an empty table, and the create / update write path. - `packages/drivers/driver-memory/src/memory-20203-epoch-ms-date-comparand.test.ts`: the same rows at this driver's door. - `packages/rest/src/data-query-epoch-ms-date-comparand.test.ts`: engine and REST over `SqlDriver`, at `where` (7 operators, with the `datetime` control), a per-aggregation `filter` equal to its `where` twin, and `having` on `max(date)`, populated and empty. Whole suites, after merging `main` (`4df101c383`) and rebuilding the closure. Every package ran with `vitest run` and no `--project`, so both `local` and `repo` projects ran where a package declares them: - `@objectstack/core`: 57 files / 1488 passed. - `@objectstack/driver-memory`: 55 / 1351 passed. - `@objectstack/driver-sql` with live PostgreSQL attached (`TZ=America/New_York`, server `Asia/Shanghai`): 197 files passed, 3 skipped / 3852 tests passed, 87 skipped. The package's own reporter says: sqlite RAN, live postgres RAN, live mysql NOT RUN. - `@objectstack/objectql`: 319 / 5767 passed. - `@objectstack/rest` (includes every query-route test): 201 files / 3582 passed, 1 skipped. Typecheck: `core`, `driver-memory`, `driver-sql` and `rest` exit 0. Each new or changed test file is in a compiled program (`--listFiles`: `tsconfig.json` for the two drivers, `tsconfig.test.json` for core and rest). The test-typecheck ledgers are unchanged (core 4 held, rest 0). ## Reverse verification The fix was committed first. `ablation-replace` (wrap mode, restore trap on the absolute path) replaced the one number line with `const instant = value;`: anchor 1 → 0, blob `61a6c0861a` → `2a9bd5d997`. Core was rebuilt, and `ablation-dist-preflight --absent` passed. Pins in the mutated state: - core 13 failed / 59 passed (the 11 number cases and the 2 agreement tests); - driver-memory 12 failed / 11 passed (the 11 greens are the empty-object rows, 0 either way); - driver-sql on SQLite + live PostgreSQL 35 failed / 11 passed / 1 skipped (the 11 greens are SQLite's empty tables; PostgreSQL's empty tables went red because the 500 came back); - REST 17 failed / 1 passed (the `min(datetime)` control). The tool restored the file: blob `61a6c0861a` = HEAD, `git diff HEAD` empty, whole-tree `git status --porcelain` empty. Core was rebuilt, and the preflight found the marker present again. Pins: core 72, memory 23, driver-sql 46 + 1 (MySQL declared unprovisioned), REST 18, all passed. ## Gates `dispatch-gates --commands --repo objectstack-ai/objectstack` at `1c8c5ce756` derives 62 commands (the dispatch clue's 47 plus the changeset families this diff adds, and more). Every command was run at `1c8c5ce756`: 59 exit 0, 1 exit 1, 2 exit 3. - `check-empty-changeset --base origin/main` **exit 1, on purpose.** This is the deliberate correction below. It needs a person's confirmation, and the file must not be restored from the base. - `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt`: **NOT MEASURED**, `PREREQUISITE NOT MET`. Both read every package's built output, which needs the whole `./packages/*` build; CI builds that before these steps. `--ran` reconciles: 62 derived, 60 run, 2 NOT-MEASURED, 0 unrun. Also run: - the three roster gates whose roster sits under `packages/` (`check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`): exit 0 each; - `check-issue-citations --base 4df101c`: exit 0; - a control-byte scan of the 7 changed files: none found. Lint, a declared narrowing: `eslint --no-inline-config --format json` on the 5 changed `.ts` files at `1c8c5ce756` reports 5 files, 0 errors / 0 warnings / 0 fatal, and `--print-config` resolves a config for each. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. ## Deviations - **`.changeset/20176-aggregation-temporal-storage-rule.md`, a one-clause DELIBERATE CORRECTION.** The pending note says "The copies agreed on every shape measured when they were lifted, so no `where`, write or read answer of either driver changes." This PR changes the number-on-`date` answers in the same release, so that sentence would ship false. It now reads "…, 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)." Nothing else in that note changed. `check-empty-changeset` is red for exactly this; please confirm it here. - **Where each driver is pinned.** `packages/rest` has no `driver-memory` dependency, and `driver-memory` cannot reach the engine, so memory's engine and REST cells are measured (the table above) but not committed as pins; its `where` is pinned at its own driver door. PostgreSQL is pinned at the driver door, in driver-sql's live matrix, and its engine and REST cells are measured. This follows PR #20202's precedent. The claim's file surface ("tests in core, the two drivers and REST") has no package that holds all three. ## Acceptance notes - **PostgreSQL after the change: the 500 is gone.** On a private live PostgreSQL 16.13 (server zone `Asia/Shanghai`), REST `where: { placed_on: { $gt: 1769940000000 } }` answers 200 with 1 row (`o3`). All 11 operators give the date's count on a populated table and 0 on an empty one, through the engine and REST, and none answers `DATABASE_ERROR`. The live cell PR #20202's review used was not reachable from this container. - MySQL: NOT MEASURED here (no server). The new driver-sql file runs in CI's Temporal Conformance MySQL cell. - `SqlDriver.toDateOnly`'s docstring still says "Anything else (and `null`/`undefined`) passes through unchanged", and a number no longer does. That is driver source outside the claim, so it was not edited. - A number already stored in a SQLite `date` column is now presented as its day on read. It still does not match a day comparand in `where`, because the column side has no legacy repair for `date` (one exists for `datetime`). Only a raw write can put such a row there. This is unchanged by this PR. - The write doors refuse a number on a `date` field, while the read doors now read one. The two have different contracts (stored value vs comparand), and neither moved beyond what is listed above. - `driver-turso` inherits `temporalFilterValue` from `SqlDriver`, and service-analytics binds through the driver's `temporalFilterValue`, so both follow the rule. Neither was measured separately. ## Out-of-scope findings (reported for the seat; nothing filed from here) 1. **class (a).** `driver-mongodb`'s `mongodb-temporal.ts` `storageDateValue`, its own copy of the `date` rule, still returns a finite number unchanged. Probe: `storageDateValue(1769940000000)` → `1769940000000`, where core's `temporalStorageForm(…, 'date')` now answers `2026-02-01`. A `Date` and an ISO string agree on both. On a live Mongo, a number against the `YYYY-MM-DD` text meets BSON type bracketing, which is what gave driver-memory its 0: NOT MEASURED, no server here. Dedupe words: `driver-mongodb epoch ms number date field` · `mongodb storageDateValue number unchanged` · `mongodb-temporal date rule copy`. 2. **class (a).** The `date` rule's `Date` conversion (`${y}-${m}-${d}` from `getUTCFullYear`) spells a year outside 1000..9999 unpadded (`999-06-15`, `10000-01-01`, `-1-01-01`), so the text no longer orders as a `YYYY-MM-DD` day. Measured at head with the same fixture: for year 999, memory and SQLite answer `$gt` 0 and `$lt` 6 (PostgreSQL 6 / 0, correct); for year 10000, memory and SQLite answer 6 / 0 (PostgreSQL 0 / 6, correct); for year -1, PostgreSQL answers 500. A `Date` does the same in-process. This PR routes a number through that one conversion, so a REST number now reaches it too. Fixing it moves `Date` cells, which this card's ruling keeps byte-identical, so it is left to its own card. Dedupe words: `date rule year below 1000 unpadded` · `calendar day year outside 4 digits text order` · `toDateOnly getUTCFullYear padding`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93cfc3f commit 615c468

7 files changed

Lines changed: 499 additions & 9 deletions

‎.changeset/20176-aggregation-temporal-storage-rule.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ What the rule does, now in one place:
2828
- 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.
2929
- An object whose declared fields the engine cannot see keeps the previous comparison.
3030

31-
`@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.
31+
`@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.
3232

3333
`@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.
3434

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/driver-sql": patch
4+
"@objectstack/driver-memory": patch
5+
"@objectstack/objectql": patch
6+
---
7+
8+
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)
9+
10+
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.
11+
12+
`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:
13+
14+
| face | `$gt` | `$lt` | `$eq` | `$in` (with a Jan 10 member) | `$between` (from Jan 2) |
15+
|:--|:--|:--|:--|:--|:--|
16+
| `where` on `driver-memory` | 0 | 0 | 0 | 0 | 0 |
17+
| `where` on `driver-sql`, SQLite | 6 | 0 | 0 | 0 | 0 |
18+
| `where` on `driver-sql`, PostgreSQL | `DATABASE_ERROR`, a 500 at REST | the same | the same | the same | the same |
19+
| a per-aggregation `filter` on `engine.aggregate` | 0 | 0 | 0 | 0 | 6 |
20+
| **now, on every face above** | **1** | **3** | **2** | **3** | **5** |
21+
22+
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.
23+
24+
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.
25+
26+
The rule is shared by the drivers' write and read paths too:
27+
28+
- `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.
29+
- 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.
30+
31+
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.

‎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') {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 type { FilterCondition } from '@objectstack/spec/data';
24+
import { InMemoryDriver } from './memory-driver.js';
25+
26+
const OBJECT = 'ledger_order';
27+
const EMPTY = 'ledger_order_empty';
28+
const WRITES = 'ledger_order_writes';
29+
const FIELDS = { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } };
30+
31+
const ROWS = [
32+
{ id: 'o1', placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z' },
33+
{ id: 'o2', placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z' },
34+
{ id: 'o3', placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z' },
35+
{ id: 'o4', placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z' },
36+
{ id: 'o5', placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z' },
37+
{ id: 'o6', placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z' },
38+
];
39+
40+
const N = 1769940000000; // 2026-02-01T10:00:00.000Z — a time of day, not a UTC midnight
41+
const N_JAN10 = 1768057200000; // 2026-01-10T15:00:00.000Z
42+
const N_JAN02 = 1767394800000; // 2026-01-02T23:00:00.000Z — late in its UTC day
43+
const N_MIDNIGHT = 1769904000000; // 2026-02-01T00:00:00.000Z
44+
45+
type As = (ms: number) => unknown;
46+
const asNumber: As = (ms) => ms;
47+
const asDate: As = (ms) => new Date(ms);
48+
49+
/** operator · the comparand, built from a number or its `Date` · `date` ids · `datetime` control ids */
50+
const OPERATORS: ReadonlyArray<readonly [string, (v: As) => unknown, string[], string[]]> = [
51+
['$gt', (v) => ({ $gt: v(N) }), ['o3'], ['o4', 'o5', 'o6']],
52+
['$lt', (v) => ({ $lt: v(N) }), ['o1', 'o2', 'o5'], ['o1', 'o2']],
53+
['$eq', (v) => ({ $eq: v(N) }), ['o4', 'o6'], ['o3']],
54+
['$in', (v) => ({ $in: [v(N), v(N_JAN10)] }), ['o1', 'o4', 'o6'], ['o3']],
55+
['$between', (v) => ({ $between: [v(N_JAN02), v(N)] }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o3']],
56+
['$gte', (v) => ({ $gte: v(N) }), ['o3', 'o4', 'o6'], ['o3', 'o4', 'o5', 'o6']],
57+
['$lte', (v) => ({ $lte: v(N) }), ['o1', 'o2', 'o4', 'o5', 'o6'], ['o1', 'o2', 'o3']],
58+
['$ne', (v) => ({ $ne: v(N) }), ['o1', 'o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']],
59+
['$nin', (v) => ({ $nin: [v(N), v(N_JAN10)] }), ['o2', 'o3', 'o5'], ['o1', 'o2', 'o4', 'o5', 'o6']],
60+
['implicit equality', (v) => v(N), ['o4', 'o6'], ['o3']],
61+
['$eq on a UTC midnight', (v) => ({ $eq: v(N_MIDNIGHT) }), ['o4', 'o6'], []],
62+
];
63+
64+
describe('[#20203] an epoch-ms number on a date field reads as the UTC calendar day of its instant', () => {
65+
let driver: InMemoryDriver;
66+
const ids = async (object: string, where: FilterCondition) =>
67+
(await driver.find(object, { where })).map((r) => r.id).sort();
68+
69+
beforeAll(async () => {
70+
driver = new InMemoryDriver({});
71+
await driver.connect();
72+
for (const name of [OBJECT, EMPTY, WRITES]) await driver.syncSchema(name, { name, fields: FIELDS });
73+
for (const row of ROWS) await driver.create(OBJECT, { ...row });
74+
});
75+
76+
for (const [op, comparand, onDate, onDatetime] of OPERATORS) {
77+
it(`${op}: ${onDate.join(', ')}; its Date agrees; the datetime control reads the instant`, async () => {
78+
expect(await ids(OBJECT, { placed_on: comparand(asNumber) }), 'number on date').toEqual(onDate);
79+
expect(await ids(OBJECT, { placed_on: comparand(asDate) }), 'the Date twin on date').toEqual(onDate);
80+
expect(await ids(OBJECT, { opened_at: comparand(asNumber) }), 'datetime control').toEqual(onDatetime);
81+
});
82+
83+
it(`${op}: an empty object answers no row`, async () => {
84+
expect(await ids(EMPTY, { placed_on: comparand(asNumber) })).toEqual([]);
85+
});
86+
}
87+
88+
it('the write path stores the same UTC calendar day — create and update', async () => {
89+
await driver.create(WRITES, { id: 'w1', placed_on: N });
90+
await driver.create(WRITES, { id: 'w2', placed_on: -1 }); // one ms before the epoch
91+
const read = async (id: string) => (await driver.findOne(WRITES, { where: { id } }))?.placed_on;
92+
expect(await read('w1')).toBe('2026-02-01');
93+
expect(await read('w2')).toBe('1969-12-31');
94+
await driver.update(WRITES, 'w1', { placed_on: N_JAN10 });
95+
expect(await read('w1')).toBe('2026-01-10');
96+
});
97+
});

0 commit comments

Comments
 (0)