Skip to content

Commit 2473e26

Browse files
fix(core,objectql)!: a temporal comparand is refused exactly when the write door refuses it — a real calendar day, an ISO datetime spelling, and a time instant with a four-digit UTC year (#20668)
Fixes #20549 Fixes #20480 Clause-②: no (narrowing) A family PR: two cards, one branch, one changeset, one commit per card. - `70b98719c` is #20549. The temporal comparand door now refuses what the write door refuses: a calendar day that does not exist, and a `datetime` string outside the ISO 8601 spellings the platform writes. - `0adb1bf84` is #20480. A `time` comparand whose instant has no four-digit UTC year is refused, in every spelling. Measured head: `0adb1bf84`, rebased onto `origin/main` `19fc8d6f1`. The two commits since `main` touch no file that `main` moved. ## The change ### `@objectstack/core` — `packages/core/src/utils/temporal-comparand.ts` - **Moved, not copied.** `ISO_DATETIME_WRITE_FORM` and `namesRealCalendarDay` leave `record-validator.ts` and sit beside `readsAsCalendarDay`. Both stay module-private, so there is no new root export. - `readsAsCalendarDay` (the `date` reading) now also requires the leading day to exist. - `readsAsInstant` (the `datetime` reading, which a `time` column also uses for an instant) now requires three things: - one of the ISO spellings; - a real leading day; - an instant that `Date.parse` reads. The bare-integer-string arm is gone (see H3). Epoch milliseconds as a NUMBER are untouched. - (#20480) There is a new private helper, `keepsTimeOfDay(value)`. It asks `temporalStorageForm(value, 'time')` itself whether the rule keeps an `HH:MM:SS` time of day. The `time` arm now refuses an instant string the rule hands back unchanged. So does a finite number or a valid `Date`, which the `time` arm never judged before. - Year 0 spells `0000-…`, so it is still read. - NaN, Infinity and an Invalid Date stay unjudged. ### `@objectstack/objectql` - **`validation/record-validator.ts`** (the write door). The `date` / `datetime` arm is now `readable && !isUninterpretableTemporalComparand(t, value)`. - Its private copies are deleted. - `readable` stays on top. It is the one place the two doors differ, on purpose: a NUMBER is refused as a written value but read as a comparand. - The `time` arm is not in the diff. - **`temporal-comparand-door.ts`**. The verdict is unchanged: core's predicate, `INVALID_FILTER` / 400, naming the field, before any read. The refusal text changes only as far as the new classes need, because "compare false for EVERY row" is untrue for them: - `whose calendar day does not exist`; - `not one of the ISO 8601 spellings`; - (#20480) `an instant whose UTC year falls outside the years 0001 to 9999, so no time of day is read from it`. Each has a `where` and a `having` sentence. The remedy now says "on a calendar day that exists" and "epoch milliseconds as a number". The junk class and the year class keep their exact words. ## Measured, before and after Through `engine.find` over InMemoryDriver, SqlDriver on SQLite, and SqlDriver on a live PostgreSQL 16.13. The process ran under `TZ=America/New_York`, and the server at `Asia/Shanghai`. Base is `cd901d7a5`; after is `0adb1bf84`. Rows are `r1` 2026-03-02T10:00Z / 09:00, `r2` 2026-07-15T14:00Z / 10:30, and `r3` 2028-02-29T10:00Z / 12:00. | comparand | base: memory / SQLite / PostgreSQL | after, all three | |:--|:--|:--| | datetime `$eq "2026-02-30T10:00:00Z"` | `[r1]` (rolled over) / `[r1]` / `[r1]` | 400 `INVALID_FILTER` | | datetime `$eq "07/15/2026 10:00"`, `"2026/07/15 10:00"` | `[r2]` (process zone) / `[r2]` / `[r2]` | 400 | | date `$eq "2026-02-30"` | `[]` / `[]` / 500 `DATABASE_ERROR` | 400 | | time `$gt "+010000-01-01T10:00:00Z"` (the #20480 card) | 3 of 3 / 3 of 3 / 500 | 400 | | time `$lt` the same | `[]` / `[]` / 500 | 400 | | time `$gt "9999-12-31T23:00:00-02:00"` (UTC year 10000) | `[]` / `[]` / 500 | 400 | | time `$gt` the number or `Date` of `+010000-01-01T10:00Z` | `[]` / 3 of 3 / 500 | 400 | | time `$gt "07/15/2026 10:00"` | `[]` (14:00 UTC, the process zone) | 400 | | datetime `$gt "2026"` | every row (read as 2026 epoch ms) | 400 | | datetime `$gt 1769940000000` (a number) | 3 of 3 | unchanged, 3 of 3 | | controls: leap day `2028-02-29` on date and datetime; ISO `Z`, `+08:00` and zone-naive `"2026-07-15 14:00"`; time `$gt` / `$lt "2026-07-15T10:00:00Z"`; time `$gt "10:00"` | `[r3]`, `[r3]`, `[r2]`×3, `[r2,r3]` / `[r1]`, `[r2,r3]` | identical | After commit 1 alone, the #20480 card's literal row already read 400 on all three, because it is not an ISO spelling. The UTC-year-10000 ISO spelling and the number and `Date` spellings still answered as at base. The second commit closes those (see H1). ## PM hypotheses — which held - **H1: partly held.** Tightening `readsAsInstant` to the ISO form refuses the card's `+010000-…` spelling as a side effect (measured after commit 1). It does not close the class: `9999-12-31T23:00:00-02:00` is an ISO spelling whose UTC year is 10000, and the number and `Date` never reached the string arm. So `time` needs its own arm, which is commit 2. - **H2: held.** The validator calls core's one predicate for both arms and deletes its copies. No root export was added: `export *` from `temporal-comparand.ts` exposes exactly the three symbols it did before. `Clause-②` stays `no (narrowing)`. - **H3: measured. One legitimate difference is kept, and one is removed.** - An epoch-ms NUMBER is refused by the write door (`readable`) and read as a comparand. This predates this PR, is scoped by the #8690 ruling (strings only), and is now asserted in `engine-temporal-comparand-door.test.ts`. - A zone-naive ISO string is admitted by both doors, so there is no divergence. - An epoch-ms STRING is refused by the write door. The comparand door read it, and two earlier suites pinned it as a control. I found no producer: the token resolver and the analytics date range emit ISO text, and no test in the repo filters with one. The same arm read `"2026"` as two seconds after 1970, and matched every row on all three backends. Zone 1's direction ("the comparand door refuses what the write door refuses") and the claim's "`datetime`: only the ISO 8601 spelling the write door admits" therefore cover it, and it is refused. The two pins are flipped, with load-bearing assertions (below). Because earlier cards pinned it, this is raised in the report as an open question rather than decided silently. - A `date` string with a real leading day and text `Date.parse` cannot read after it (`2026-07-15T25:00`) is refused by the write door. It is read by the comparand door as its day, the #20481 shape. This predates this PR and is outside both cards' classes, so it is left as is (Acceptance notes). - **H4: held.** `service-analytics/src/comparand-shape.ts` is not edited. Its `judgeTemporalLiterals` calls core's predicate, so the raw-SQL decline moves with it. The whole suite is green on `0adb1bf84`: 135 files, 3171 tests, under `TZ=America/New_York`. No pin flipped. - **H5: nothing to carry.** Core's `namesRealCalendarDay` stays private, so there is still nothing for `packages/rest/src/import-coerce.ts` to read in place of its own copy. ## Compile faces — one conclusion each The door is the engine's single filter collection point, in front of every driver. The pins show zero driver reads on every refusal: a recording driver in objectql, and the REST door over SQLite and PostgreSQL. 1. `driver-sql` `applyFilterCondition`, with `driver-sqlite-wasm` and turso LOCAL: **already compliant, behind the door.** Measured: the refusal happens before any read on SQLite and PostgreSQL. The driver's deliberate pass-through (`temporalFilterValue('t','at','not-a-date')`) is unchanged. 2. turso `RemoteTransport.buildWhereSQL`: **already compliant, behind the same door.** Not measured, because no turso server was available. 3. service-analytics `compileScopedFilterToSql` (RLS read side): **explicitly out of scope.** It compiles the platform's injected RLS predicate, which the door never judges by design (the door's docblock: "an injected read filter is the platform's own"). 4. service-analytics `lowerAnalyticsWhere`: **changed by inheritance.** A comparand core now refuses is declined off the raw-SQL strategy to the ObjectQL strategy, whose `engine.aggregate` passes the door. The suite is green, as in H4. 5. `formula` `matchesFilterCondition`: **explicitly out of scope.** It evaluates authored RLS `check` predicates and formula conditions, not a caller's `where`. Its own `2026-02-30` text-fallback pin (`matches-filter.test.ts:180`) is unchanged. - Half face, objectql `applyHaving` / `matchesHaving`: **changed.** `assertHavingTemporalComparandsInterpretable` runs the same predicate. It is pinned in `engine-temporal-comparand-door.test.ts` (#20549 and #20480 `having` rows) and `engine-aggregate-having-temporal-door.test.ts` (#20480 rows, plus the parity table computed from the predicate). - Per-aggregation `filter`: **changed**, and pinned in the same two files and in `engine-aggregate-temporal-storage-rule.test.ts`. - `driver-memory` `checkCondition`: **behind the door.** Measured: the engine over InMemoryDriver refuses every row above. The driver's admitted-controls half is pinned. - `driver-mongodb` `translateFieldOperators`: **behind the door, not measured**, because no MongoDB was available. ## Pins **#20549** (commit 1): - `core` `temporal-comparand.test.ts`, a new describe: - impossible days on date and datetime (plus the datetime day part) are refused, and leap days and month ends are read; - 15 non-ISO datetime spellings are refused, each shown `Date.parse`-readable (or a bare integer) as the control; - 14 ISO spellings are read; - `T25:00` and `+99:99` are refused; - the date reading of an instant on a real day is kept; - the `time` instant half is covered; - the exemptions (empty string, blank, `{today}`, an epoch-ms number, a `Date`) are kept. - `objectql` `engine-temporal-comparand-door.test.ts`, a new describe: - 14 comparands × `$eq` / `$gt` / `$in` member → `INVALID_FILTER` / 400 naming the field, with zero reads, and not the junk class's words; - the leap day and ISO controls in the same `it`; - the per-aggregation filter and `having` positions; - a **one-rule corpus pin:** over 32 strings × 2 fields, a string is refused as a comparand exactly when `engine.insert` refuses it with `invalid_date`; - the number difference, asserted. - `rest` `data-temporal-write-real-day-iso.test.ts`, beside the write door's twin rows: `POST /api/v1/data/:object/query` answers 400 `INVALID_FILTER` for the card's values with no read, and the leap and ISO controls find their rows, on SQLite and on live PostgreSQL. - Driver halves: - `sql-driver-20264-temporal-year-range.test.ts` gains a leap row and an `it` over 7 ISO spellings, on the dialect matrix CI's `Temporal Conformance (live PG + MySQL)` job runs; - `memory-20525-temporal-write-real-day-iso.test.ts` gains comparand controls under `America/New_York`. **#20480** (commit 2): - `core`: a new describe covers the card's spelling, UTC year 10000 and year -1, numbers and `Date`s, and the Date-range extremes, all refused. The 2026 control and year 0 are read. An agreement pin: refused exactly when `temporalStorageForm(v, 'time') === v`. - `objectql` `engine-temporal-comparand-door.test.ts`: five spellings × `$gt` / `$lt` / `$eq` at `where`, plus the per-aggregation filter and `having`, with zero reads, beside the 2026 control and year 0. - `engine-aggregate-having-temporal-door.test.ts`: two refused rows, and two 2026 controls on `max(time)`. - `rest`: a `time` field on SQLite and live PostgreSQL. The card's instant, the UTC-10000 ISO spelling and the number answer 400, with no read. The 2026 instant, the offset and the number answer 2 / 1. - Drivers: - `sql-driver-time-live-dialects.test.ts` gains the 2026 control in five spellings, on live PostgreSQL and MySQL (CI's temporal job); - `memory-temporal-storage-form.test.ts` gains four 2026 control rows. **Flipped pins, one round (sweep ①).** Each keeps a load-bearing assertion of the new semantics: - `core` `temporal-comparand.test.ts`: the #20264 control `'1769940000000'` (string) moves to the number `1769940000000`, read. The string is refused in the #20549 describe. The #20240 "leaves the time rule alone" test becomes a per-value verdict: year 0 and in-range are read; years 10000 / -1 and the Date extremes are refused, with the rule's own output asserted. - `objectql` `engine-aggregate-temporal-storage-rule.test.ts`: the family row "an epoch-ms string `$gt` on a datetime counts 3" is now `INVALID_FILTER` / 400 at both positions, for `"1769940000000"` and `"2026"`. The number of the same instant still counts 3. - `objectql` `engine-temporal-year-range.test.ts`: "a time field judges no year" becomes year 0 read (three spellings, three reads) and years 10000 / -1 refused (five spellings), with no further read. - `objectql` `engine-date-year-range-door.test.ts`: the time half becomes year 0 read (two reads) and 10000 / -1 refused. - `objectql` `engine-aggregate-having-temporal-door.test.ts`: "the number for 10000-01-01 on max(time) — not judged on time" moves from the unchanged table to the refused table (`INVALID_FILTER` / 400, no read, and the `where` twin agrees). The repo-wide sweep found no other same-semantic pin, re-run on `0adb1bf84`. It covered quoted 4–14 digit strings under a filter operator on a temporal field, slashed or worded datetime comparands, and extended-year comparands on `time` fields, over every `*.test.ts`. Truly illegal shapes keep their refusal assertions verbatim. ## Verification, on `0adb1bf84` Every suite below ran under `TZ=America/New_York`. PostgreSQL cells ran against a live PostgreSQL 16.13 at `Asia/Shanghai`. - `pnpm --filter @objectstack/core test`: 57 files / 1536 tests passed. - `@objectstack/objectql`, whole suite: 336 files / 6674 tests passed. - `@objectstack/rest`, whole suite with live PostgreSQL: 228 files, 4422 passed / 22 skipped. The changed file ran verbosely, and both the `sqlite` and `live postgres` cells executed every #20549 and #20480 `it`. - `@objectstack/driver-memory` test: 62 files / 1436 passed. - `@objectstack/driver-sql` test with live PostgreSQL: 208 files passed / 3 skipped, 4023 tests passed / 95 skipped. The two changed files ran verbosely: the SQLite and live-postgres cells ran; MySQL is a named skip. - `@objectstack/service-analytics`: 135 files / 3171 passed. - `typecheck` passed for all five: core, objectql, rest, driver-memory and driver-sql (the test layers included, with their `test-typecheck-debt.json` ledgers unchanged). - `pnpm check:driver-conformance`, before (`19fc8d6f1`) and after (`0adb1bf84`): identical, 50 covered cells, 0 DEBT, 0 exempt, dialect axis 10 of 10. - `node scripts/pm/dispatch-gates.mjs --commands` on the final head derived 67 commands. All 67 ran with exit 0. - `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET, no workspace `dist/`). They were re-run with exit 0 after `turbo run build --filter='./packages/*' --filter='./packages/*/*'`. - `--ran` over the recorded exit codes: "67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero)". - The five artifact-roster gates whose rosters sit under a changed directory also ran, exit 0: `check-changeset-fixed`, `check:authz-resolver`, `check:filter-alias-parity`, `check:object-def-param-keys` and `check:tenant-chokepoint`. - Lint, narrowed and declared (the repo-wide `pnpm lint` is CI's). `eslint --no-inline-config --format json` over the 14 changed `.ts` files: 14 files, 0 errors, 0 warnings. - Population: `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` / `packages/**` objects cover all 14. - Invariance: the config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. - **Ablation**, two legs each, through `scripts/ablation-replace.mjs` and `scripts/ablation-dist-preflight.mjs`, with a `trap` restore. Each restore was proven: blob == HEAD, and a clean `git status --porcelain`. - **A (#20549).** The `readsAsInstant` ISO / real-day guard was deleted: anchor 1 → 0, and the marker was absent from `packages/core/dist`. Core's predicate suite went 3 failed / 23, and the objectql door suite went 2 failed / 14 (the refusal and the aggregation/`having` tests). The corpus agreement pin stayed green, correctly, because both doors moved together. Restored and rebuilt, the marker is present in 2 dist files, and 23/23 and 14/14 pass. - **B (#20480).** The `time` arm's non-string verdict was replaced with `return false`: the marker was present in dist before, and absent after. Core went 3 failed / 23; four objectql files went 4 failed / 82. Restored and rebuilt, the marker is present, and 82/82 pass. **NOT MEASURED:** - MySQL cells: not provisioned in this container. They run in CI's `Temporal Conformance (live PG + MySQL)` job, in `sql-driver-time-live-dialects.test.ts` and `sql-driver-20264-temporal-year-range.test.ts`. - turso remote and MongoDB: no server. - The PostgreSQL cells in `packages/rest` ran locally, but no CI job provisions PostgreSQL for that package. CI's PostgreSQL coverage of this card is the driver-sql half above. ## Acceptance notes (not filed) - A `date` comparand with a real leading day and unreadable trailing text (`"2026-07-15T25:00"`) is read as its day. The write door refuses the same string through `Date.parse`. This predates this PR and is outside both cards' classes. - The `time` year class's wording is chosen by core's `isOutsideTemporalYearRange` on the instant. For a year-0 instant in a non-ISO spelling on a `time` column (refused for its spelling), the message would name the year class instead. The verdict is right; the edge is theoretical. - The in-flight spec lane branch for #20600 (`datetime.ts`, `having-filter.ts`, `matches-filter.ts`) has not landed, and no file here overlaps it. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bae3859 commit 2473e26

15 files changed

Lines changed: 962 additions & 143 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(core,objectql)!: a temporal filter comparand is refused with `INVALID_FILTER` / 400 exactly when the same value is refused as a written value — a day that does not exist (`"2026-02-30"`) is no longer rolled over or compared as text, and a non-ISO `datetime` spelling (`"07/15/2026 10:00"`) is no longer read in the server's zone (#20549); and a `time` comparand whose instant has no four-digit UTC year (`"+010000-01-01T10:00:00Z"`) is refused rather than compared as text (#20480)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a filter comparand VALUE at the engine's temporal-comparand door (where, a per-aggregation filter, having) and, through the same predicate, the analytics raw-SQL decline: no authorable key, spelling or stored shape of metadata moves, `packages/spec` is untouched, and no stored row is read or rewritten. What is refused is a temporal string naming a day that does not exist, a datetime string outside the ISO spellings, and a bare integer string; which instant such a string meant (a host zone, a locale's day order, a year or epoch milliseconds) is not something a ledger entry can decide. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a comparand value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what a `date`, a `datetime` and a `time` field accept as a filter comparand. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above).
13+
14+
The record validator already refused the `date` / `datetime` classes as written values (`VALIDATION_FAILED` / `invalid_date`). The comparand door was wider, so a filter admitted what a write refused and answered the wrong rows. The two predicates moved into `@objectstack/core`'s `isUninterpretableTemporalComparand`, and both doors now ask that one rule. A comparand is refused with `INVALID_FILTER` / 400, naming the field, before any driver read, at `where`, a per-aggregation `filter` and `having`:
15+
16+
- **A day that does not exist**, on a `date` or as the day part of a `datetime`: `"2026-02-30"`, `"2026-02-29"` (2026 is not a leap year), `"2026-04-31"`, `"2026-02-30T10:00:00Z"`. `"2028-02-29"` is a real day and is read.
17+
- **A `datetime` string in any spelling but the ISO 8601 ones the platform writes**, after trimming: `YYYY-MM-DD` (midnight UTC); `YYYY-MM-DDTHH:MM[:SS[.fraction]]` followed by `Z`, a `±HH:MM` or `±HHMM` offset, or nothing (a zone-naive wall clock is UTC, ADR-0074); and `YYYY-MM-DD HH:MM[:SS[.fraction]]` with no zone. Refused now, for example: `"07/15/2026 10:00"`, `"2026/07/15 10:00"`, `"15 July 2026 10:00"`, `"07/08/2026"`, `"Wed, 15 Jul 2026 10:00:00 GMT"`, `"2026-07-15 10:00:00+08:00"` (write it with a `T`), and a bare integer string such as `"2026"` or `"1784109600000"`.
18+
- **An instant on a `time` column in either class above.** A `time` column reads a comparand that is not a bare wall clock as an instant, by the `datetime` rule, and keeps its UTC time of day — so `"07/15/2026 10:00"` was the host zone's time of day, and `"1784109600000"` a string of epoch milliseconds. A wall clock (`"10:00"`, `"10:00:00.5"`), an ISO instant, a `Date` and an epoch-millisecond number are read as before, in a four-digit year (next).
19+
- **An instant on a `time` column whose UTC year has no four-digit spelling**, in every spelling (#20480): `"+010000-01-01T10:00:00Z"`, `"-000001-01-01T10:00:00Z"`, `"9999-12-31T23:00:00-02:00"` (year 10000 in UTC), and the epoch-millisecond number or `Date` of any of them. A `time` column keeps the UTC time of day of an instant only when that instant spells a four-digit year; any other one reached the driver as written and was compared with the stored `HH:MM:SS` text. No time of day is read from an extended year. Year 0 (`"0000-06-15T10:00:00Z"`) spells four digits, and its time of day is read as before.
20+
21+
Epoch milliseconds stay a `datetime` comparand as a JSON number: `{ "$gt": 1784109600000 }` is read exactly as before. As a string, a bare integer was read as epoch milliseconds, so `"2026"` meant two seconds after 1970 and matched every later row; send the number, or an ISO instant.
22+
23+
What a caller sees through `POST /api/v1/data/:object/query`, the process in America/New_York, PostgreSQL 16 at `Asia/Shanghai`:
24+
25+
| `where` | memory | SQLite | PostgreSQL | now, on all three |
26+
|:--|:--|:--|:--|:--|
27+
| `datetime` `$eq "2026-02-30T10:00:00Z"` | 200, the row stored at `2026-03-02T10:00:00.000Z` | the same | the same | 400 `INVALID_FILTER` |
28+
| `datetime` `$eq "07/15/2026 10:00"`, `"2026/07/15 10:00"` | 200, the row at `2026-07-15T14:00:00.000Z`, the server process's zone | the same | the same | 400 `INVALID_FILTER` |
29+
| `date` `$eq "2026-02-30"` | 200 `[]`, compared as text | the same | 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` |
30+
| `datetime` `$gt "2026"` | 200, every row (read as 2026 epoch milliseconds) | the same | the same | 400 `INVALID_FILTER` |
31+
| `time` `$gt "+010000-01-01T10:00:00Z"`, rows `09:00` / `10:30` / `12:00` | 200, 3 of 3 (compared as text) | the same | 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` |
32+
| `time` `$gt` the number of that instant | 200 `[]` | 200, 3 of 3 | 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` |
33+
34+
The refusal names the field and the value, says the filter was not applied, and names the spellings that are read. The rows a non-ISO comparand matched were a property of the deployment host: the same request answered differently on two servers.
35+
36+
**Who is affected.** A caller that filters a `date` or `datetime` field with a string: a REST or SDK client, a saved report or view filter, a dashboard's analytics query (the raw-SQL strategy declines such a comparand, and the engine refuses it), an MCP `query_records` call written by a model. A `{placeholder}` such as `{30_days_ago}`, the empty string, a JS `Date` and an epoch-millisecond number are unchanged.
37+
38+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL:
39+
40+
- a real leap day: `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"`;
41+
- each ISO spelling above, compared as the same instant whatever the host's zone: `"2026-07-15T14:00:00Z"`, `"2026-07-15T22:00:00+08:00"`, `"2026-07-15 14:00"` (UTC, not the host zone);
42+
- a `date` comparand with a leading real `YYYY-MM-DD`, still compared as that day (`"2026-07-15T10:00:00Z"` on a `date` is July 15);
43+
- the same wall clock as a 2026 instant on a `time` column: `$gt "2026-07-15T10:00:00Z"` answers the `10:30` and `12:00` rows, as does its epoch-millisecond number or `Date`;
44+
- the year range 0001..9999, and every written value (the record validator now asks the same rule it copied, and answers exactly as before).

‎packages/core/src/utils/temporal-comparand.test.ts‎

Lines changed: 167 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,29 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
106106
}
107107
});
108108

109-
it('leaves the time rule alone — a wall clock has no year', () => {
109+
// [#20480] A wall clock has no year, but a `time` column reads a number or a
110+
// `Date` as an INSTANT and keeps its UTC time of day — and the rule keeps one
111+
// only when the instant's UTC year has a four-digit spelling. Year 0 does
112+
// (`0000-…`), so its time of day is read; year 10000, year -1 and the Date
113+
// range's extremes do not, and the rule handed them back unchanged — a
114+
// number compared with `HH:MM:SS` text. Those are refused now; this pin
115+
// asserted all of them read before.
116+
it('[#20480] a time column reads the time of day of every instant with a four-digit UTC year, and refuses the rest', () => {
117+
const YEAR_0 = new Set(['[#20264] the first millisecond of year 0', '[#20264] the last millisecond of year 0']);
110118
for (const [name, ms] of [...OUT_OF_RANGE, ...PAST_THE_DATE_RANGE, ...IN_RANGE]) {
111-
expect(isUninterpretableTemporalComparand('time', ms), `number, ${name}`).toBe(false);
112-
expect(isUninterpretableTemporalComparand('time', new Date(ms)), `Date, ${name}`).toBe(false);
119+
const spelled = temporalStorageForm(ms, 'time');
120+
const keeps = typeof spelled === 'string' && /^\d{2}:\d{2}:\d{2}(\.\d{3})?$/.test(spelled);
121+
const expected = !(IN_RANGE.some(([n]) => n === name) || YEAR_0.has(name));
122+
expect(keeps, `the rule keeps a time of day for ${name}`).toBe(!expected);
123+
expect(isUninterpretableTemporalComparand('time', ms), `number, ${name}`).toBe(expected);
124+
if (Number.isFinite(new Date(ms).getTime())) {
125+
expect(isUninterpretableTemporalComparand('time', new Date(ms)), `Date, ${name}`).toBe(expected);
126+
}
113127
}
114128
});
115129

116130
it('does not judge NaN, ±Infinity or an Invalid Date — they name no instant and no year', () => {
117-
for (const kind of ['date', 'datetime'] as const) {
131+
for (const kind of ['date', 'datetime', 'time'] as const) {
118132
for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, new Date(Number.NaN)]) {
119133
expect(isUninterpretableTemporalComparand(kind, value), `${kind} ${String(value)}`).toBe(false);
120134
}
@@ -155,7 +169,8 @@ describe('[#20264] isUninterpretableTemporalComparand — a datetime string outs
155169
['year 0099 — inside the range', '0099-03-04T10:00:00.000Z'],
156170
['a 2026 instant (the control)', '2026-02-01T10:00:00.000Z'],
157171
['a 2026 zone-naive timestamp (the control)', '2026-02-01 10:00'],
158-
['epoch milliseconds for 2026, as a string (the control)', '1769940000000'],
172+
// [#20549] The 2026 epoch-millisecond control is a NUMBER now: as a
173+
// string it is refused with every other bare integer string (below).
159174
];
160175
it('refuses each, on datetime and on date', () => {
161176
for (const [name, value] of REFUSED) {
@@ -169,9 +184,156 @@ describe('[#20264] isUninterpretableTemporalComparand — a datetime string outs
169184
for (const [name, value] of READ) {
170185
expect(isUninterpretableTemporalComparand('datetime', value), name).toBe(false);
171186
}
187+
expect(isUninterpretableTemporalComparand('datetime', 1769940000000), 'epoch milliseconds for 2026, a number').toBe(false);
172188
});
173189
it('a time column judges no year — its rule keeps a time of day', () => {
174190
expect(isUninterpretableTemporalComparand('time', '0000-06-15T10:00:00.000Z')).toBe(false);
175191
expect(isUninterpretableTemporalComparand('time', '10:00')).toBe(false);
176192
});
177193
});
194+
195+
// [#20549] The write door (the record validator) refuses a string whose
196+
// leading `YYYY-MM-DD` names a day that does not exist, and a `datetime`
197+
// string outside the ISO 8601 spellings the platform writes. The comparand
198+
// door was wider: measured under TZ=America/New_York on driver-memory, SQLite
199+
// and PostgreSQL 16, `datetime $eq "2026-02-30T10:00:00Z"` matched the row
200+
// stored at 2026-03-02T10:00Z (rolled over), `"07/15/2026 10:00"` matched
201+
// 2026-07-15T14:00Z (the process zone), and `date $eq "2026-02-30"` answered
202+
// 200 [] on memory and SQLite and 500 on PostgreSQL. The two predicates moved
203+
// here from the validator, which now asks this function, so one rule answers
204+
// both doors.
205+
describe('[#20549] isUninterpretableTemporalComparand — a real calendar day, and an ISO spelling for a datetime', () => {
206+
const IMPOSSIBLE_DAYS = ['2026-02-30', '2026-02-29', '2026-04-31', '2026-13-01', '2026-00-10', '2026-06-00', '2100-02-29'];
207+
208+
it('refuses a leading day that does not exist — on a date, and as the day part of a datetime', () => {
209+
for (const day of IMPOSSIBLE_DAYS) {
210+
expect(isUninterpretableTemporalComparand('date', day), `date ${day}`).toBe(true);
211+
expect(isUninterpretableTemporalComparand('date', `${day}T10:00:00Z`), `date ${day}T10:00:00Z`).toBe(true);
212+
expect(isUninterpretableTemporalComparand('datetime', day), `datetime ${day}`).toBe(true);
213+
expect(isUninterpretableTemporalComparand('datetime', `${day}T10:00:00Z`), `datetime ${day}T10:00:00Z`).toBe(true);
214+
expect(isUninterpretableTemporalComparand('datetime', `${day} 10:00`), `datetime ${day} 10:00`).toBe(true);
215+
}
216+
});
217+
218+
it('reads every real day beside them — the leap days and the month ends, the discriminating half', () => {
219+
for (const day of ['2028-02-29', '2000-02-29', '2026-02-28', '2026-04-30', '2026-12-31', '0004-02-29', '2026-01-01']) {
220+
expect(isUninterpretableTemporalComparand('date', day), `date ${day}`).toBe(false);
221+
expect(isUninterpretableTemporalComparand('datetime', day), `datetime ${day}`).toBe(false);
222+
expect(isUninterpretableTemporalComparand('datetime', `${day}T10:00:00Z`), `datetime ${day}T10:00:00Z`).toBe(false);
223+
}
224+
});
225+
226+
it('refuses a datetime string outside the ISO spellings — each one Date.parse reads', () => {
227+
const NOT_ISO = [
228+
'07/15/2026 10:00', '2026/07/15 10:00', '15 July 2026 10:00', '07/08/2026',
229+
'Wed, 15 Jul 2026 10:00:00 GMT', '2026-07-15 10:00 PM', '2026-07-15t10:00:00z',
230+
'2026-07-15 10:00:00+08:00', '2026-07-15 10:00Z', '+002026-07-15T10:00:00Z', '2026-07', '2026-7-15',
231+
// A bare integer string: epoch milliseconds to the rule, a year to its author.
232+
'2026', '1769940000000', '-1',
233+
];
234+
for (const value of NOT_ISO) {
235+
expect(Number.isFinite(Date.parse(value)) || /^-?\d+$/.test(value), `the control: ${value} is read by someone`).toBe(true);
236+
expect(isUninterpretableTemporalComparand('datetime', value), value).toBe(true);
237+
}
238+
});
239+
240+
it('reads each ISO spelling the write door writes, the same instant in every host zone — the discriminating half', () => {
241+
const ISO = [
242+
'2026-07-15', '2026-07-15T10:00', '2026-07-15T10:00:00', '2026-07-15T10:00:00.123',
243+
'2026-07-15T10:00:00Z', '2026-07-15T10:00:00.000Z', '2026-07-15T10:00:00.123456Z',
244+
'2026-07-15T18:00:00+08:00', '2026-07-15T18:00:00+0800', '2026-07-15T05:00:00-05:00',
245+
'2026-07-15 10:00', '2026-07-15 10:00:00', '2026-07-15 10:00:00.5', ' 2026-07-15T10:00:00Z ',
246+
];
247+
for (const value of ISO) {
248+
expect(isUninterpretableTemporalComparand('datetime', value), value).toBe(false);
249+
}
250+
});
251+
252+
it('still refuses what the grammar admits but no clock reads', () => {
253+
for (const value of ['2026-07-15T25:00:00Z', '2026-07-15T10:60:00Z', '2026-07-15T10:00:00+99:99']) {
254+
expect(isUninterpretableTemporalComparand('datetime', value), value).toBe(true);
255+
}
256+
});
257+
258+
it('leaves a date column\'s own reading alone: a real leading day, whatever follows it', () => {
259+
// The `date` rule collapses a leading day, so an instant on a real day is
260+
// that day (#20481); only the day's existence is new.
261+
for (const value of ['2026-07-15T10:00:00Z', '2026-07-15 10:00', '2026-07-15T10:00:00+08:00']) {
262+
expect(isUninterpretableTemporalComparand('date', value), value).toBe(false);
263+
}
264+
expect(isUninterpretableTemporalComparand('date', '2026/07/15'), 'the #20481 shape, as before').toBe(true);
265+
});
266+
267+
it('an instant on a time column is read by the datetime rule, so the same two classes are refused there', () => {
268+
for (const value of ['07/15/2026 10:00', '2026/07/15 10:00', '2026-02-30T10:00:00Z', '1784109600000', 'Wed, 15 Jul 2026 10:00:00 GMT']) {
269+
expect(isUninterpretableTemporalComparand('time', value), value).toBe(true);
270+
}
271+
// The wall clocks and the ISO instants beside them — the discriminating half.
272+
for (const value of ['10:00', '10:00:00', '10:00:00.5', '2026-07-15T10:00:00Z', '2026-07-15T18:00:00+08:00', '2026-07-15 10:00']) {
273+
expect(isUninterpretableTemporalComparand('time', value), value).toBe(false);
274+
}
275+
expect(isUninterpretableTemporalComparand('time', Date.UTC(2026, 6, 15, 10)), 'epoch milliseconds as a number').toBe(false);
276+
});
277+
278+
it('keeps the comparand-only exemptions and the non-string readings', () => {
279+
for (const kind of ['date', 'datetime'] as const) {
280+
expect(isUninterpretableTemporalComparand(kind, ''), `${kind} empty`).toBe(false);
281+
expect(isUninterpretableTemporalComparand(kind, ' '), `${kind} blank`).toBe(false);
282+
expect(isUninterpretableTemporalComparand(kind, '{today}'), `${kind} placeholder`).toBe(false);
283+
expect(isUninterpretableTemporalComparand(kind, 1769940000000), `${kind} epoch-ms number`).toBe(false);
284+
expect(isUninterpretableTemporalComparand(kind, new Date(Date.UTC(2026, 1, 28))), `${kind} Date`).toBe(false);
285+
}
286+
});
287+
});
288+
289+
// [#20480] The `time` half of the one temporal rule. A `time` comparand that
290+
// is not a bare wall clock is read as an INSTANT by the `datetime` rule and
291+
// keeps its UTC time of day — only when that instant's UTC year has a
292+
// four-digit spelling. `+010000-01-01T10:00:00Z` passed this door (`Date.parse`
293+
// reads it) and came back from the rule unchanged, so the driver compared it
294+
// as text: `$gt` answered 3 of 3 rows on memory and SQLite, and PostgreSQL
295+
// answered 500. The same instant spelled with four digits in its own zone
296+
// (`9999-12-31T23:00:00-02:00`), as a number or as a `Date` answered 0 or 3
297+
// by driver. Every spelling is refused now; no time of day is read from an
298+
// extended year.
299+
describe('[#20480] isUninterpretableTemporalComparand — a time column\'s instant outside the four-digit years', () => {
300+
const Y10000_10 = Date.parse('+010000-01-01T10:00:00Z');
301+
const YNEG1_10 = Date.parse('-000001-01-01T10:00:00Z');
302+
303+
it('refuses the card\'s spelling and every other spelling of an instant the rule keeps no time of day for', () => {
304+
for (const value of [
305+
'+010000-01-01T10:00:00Z', // the card's comparand
306+
'-000001-01-01T10:00:00Z',
307+
'9999-12-31T23:00:00-02:00', // year 10000 in UTC
308+
]) {
309+
expect(isUninterpretableTemporalComparand('time', value), value).toBe(true);
310+
}
311+
for (const value of [Y10000_10, YNEG1_10, new Date(Y10000_10), new Date(YNEG1_10), 8.64e15, -8.64e15, 8.64e15 + 1]) {
312+
expect(isUninterpretableTemporalComparand('time', value), String(value)).toBe(true);
313+
}
314+
});
315+
316+
it('reads the time of day of the same wall clock in a four-digit year — the 2026 control and the year edges', () => {
317+
for (const value of [
318+
'2026-01-01T10:00:00Z', '2026-01-01T10:00:00.000Z', '2026-01-01T18:00:00+08:00',
319+
'9999-12-31T10:00:00Z', '0001-01-01T10:00:00Z', '0000-06-15T10:00:00.000Z',
320+
Date.parse('2026-01-01T10:00:00Z'), new Date(Date.parse('2026-01-01T10:00:00Z')),
321+
Date.parse('0000-06-15T10:00:00Z'), new Date(Date.parse('9999-12-31T23:59:59.999Z')),
322+
]) {
323+
expect(isUninterpretableTemporalComparand('time', value), String(value)).toBe(false);
324+
expect(temporalStorageForm(value, 'time'), `the rule keeps a time of day for ${String(value)}`).toMatch(/^\d{2}:\d{2}:\d{2}(\.\d{3})?$/);
325+
}
326+
for (const value of ['10:00', '10:00:00', '23:59:59.999']) {
327+
expect(isUninterpretableTemporalComparand('time', value), value).toBe(false);
328+
}
329+
});
330+
331+
it('agrees with the rule: an instant is refused on a time column exactly when the rule hands it back unchanged', () => {
332+
for (const value of [
333+
'+010000-01-01T10:00:00Z', '9999-12-31T23:00:00-02:00', '2026-01-01T10:00:00Z', '0000-06-15T10:00:00.000Z',
334+
Y10000_10, YNEG1_10, Date.parse('2026-01-01T10:00:00Z'), 8.64e15, 8.64e15 + 1, -8.64e15,
335+
]) {
336+
expect(isUninterpretableTemporalComparand('time', value), String(value)).toBe(temporalStorageForm(value, 'time') === value);
337+
}
338+
});
339+
});

0 commit comments

Comments
 (0)