Skip to content

Commit 92ea760

Browse files
fix(objectql)!: a temporal string is written on a real calendar day, and a datetime string in an ISO 8601 spelling, or refused with VALIDATION_FAILED / invalid_date (#20525) (#20547)
Fixes #20525 Clause-②: no (narrowing) The record validator's `date` / `datetime` arm now stores a real calendar day and an ISO instant, or it refuses. A string whose leading `YYYY-MM-DD` names a day that does not exist is refused, for a `date` and for the day part of a `datetime`. A `datetime` string outside the ISO 8601 spellings below is refused. Both answer `VALIDATION_FAILED` / 400 with the field code `invalid_date`, naming the field, before any driver write. Nothing is rolled over, and nothing is read in the server's zone. This executes triage's ruling on the card (5880930860), Arm 1 and Arm 2 as written. Measured head: `0999284ab`. It is the last change commit `5083d6742` plus a true merge of `origin/main` `288611e3e` (two parents). The merge brought spec, `driver-sql` and `platform-objects` changes. It touched no file under `packages/objectql`, `packages/core`, `packages/rest` or `packages/drivers/driver-memory`: those four trees are byte-identical between `5083d6742` and `0999284ab`. ## The change One source file changes: `packages/objectql/src/validation/record-validator.ts`, +72 / −2. - Two private helpers sit beside `validateOne`. Neither is exported, and `packages/core` is not edited. - `namesRealCalendarDay(s)`: the leading `YYYY-MM-DD` has month 01..12 and a day from 01 to that month's length, with February 29 only in a leap year. It is arithmetic, not a `Date` round trip. - `ISO_DATETIME_WRITE_FORM`: the admitted `datetime` grammar, below. - The arm's verdict line gains one conjunct: `readable && readsAsDay && writtenAsIso && !isOutsideTemporalYearRange(value, t)`. - `writtenAsIso` is `true` for a non-string. - For a string it is `writesAsIsoTemporal(value, t)`. Both kinds need a real leading day, and a `datetime` must also match the grammar, after trimming. - A `Date` and a number go through the arm exactly as before. The `time` arm is not in the diff. ### The admitted `datetime` grammar (H3) After trimming, a `datetime` string is one of: - `YYYY-MM-DD`, midnight UTC; - `YYYY-MM-DDTHH:MM[:SS[.fraction]]`, then `Z`, a `±HH:MM` or `±HHMM` offset, or nothing. With nothing, the wall clock is read as UTC (ADR-0074). - `YYYY-MM-DD HH:MM[:SS[.fraction]]`, zone-naive only, and read as UTC the same way. On top of that, the leading day is real, `Date.parse` reads the string (so `T25:00` and a `+99:99` offset stay refused), and the year is 0001..9999. Each admitted spelling was read through core's `temporalStorageForm` under four host zones: `UTC`, `America/New_York`, `Asia/Shanghai` and `Pacific/Kiritimati`. All 34 probes gave the same instant in every zone. That includes years 0050, 0100 and 0999, fractions of any length, `+0800` offsets and `T24:00` (the end of the named day, stored as the next day's `00:00Z`). Refused by the grammar, with the reason for each borderline case: - **A space separator with a zone** (`"2026-07-15 10:00Z"`, `"2026-07-15 10:00:00+08:00"`). The storage rule hands such a string to `Date.parse` whole, and its non-ISO reading moved `"0050-01-01 10:00+01:00"` to `1950-01-01T09:00:00.000Z` in all four zones. The `T` spelling of the same value is read correctly. - **Lower-case `t` / `z`**. The storage rule's zone-naive branch matches an upper-case `T` only. A zone-naive `"…t10:00"` would reach `Date.parse` and be read in the host zone. - **Reduced precision** (`"2026"`, `"2026-07"`) and **an expanded year** (`"+002026-07-15T10:00:00Z"`). These are not spellings the platform writes. `"2026"` was stored as 2026 epoch milliseconds (`1970-01-01T00:00:02.026Z`). The `date` arm already refuses `"+002026-07-15"` since #20481. ## H1: before and after, at the public door `POST /api/v1/data/:object`, then a read-back through `POST /api/v1/data/:object/query`. The process ran in `TZ=America/New_York` (offset 240 on 2026-07-15). PostgreSQL 16 was a private local server at `Asia/Shanghai`, `DateStyle` `ISO, MDY`. The base is `b2b6a0643`. The head column was measured on the fix at `5083d6742`, and the source trees it reads are the ones at `0999284ab`. H1 holds as the dispatch wrote it, on every cell. **Arm 1, an impossible day:** | written | memory, base | SQLite, base | PostgreSQL, base | head, all three | |:--|:--|:--|:--|:--| | `date` `"2026-02-30"`, `"2026-02-29"`, `"2026-04-31"`, `"2026-02-30T10:00:00Z"` | 201, read back as written (`"2026-02-30"`, …) | the same | 500 `DATABASE_ERROR` | 400 `invalid_date` | | `datetime` `"2026-02-30T10:00:00Z"`, `"2026-02-30 10:00"` | 201, `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 `invalid_date` | | `datetime` `"2026-02-30"` | 201, `"2026-03-02T00:00:00.000Z"` | the same | the same | 400 `invalid_date` | | `datetime` `"2026-02-29T10:00:00Z"` | 201, `"2026-03-01T10:00:00.000Z"` | the same | the same | 400 `invalid_date` | | `date` `"2028-02-29"` (the leap-day control) | 201, `"2028-02-29"` | the same | the same | unchanged | | `datetime` `"2028-02-29T10:00:00Z"` (the leap-day control) | 201, `"2028-02-29T10:00:00.000Z"` | the same | the same | unchanged | **Arm 2, a non-ISO `datetime`:** | written to a `datetime` | memory, base | SQLite, base | PostgreSQL, base | head, all three | |:--|:--|:--|:--|:--| | `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"`, the process zone | the same | the same | 400 `invalid_date` | | `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"`, month-first in the process zone | the same | the same | 400 `invalid_date` | | `"2026-07-15 10:00 PM"` | 201, `"2026-07-16T02:00:00.000Z"` | the same | the same | 400 `invalid_date` | | `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 `invalid_date` | | `"2026-07"` | 201, `"2026-07-01T00:00:00.000Z"` | the same | the same | 400 `invalid_date` | | `"Wed, 15 Jul 2026 10:00:00 GMT"`, `"2026-07-15t10:00:00z"`, `"+002026-07-15T10:00:00Z"`, `"2026-07-15 10:00Z"` | 201, `"2026-07-15T10:00:00.000Z"` | the same | the same | 400 `invalid_date` | | `"2026-07-15 10:00:00+08:00"` | 201, `"2026-07-15T02:00:00.000Z"` | the same | the same | 400 `invalid_date` | **Controls:** | written | memory, base | SQLite, base | PostgreSQL, base | head, all three | |:--|:--|:--|:--|:--| | `datetime` `"2026-07-15 10:00"`, `"2026-07-15 10:00:00"`, `"2026-07-15T10:00"`, `"2026-07-15T10:00:00Z"` | 201, `"2026-07-15T10:00:00.000Z"`, UTC and not the process zone | the same | the same | unchanged | | `datetime` `"2026-07-15T10:00:00+08:00"`, `"2026-07-15T10:00:00+0800"` | 201, `"2026-07-15T02:00:00.000Z"` | the same | the same | unchanged | | `datetime` `"2026-07-15T10:00:00.123Z"`, `"2026-07-15T24:00:00Z"`, `"2026-07-15"`, `"0050-01-01"`, `"0050-01-01T10:00:00Z"` | 201, the same instant | the same | the same | unchanged | | `date` `"2026-07-15"`, `"0050-01-01"` | 201, as written | the same | the same | unchanged | | an epoch-millisecond number, and the string `"1784109600000"` | 400 `invalid_date` | 400 | 400 | unchanged | | a `Date` (engine, since JSON carries none) | accepted | accepted | accepted | unchanged | ## H2: one calendar-day predicate, private to objectql No reusable core helper exists. - `packages/core/src/utils/filter-tokens.ts` has a private `daysInMonth`. - `packages/spec/src/data/calendar-day.ts`'s exported `nextUtcCalendarDay` is the one public real-day check. It is a `Date.UTC` round trip, and `Date.UTC` reads years 0..99 as 1900..1999. So it answers `null` for `"0050-01-01"`, a supported year since #20264, and reusing it would have refused a date the door accepts today. The same holds for `utcInstantMs`. That is reported below as a finding. The predicate is therefore a private arithmetic helper in the validator. No core export is added, so the claim's `Clause-②: no (narrowing)` holds, and only `@objectstack/objectql` carries a changeset. It can be lifted into core as a move if the comparand door later takes the same answer (H5). ## H4: producer census Read at objectstack `0999284ab` / `b2b6a0643` and objectui `origin/main` `0eb9f36`. - **Seeds and examples.** Seed loaders write through `engine.insert` / `engine.update`, so they are validated. - A literal census over the 256 tracked files under `examples/**` found 41 ISO temporal literals in 5 files (the positive control). It found 0 `Date.parse`-readable non-ISO literals and 0 impossible-day literals. - A grep for `toLocale*` / `toUTCString` / `toDateString` / `String(new Date(…))` feeding a record over `examples/**` and non-test `packages/**` found no temporal field producer. - **Flows and formulas.** CEL `now()` returns a `Date`, which is admitted as before. No string formatter of a date feeds a record in `packages/formula` or `service-automation`. - **objectui writers.** All are ISO. - `DateTimeField` sends `fromDateTimeInputValue`, which is `toISOString()`. An impossible day is returned untouched there "because the engine would re-emit it as the rolled day" (objectui `native-date-value.ts`). The engine now refuses it, which is the answer that comment wanted. - The calendar and gantt `datetime` writers use `toISOString()`. - The action and bulk param widgets map `datetime-local` to that same `DateTimeField`. - **AI / MCP.** `create_record` / `update_record` pass values through. A model-written non-ISO `datetime` now gets the 400 back as the tool error. - **`/import` (`packages/rest/src/import-coerce.ts` `parseDateCell`).** Measured at `POST /api/v1/data/:object/import`, `TZ=America/New_York`, no business timezone, on memory, SQLite and PostgreSQL. - A `date` cell `2026-02-30`: at the base, memory and SQLite stored `"2026-02-30"` and PostgreSQL failed the row with `22008`. At head it is a per-row `invalid_date` on all three. That is the intended loud answer (H4), not a producer to fix. - A `datetime` cell is turned into ISO text by the importer itself, before this door, so this card does not change it. Three readings there are not ISO-faithful: - `2026-02-30`, `2026-02-30 10:00` and `2026-02-30T10:00:00Z` are stored as March 2 (`Date.UTC` / `Date.parse` roll the day over); - `07/15/2026 10:00` is stored as `2026-07-15T14:00:00.000Z` (the process zone); - `07/08/2026` is stored as `2026-07-08T04:00:00.000Z` (month-first, the process zone). - `import-coerce.ts` is outside this claim's surface, so these are reported, not edited. The zone and locale half is an import-format question, raised for a decision. - **driver-sql's `canonicalUtcDatetime`** (measured only) is today a one-line delegate to core's `temporalStorageForm`. It has no private reading left to diverge. No census write path reaches a driver around `validateRecord`. ## H5: the comparand door keeps its own answer, and diverges Measured through `POST /api/v1/data/:object/query` at head, `TZ=America/New_York`. The comparand door (`temporal-comparand-door.ts`, core's `isUninterpretableTemporalComparand`) is not edited. | `where` | memory | SQLite | PostgreSQL | |:--|:--|:--|:--| | `placed_on $eq "2026-02-30"` (a `date`) | admitted, 200, `[]` (compared as text) | the same | 500 `DATABASE_ERROR` | | `opened_at $eq "2026-02-30T10:00:00Z"` | admitted and **rolled over**: matches the row stored at `2026-03-02T10:00:00.000Z` | the same | the same | | `opened_at $eq "2026/07/15 10:00"`, `"07/15/2026 10:00"` | admitted, read in the **process zone**: matches the row at `2026-07-15T14:00:00.000Z` | the same | the same | So an impossible day is admitted as a comparand on both kinds: a `date` is compared as text or is a 500 on PostgreSQL, and a `datetime` is rolled over. A non-ISO `datetime` comparand is read in the host zone. The write door is now strictly narrower. #20481's "a `date` string refused as a comparand is refused as a written value too" still holds, and its converse no longer does for an impossible day. This is reported as an out-of-scope finding. Sharing the predicate would change the comparand door's answer for every `where`, `filter` and `having`, so it is not the costless same-predicate change the dispatch allows. ## Tests - `packages/objectql/src/engine-temporal-write-real-day-iso.test.ts` (new, 3 tests, recording driver). - It covers 9 impossible-day values across both kinds, 12 non-ISO `datetime` spellings and 4 already-refused values, including an epoch number. - Each is tried on insert, update, a multi-row update and `engine.validate`. Each asserts `code` `VALIDATION_FAILED` and `fields` exactly `[field, invalid_date]`, with zero driver writes. - The positive control has 21 values: the leap days, each grammar form, a leading blank, year 0050 and a `Date`. Each reaches the driver unchanged on every door. - `packages/rest/src/data-temporal-write-real-day-iso.test.ts` (new, 3 tests per cell). - It sends `POST` and `PATCH` over a real `SqlDriver`, with the process switched to `America/New_York` and the switch asserted. SQLite always runs. Live PostgreSQL runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. - The card's `2026-02-30` for both kinds and its four non-ISO spellings each assert 400, `VALIDATION_FAILED` and the field code, with no write. The existing row keeps its values. - There is an epoch-number control. The leap days and the ISO spellings read back unchanged in UTC, `"2026-07-15 10:00"` as `…T10:00:00.000Z` and not `…T14:00Z`. - `packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts` (new, 2 tests). Under `America/New_York`, each admitted `datetime` spelling and a `Date` is stored as its UTC instant and found by it. The leap day is stored as itself. **Reverse verification.** The fix was committed first. - **objectql (src):** `scripts/ablation-replace.mjs` removed `writtenAsIso` from the verdict line. - The mutation landed: anchor 1 → 0, blob `e51b0aff89c1` → `fe92054de0c4`. - The new engine file went 2 failed / 1 passed. The positive control stays green, as it should. - The tool restored the file: blob equals HEAD, and `git diff HEAD` is empty. - **REST (reads `@objectstack/objectql` from `dist`).** - The first attempt was a no-run. Deleting the conjunct left `writtenAsIso` unused, the DTS build failed with TS6133, and no test ran. - Re-anchored: `writesAsIsoTemporal(value, t) || value.length >= 0` (blob `e51b0aff89c1` → `0782d15e00fb`), then an objectql rebuild. `ablation-dist-preflight` found the marker in 4 built files. The new REST file went 2 failed / 4 passed: the refusal test was red on SQLite and on live PostgreSQL, and both controls were green. - Restore leg: the source was back to HEAD, and objectql was rebuilt. The preflight found the ablation marker absent from all 14 built files, the tree clean, and the fix marker present in 4. The REST file then passed 6/6. Suites: - **objectql, at `5083d6742`:** `--project local` 334 files / 6643 passed. `test:repo` 1 / 5. `typecheck` exit 0. `check:test-typecheck` compiles the new file (`--listFiles` lists it), with the debt unchanged at 40 files / 234 errors / 65 signatures. - **rest, at `5083d6742`:** `--project local` 224 files, 4241 passed / 46 skipped. `test:repo` 1 / 8. `typecheck` exit 0, and test-typecheck debt is 0. - **driver-memory, at `5083d6742`:** 61 files / 1423 passed. `typecheck` exit 0, and its `tsconfig.json` program lists the new file. - **The three new files and #20481's three, at `0999284ab`, after the merge and a closure rebuild:** - objectql: 2 files, 7 / 7; - rest: 2 files, 12 / 12, SQLite and live PostgreSQL 16, 0 skipped; - driver-memory: 2 files, 4 / 4. ## Gates at `0999284ab` - **`dispatch-gates --commands --repo objectstack-ai/objectstack`:** 65 commands. Each was run with its exit code captured before any pipe, and 63 exited 0. `check-adr-0087-registration` reads the changeset as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`, exit 0. - **NOT MEASURED: `check:dual-build-cjs-loads`, `check:type-check-debt`.** Both exit 3 `PREREQUISITE NOT MET`, because they need the whole tree built. Only the rest and driver-memory closures are built here. Scoped reading: both `require` entries of `@objectstack/objectql` load from the rebuilt `dist`, `.` with 178 exports and `./core` with 52. - **`--ran` reconciliation:** 65 derived, 63 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN. - **Narrowed lint:** `eslint --no-inline-config --format json` over the 4 changed TS files gave 4 files, 0 errors and 0 warnings. `--print-config` shows no `parserOptions.project` or `projectService` on any of them. Type-aware linting is off, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **REST on memory** is not a cell of the committed REST file, as on #20481. `@objectstack/driver-memory` has no binding in `packages/rest`, and a new binding is a census disposition. Memory is covered three ways: - the engine pin (the refusal reaches no driver); - the driver-memory pin; - the H1 table, measured through `RestServer` over `InMemoryDriver` with a scratch script that was not committed. - **The refused ISO-adjacent spellings are a deliberate reading of "an ISO 8601 string is admitted":** space plus zone, lower case, reduced precision and the expanded year. The measured reason for each is in the grammar section. The changeset names them. - **The `invalid_datetime` message** ("must be a valid datetime (ISO-8601)") now states the rule exactly. The `date` message is unchanged. - **Not edited, by the claim:** the `time` arm (#20480), `packages/spec`, `import-coerce.ts` (#20534 holds that file), driver-sql, and the comparand door. `driver-mongodb` keeps its own storage copy and is unmeasured here. The refusal sits in the engine in front of it. - **Out of scope, measured and reported to the seat with public-door readings:** - the comparand door (H5); - `/import`'s `datetime` rollover and process-zone reading (H4); - spec's `nextUtcCalendarDay` / `utcInstantMs` refusing years 0001..0099. A `datetime` `where … $lte "0050-01-01"` returns `[]` on all three backends, while the control `$lte "2026-07-15"` includes that day's 14:00Z row. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8538edf commit 92ea760

5 files changed

Lines changed: 593 additions & 2 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a `date` / `datetime` string is written on a calendar day that exists, and a `datetime` string in an ISO 8601 spelling, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026-02-30T10:00:00Z"` is no longer stored as March 2, and `"07/15/2026 10:00"` is no longer read in the server's zone (#20525)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's date / datetime arm: no authorable key, spelling or stored shape of metadata moves, `packages/spec` is untouched, and a stored row keeps whatever it holds. What is refused is a temporal string naming a day that does not exist, or a datetime string outside the ISO spellings, and which instant such a string meant (a host zone, a locale's day order) is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what a `date` and a `datetime` field accept as a written value. 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).
12+
13+
Two kinds of string are now refused with `VALIDATION_FAILED` / 400, the field code `invalid_date` and its existing message ("must be a valid date (ISO-8601)" / "must be a valid datetime (ISO-8601)"), naming the field, before anything is written:
14+
15+
- **A day that does not exist**, as 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 accepted.
16+
- **A `datetime` string in any spelling but these ISO 8601 ones**, 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 (UTC the same way). Refused now, for example: `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"`, `"07/08/2026"`, `"2026-07-15 10:00 PM"`, `"Wed, 15 Jul 2026 10:00:00 GMT"`, `"2026"`, `"2026-07"`, `"2026-07-15t10:00:00z"` (lower case), `"+002026-07-15T10:00:00Z"`, and a space-separated time carrying a zone, `"2026-07-15 10:00:00+08:00"` (write it with a `T`).
17+
18+
The fix is to send the value in one of those spellings — `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` or `"2026-07-15 10:00"` — or a JS `Date`. No other spelling is read for you, on purpose: `07/08/2026` is July 8 in one locale and August 7 in another, and a wall clock with no zone was read in whatever zone the server process ran in.
19+
20+
What a caller sees, before and after, through `POST /api/v1/data/:object` and a read-back, the process in America/New_York, PostgreSQL 16 at `Asia/Shanghai`:
21+
22+
| written | memory | SQLite | PostgreSQL | now, on all three |
23+
|:--|:--|:--|:--|:--|
24+
| `date` `"2026-02-30"` | 201, read back `"2026-02-30"`, a day that does not exist | the same | 500 `DATABASE_ERROR` | 400 `invalid_date` |
25+
| `datetime` `"2026-02-30T10:00:00Z"` | 201, read back `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 `invalid_date` |
26+
| `datetime` `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"`, the server process's zone | the same | the same | 400 `invalid_date` |
27+
| `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"`, month-first in the process zone | the same | the same | 400 `invalid_date` |
28+
| `datetime` `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 `invalid_date` |
29+
30+
The stored instant of a non-ISO `datetime` was a property of the deployment host: the same request landed hours apart on two servers.
31+
32+
What changes: the record validator's `date` / `datetime` arm asks two more questions of a string, on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write. Does its leading `YYYY-MM-DD` name a day that exists (month 01..12, day up to that month's length, February 29 only in a leap year)? And, for a `datetime`, is it one of the ISO spellings above? A `Date` names a real instant and keeps its answer.
33+
34+
**Who is affected.** A caller that writes a `date` or `datetime` field as a string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row that already holds such a value keeps it, since nothing re-reads stored rows. An update that omits the field is not affected; one that sends the old string back is refused, so re-write it in an ISO spelling. The server import (`POST /api/v1/data/:object/import`) turns a `datetime` cell into ISO text itself before the write, so its `datetime` cells reach this check already converted; a `date` cell naming a day that does not exist (`2026-02-30`) is now a per-row `invalid_date`, where memory and SQLite stored it and PostgreSQL failed the row.
35+
36+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST:
37+
38+
- a real leap day: `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"`;
39+
- each ISO spelling above, stored as the same instant: `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` (`"2026-07-15T02:00:00.000Z"`), `"2026-07-15 10:00"` and `"2026-07-15T10:00"` (`"2026-07-15T10:00:00.000Z"`, UTC, not the host zone), `"2026-07-15"` (`"2026-07-15T00:00:00.000Z"`);
40+
- a `date` string with a leading real `YYYY-MM-DD`, still stored as that day;
41+
- a `Date`, still accepted; an epoch-millisecond number, still refused with `invalid_date`;
42+
- the year range 0001..9999;
43+
- every `time` value, and every filter comparand (`where`, a per-aggregation `filter`, `having`).
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20525] What the temporal write door admits, this driver stores as written:
5+
* a leap day as that day, and each ISO `datetime` spelling as the instant it
6+
* names — the same instant whatever the server process's zone.
7+
*
8+
* The engine's record validator now refuses a string whose leading
9+
* `YYYY-MM-DD` names a day that does not exist, and a `datetime` string outside
10+
* the ISO spellings the storage rule reads the same on every host, with
11+
* `VALIDATION_FAILED` / `invalid_date` before any driver write
12+
* (`packages/objectql/src/engine-temporal-write-real-day-iso.test.ts`). The
13+
* refusal is therefore not this driver's; what it owes is the other half: every
14+
* spelling the door admits is STORED as the value it names and found by it,
15+
* with the process in a zone that is not UTC so a host-zone reading would show.
16+
*
17+
* Measured on the base through REST over this driver, the process in
18+
* America/New_York: `datetime` `"2026-02-30T10:00:00Z"` was stored as
19+
* `"2026-03-02T10:00:00.000Z"`, `"2026/07/15 10:00"` as
20+
* `"2026-07-15T14:00:00.000Z"`, and `date` `"2026-02-30"` verbatim. The engine
21+
* refuses those now; the REST door over SQL is
22+
* `packages/rest/src/data-temporal-write-real-day-iso.test.ts`.
23+
*/
24+
25+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
26+
import { InMemoryDriver } from './memory-driver.js';
27+
28+
const OBJECT = 'ledger_temporal_20525';
29+
const FIELDS = { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } };
30+
const HOST_ZONE = 'America/New_York';
31+
32+
/** Each `datetime` spelling the write door admits → the instant it names, in UTC. */
33+
const ADMITTED_DATETIME: ReadonlyArray<readonly [string, unknown, string]> = [
34+
['leap', '2028-02-29T10:00:00Z', '2028-02-29T10:00:00.000Z'],
35+
['utc', '2026-07-15T10:00:00Z', '2026-07-15T10:00:00.000Z'],
36+
['offset', '2026-07-15T18:00:00+08:00', '2026-07-15T10:00:00.000Z'],
37+
['naive-t', '2026-07-15T10:00', '2026-07-15T10:00:00.000Z'],
38+
['naive-space', '2026-07-15 10:00:00', '2026-07-15T10:00:00.000Z'],
39+
['a-date', new Date(Date.UTC(2026, 6, 15, 10)), '2026-07-15T10:00:00.000Z'],
40+
];
41+
42+
const originalTz = process.env.TZ;
43+
44+
describe('[#20525] every temporal spelling the write door admits is stored as the value it names', () => {
45+
let driver: InMemoryDriver;
46+
47+
beforeAll(async () => {
48+
process.env.TZ = HOST_ZONE;
49+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE);
50+
driver = new InMemoryDriver({});
51+
await driver.connect();
52+
await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS });
53+
for (const [id, opened_at] of ADMITTED_DATETIME) await driver.create(OBJECT, { id, opened_at });
54+
await driver.create(OBJECT, { id: 'leap-day', placed_on: '2028-02-29' });
55+
await driver.create(OBJECT, { id: 'before', placed_on: '2028-02-28', opened_at: '2026-07-15T09:59:59Z' });
56+
});
57+
58+
afterAll(() => {
59+
if (originalTz === undefined) delete process.env.TZ;
60+
else process.env.TZ = originalTz;
61+
});
62+
63+
it('reads each datetime back as its UTC instant, and the leap day as itself', async () => {
64+
for (const [id, , instant] of ADMITTED_DATETIME) {
65+
expect((await driver.findOne(OBJECT, { where: { id } }))?.opened_at, id).toBe(instant);
66+
}
67+
expect((await driver.findOne(OBJECT, { where: { id: 'leap-day' } }))?.placed_on).toBe('2028-02-29');
68+
});
69+
70+
it('finds each one by the instant it names, and orders it after the second before', async () => {
71+
const ids = async (where: Record<string, unknown>) =>
72+
(await driver.find(OBJECT, { where })).map((r) => r.id as string).sort();
73+
const july = ADMITTED_DATETIME.filter(([id]) => id !== 'leap').map(([id]) => id).sort();
74+
expect(await ids({ opened_at: { $eq: '2026-07-15T10:00:00Z' } })).toEqual(july);
75+
expect(await ids({ opened_at: { $gt: '2026-07-15T09:59:59Z', $lt: '2027-01-01' } })).toEqual(july);
76+
expect(await ids({ placed_on: { $gt: '2028-02-28' } })).toEqual(['leap-day']);
77+
});
78+
});
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20525] A temporal string is written on a calendar day that exists, and a
5+
* `datetime` string in an ISO 8601 spelling — or it is refused with
6+
* `VALIDATION_FAILED` and the field's `invalid_date` code, on insert, update, a
7+
* multi-row update and the dry-run `validate`, before any driver write. Never
8+
* rolled over, never re-read in the server's zone.
9+
*
10+
* Measured on the base (`b2b6a0643`) through `POST /api/v1/data/:object` and a
11+
* read-back, the process in America/New_York, PostgreSQL 16 at Asia/Shanghai:
12+
*
13+
* | written | memory | SQLite | PostgreSQL | now |
14+
* |:--|:--|:--|:--|:--|
15+
* | `date` `"2026-02-30"` | 201, `"2026-02-30"` | 201, `"2026-02-30"` | 500 `DATABASE_ERROR` | 400 |
16+
* | `datetime` `"2026-02-30T10:00:00Z"` | 201, `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 |
17+
* | `datetime` `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"` (the process zone) | the same | the same | 400 |
18+
* | `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"` (month-first, the process zone) | the same | the same | 400 |
19+
* | `datetime` `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 |
20+
* | `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"` (a leap day) | 201, as written | the same | the same | unchanged |
21+
* | `datetime` `"2026-07-15 10:00"` (zone-naive ISO) | 201, `"2026-07-15T10:00:00.000Z"` (UTC, ADR-0074) | the same | the same | unchanged |
22+
*
23+
* The REST door over real drivers is
24+
* `packages/rest/src/data-temporal-write-real-day-iso.test.ts`; the memory
25+
* driver's half is `memory-20525-temporal-write-real-day-iso.test.ts`. This
26+
* file's driver records writes and stores nothing, because the refusal sits in
27+
* front of every driver — so these verdicts hold whatever the process zone.
28+
*/
29+
30+
import { describe, it, expect, beforeEach } from 'vitest';
31+
import { ObjectQL } from './engine.js';
32+
33+
const ledger = {
34+
name: 'ledger',
35+
label: 'Ledger',
36+
fields: {
37+
id: { name: 'id', type: 'text' as const, primaryKey: true },
38+
customer_id: { name: 'customer_id', type: 'text' as const },
39+
placed_on: { name: 'placed_on', type: 'date' as const },
40+
opened_at: { name: 'opened_at', type: 'datetime' as const },
41+
},
42+
};
43+
44+
type Field = 'placed_on' | 'opened_at';
45+
46+
/** Arm 1 — a leading day that does not exist. Each was `Date.parse`-readable, and stored as written or rolled over at the base. */
47+
const IMPOSSIBLE_DAY: ReadonlyArray<readonly [Field, string]> = [
48+
['placed_on', '2026-02-30'],
49+
['placed_on', '2026-02-29'],
50+
['placed_on', '2026-04-31'],
51+
['placed_on', '2026-02-30T10:00:00Z'],
52+
['opened_at', '2026-02-30T10:00:00Z'],
53+
['opened_at', '2026-02-30'],
54+
['opened_at', '2026-02-30 10:00'],
55+
['opened_at', '2026-02-29T10:00:00Z'],
56+
['opened_at', '2026-04-31T10:00:00+08:00'],
57+
];
58+
59+
/** Arm 2 — a `datetime` string in no ISO spelling the storage rule reads the same on every host. Each was a 201 at the base. */
60+
const NON_ISO_DATETIME: readonly string[] = [
61+
'2026/07/15 10:00',
62+
'07/15/2026 10:00',
63+
'15 July 2026 10:00',
64+
'07/08/2026',
65+
'2026-07-15 10:00 PM',
66+
'Wed, 15 Jul 2026 10:00:00 GMT',
67+
'2026',
68+
'2026-07',
69+
'2026-07-15t10:00:00z',
70+
'+002026-07-15T10:00:00Z',
71+
'2026-07-15 10:00Z',
72+
'2026-07-15 10:00:00+08:00',
73+
];
74+
75+
/** Refused at the base already — kept refused. */
76+
const STILL_REFUSED: ReadonlyArray<readonly [Field, unknown]> = [
77+
['opened_at', 'not-a-date'],
78+
['opened_at', '2026-07-15T25:00:00Z'],
79+
['opened_at', Date.UTC(2026, 6, 15, 10)],
80+
['placed_on', '2026/07/15'],
81+
];
82+
83+
/** The leap-day control and the ISO controls — each reaches the driver. */
84+
const ACCEPTED: ReadonlyArray<readonly [Field, unknown]> = [
85+
['placed_on', '2028-02-29'],
86+
['placed_on', '2026-02-28'],
87+
['placed_on', '2026-07-15'],
88+
['placed_on', '2026-07-15T10:00:00Z'],
89+
['placed_on', '2026-07-15 10:00'],
90+
['placed_on', ' 2026-07-15'],
91+
['placed_on', '0050-01-01'],
92+
['placed_on', new Date(Date.UTC(2026, 6, 15, 10))],
93+
['opened_at', '2028-02-29T10:00:00Z'],
94+
['opened_at', '2026-07-15'],
95+
['opened_at', '2026-07-15T10:00'],
96+
['opened_at', '2026-07-15T10:00:00'],
97+
['opened_at', '2026-07-15T10:00:00Z'],
98+
['opened_at', '2026-07-15T10:00:00.123Z'],
99+
['opened_at', '2026-07-15T10:00:00+08:00'],
100+
['opened_at', '2026-07-15T10:00:00-0530'],
101+
['opened_at', '2026-07-15 10:00'],
102+
['opened_at', '2026-07-15 10:00:00.5'],
103+
['opened_at', ' 2026-07-15 10:00'],
104+
['opened_at', '0050-01-01T10:00:00Z'],
105+
['opened_at', new Date(Date.UTC(2026, 6, 15, 10))],
106+
];
107+
108+
const REFUSED: ReadonlyArray<readonly [Field, unknown]> = [
109+
...IMPOSSIBLE_DAY,
110+
...NON_ISO_DATETIME.map((v) => ['opened_at', v] as const),
111+
...STILL_REFUSED,
112+
];
113+
114+
/** A driver that records every read and write, and answers none. */
115+
function makeRecordingDriver() {
116+
const writes: Record<string, unknown>[] = [];
117+
const driver: any = {
118+
name: 'recording', version: '0.0.0', supports: {},
119+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
120+
async find() { return []; },
121+
async findOne() { return { id: 'r1' }; },
122+
async count() { return 0; },
123+
async aggregate() { return []; },
124+
async create(_o: string, data: Record<string, unknown>) { writes.push(data); return { ...data }; },
125+
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
126+
async updateMany(_o: string, _ast: unknown, data: Record<string, unknown>) { writes.push(data); return 0; },
127+
async delete() { return true; },
128+
async deleteMany() { return 0; },
129+
async bulkCreate(_o: string, batch: Record<string, unknown>[]) { writes.push(...batch); return batch; },
130+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
131+
async commit() {}, async rollback() {},
132+
};
133+
return { driver, writes };
134+
}
135+
136+
const refusalOf = async (p: Promise<unknown>) =>
137+
p.then(() => null, (e: any) => e as Error & { code?: string; status?: number; fields?: Array<{ field: string; code: string }> });
138+
139+
const labelOf = (field: Field, value: unknown) =>
140+
`${field} ${value instanceof Date ? `Date ${value.toISOString()}` : JSON.stringify(value)}`;
141+
142+
describe('[#20525] the write door — a real calendar day, and an ISO spelling for a datetime, or VALIDATION_FAILED before any write', () => {
143+
let engine: ObjectQL;
144+
let writes: Record<string, unknown>[];
145+
146+
beforeEach(async () => {
147+
const rec = makeRecordingDriver();
148+
writes = rec.writes;
149+
engine = new ObjectQL();
150+
engine.registerDriver(rec.driver, true);
151+
await engine.init();
152+
engine.registry.registerObject(ledger, 'test');
153+
});
154+
155+
const doors = (field: Field, value: unknown) => [
156+
['insert', () => engine.insert('ledger', { id: 'n1', customer_id: 'c1', [field]: value })],
157+
['update', () => engine.update('ledger', { id: 'r1', [field]: value })],
158+
['multi-row update', () => engine.update('ledger', { [field]: value }, { where: { customer_id: 'c1' }, multi: true })],
159+
] as const;
160+
161+
it('refuses an impossible day and a non-ISO datetime on insert, update and a multi-row update, naming the field with invalid_date — and writes nothing', async () => {
162+
for (const [field, value] of REFUSED) {
163+
for (const [door, call] of doors(field, value)) {
164+
const err = await refusalOf(call());
165+
expect(err, `${door}, ${labelOf(field, value)}`).not.toBeNull();
166+
expect(err!.code, `${door}, ${labelOf(field, value)}`).toBe('VALIDATION_FAILED');
167+
expect(err!.fields, `${door}, ${labelOf(field, value)}`).toEqual([expect.objectContaining({ field, code: 'invalid_date' })]);
168+
}
169+
}
170+
expect(writes, 'no write — every refusal precedes the driver').toHaveLength(0);
171+
});
172+
173+
it('the dry-run validate predicts each refusal — and each accepted value as valid', async () => {
174+
for (const [field, value] of REFUSED) {
175+
const verdict = await engine.validate('ledger', { [field]: value });
176+
expect(verdict.valid, labelOf(field, value)).toBe(false);
177+
expect(verdict.results[0]!.errors, labelOf(field, value)).toEqual([expect.objectContaining({ field, code: 'invalid_date' })]);
178+
}
179+
for (const [field, value] of ACCEPTED) {
180+
expect((await engine.validate('ledger', { [field]: value })).valid, labelOf(field, value)).toBe(true);
181+
}
182+
});
183+
184+
it('accepts a leap day, the ISO spellings and a Date on every door — the POSITIVE CONTROL', async () => {
185+
for (const [field, value] of ACCEPTED) {
186+
for (const [door, call] of doors(field, value)) {
187+
const before = writes.length;
188+
await expect(call(), `${door}, ${labelOf(field, value)}`).resolves.toBeDefined();
189+
expect(writes.length, `${door}, ${labelOf(field, value)} reached the driver`).toBe(before + 1);
190+
expect(writes.at(-1)![field], `${door}, ${labelOf(field, value)} reached it as written`).toEqual(value);
191+
}
192+
}
193+
});
194+
});

0 commit comments

Comments
 (0)