Skip to content

Commit 63bfe69

Browse files
fix(objectql)!: a time field is a zone-less wall clock — a zone-suffixed time of day and an extended-year instant are refused with VALIDATION_FAILED / invalid_time (#20671) (#20721)
Fixes #20671 Clause-②: no (narrowing) The record validator's `time` arm now judges a written value by `@objectstack/core`'s one temporal rule, `isUninterpretableTemporalComparand('time', value)`, the rule the `time` comparand door asks since PR #20668. That is how #20525 moved the `date` / `datetime` arm. A `time` field is a zone-less wall clock (triage 5895825766): a time of day with a `Z` or an offset is refused with `VALIDATION_FAILED` / 400, field code `invalid_time`, and a sentence that says what to do. An extended-year instant is refused too. Nothing reaches a driver, so it is never a 500. The unanchored `hasDate` test is gone. Base `fa0a4b661` (this branch's merge base). Head `9b426f8ab`. ## Reproduced first, then after `POST /api/v1/data/:object` then a read-back through `POST /api/v1/data/:object/query`. The process ran in `TZ=America/New_York`. PostgreSQL 16.13 was a private server at `Asia/Shanghai`. Memory is `RestServer` over `InMemoryDriver`, from a scratch probe that was not committed. The card's table reproduced on every cell. | written to a `time` | memory, base | SQLite, base | PostgreSQL, base | head, all three | |:--|:--|:--|:--|:--| | `"+010000-01-01T10:00:00Z"` (the card) | 201, read back verbatim | 201, verbatim | 500 `DATABASE_ERROR` | 400 `invalid_time` | | `"9999-12-31T23:00:00-02:00"` (UTC year 10000) | 201, verbatim | 201, verbatim | 500 | 400 `invalid_time` | | `"10:00Z"` (the card) | 201, `"10:00Z"` | 201, `"10:00Z"` | 201, `"10:00:00"` | 400, the zone sentence | | `"10:00+08:00"`, `"10:00:00+0800"` | 201, verbatim | 201, verbatim | 201, `"10:00:00"` | 400, the zone sentence | | `"10:00:00.250Z"` | 201, verbatim | 201, verbatim | 201, `"10:00:00.250"` | 400, the zone sentence | | `"2026-07-15 10:00Z"` (a space and a zone) | 201, `"10:00:00"` | the same | the same | 400 `invalid_time` | | `"10:00"`, `"10:00:00"` (the controls) | 201, `"10:00:00"` | the same | the same | unchanged | | `"10:00:00.250"` | 201, `"10:00:00.250"` | the same | the same | unchanged | | `"2026-07-15T10:00:00Z"`, `"2026-07-15T18:00:00+08:00"`, `"2026-07-15 10:00"` | 201, `"10:00:00"` | the same | the same | unchanged | | `"07/15/2026 10:00"`, `"x2026-07-15T10:00:00Z"`, `"{now}"`, the number `36000000` | 400 `invalid_time` | the same | the same | unchanged | | `" "` (blank) | 201, `null` | the same | the same | unchanged | The zone sentence, in English: "Slot is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant". Every other refusal keeps the existing "must be a valid time (HH:MM or HH:MM:SS)". ## The change - `packages/objectql/src/validation/record-validator.ts`, the `time` arm: - the verdict is `readable && !isUninterpretableTemporalComparand(t, value)`, the `date` / `datetime` arm's line; - `readable` holds the write door to what the comparand door exempts on purpose. A number stays refused as a written `time` (a comparand may be epoch milliseconds), and a `{placeholder}` stays refused (it is filter vocabulary, judged by `classifyFilterToken` from `@objectstack/spec/data`). A blank is missing before the arm, as before; - the private `timeOfDay` / `hasDate` patterns are deleted; - a private `isZonedTimeOfDay` chooses the sentence, never the verdict: a time of day plus `Z` / `z` / an offset whose wall-clock half core's rule reads. So `"25:00Z"` gets the plain sentence. - `packages/spec/src/system/validation-message.ts`: one message key, `invalid_time_zoned`, in `en` / `zh-CN` / `ja-JP` / `es-ES`. It is a rendering variant of the existing wire code `invalid_time`, which does not change. See the scope section for why it is here. - `content/docs/protocol/objectql/types.mdx`: the `time` input sentence said "with an optional fractional part and `Z`/offset". It now says no zone, and that an epoch number is refused. The number was already refused at base: `36000000` answered 400 on all three. ## PM hypotheses, which held - **H1 held.** At `fa0a4b661` core's predicate refuses `"10:00Z"`, `"10:00+08:00"`, `"+010000-01-01T10:00:00Z"` and `"9999-12-31T23:00:00-02:00"`, measured on core's `dist`. The arm asks it. The one addition is the write door's type gate above. The predicate answers `false` for a number, a `{placeholder}` and a blank, which are comparand exemptions, and the old arm refused the first two as written values. - **H2 held.** A full ISO instant with a four-digit year is admitted and stores its UTC time of day (ADR-0053 D-C1: "A `Date` / epoch-ms / full-timestamp value folds to its UTC time-of-day"). It is pinned as a control on the engine, on REST over SQLite and PostgreSQL, and on the memory driver. No `needs_decision` is raised on it. - **H3 held.** The wire code stays `invalid_time`. `fail(code, constraint, messageKey)` goes to `buildFieldError`, then to `renderValidationMessage(messageKey)`, and that reads `BUILTIN_VALIDATION_MESSAGES` in `packages/spec`. So the prescription has to live there. Details are in the scope section. - **H4: not a clean reuse. The seat answered it in-seat as A (5899587971, by ADR-0104 D1): a row already stored with a zone-suffixed `time` keeps its value, with no `value-shapes` report, as PRs #20524 / #20547 did for `date` / `datetime`.** Measured: - (a) `valueShapeViolation` has one caller, the scan (`scan-value-shapes.ts:155`). The write path does not call it. Its sibling `isScannableValueShapeField` IS on the write path: `ObjectQL.objectHasCoveredValueField` decides from it whether an object reads the `adr-0104-value-shapes` flag and passes `valueShapeStrict` to the validator. Adding `time` there changes no `time` verdict, because the arm reads no strictness flag. It does make every object whose only covered field is a `time` read the flag, and it makes the boot line announce a warn mode that does not govern `time`. - (b) ADR-0104 D1 defines what a passed flag means: "no stored value of the covered classes fails `valueSchemaFor(field, 'stored')`", and "the covered classes are exactly the validator's own non-media branch — `REFERENCE_VALUE_TYPES` … and `STRUCTURED_JSON_TYPES`". Covering `time` changes that fact. Every deployment that already holds the flag, including every fresh datastore that attests it at creation, would never re-run the scan, so its rows would not be reported. The findings would also block a gate whose strictness the `time` arm never reads. And the spec's `valueSchemaFor(time)` itself admits `"10:00Z"` (measured `true`), so the scan could not reuse its own predicate for this. - Nothing is rewritten, as triage requires. The options and the four-axis analysis are in the `os-dev-report` on #20671. - **H5 held.** No driver changes. A refused value never reaches a driver: the recording-driver pin shows zero writes, and the REST pin counts zero writes. ## Scope: two `packages/spec` edits, one kept and one reverted The claim's file surface did not name `packages/spec`. Both edits are explained here, as the claim asks for a breach. **Kept: `packages/spec/src/system/validation-message.ts`, the `invalid_time_zoned` key.** The card needs it. Triage rules that a suffix "is refused with a prescription: drop the suffix, or use a `datetime` field for an instant". A refusal's sentence can only come from that catalog. Measured on spec's `dist`: `renderValidationMessage({ messageKey: 'invalid_time_zoned_absent_probe', label: 'Slot' })` renders `"Slot (invalid_time_zoned_absent_probe)"`, the resolution order's step 4, a coding-error fallback. With the key it renders the sentence above, and in zh-CN it renders "时段是不带时区的时刻:…". The spec test "every locale defines every message key" makes all four locales required, and it passes: `en` / `zh-CN` / `ja-JP` / `es-ES` each have 38 keys. The key does not widen a published type or export: - the declared type of `BUILTIN_VALIDATION_MESSAGES` does not change: a record of locale to a record of message key to template; - no export is added: `check:api-surface` answers "@objectstack/spec public API surface + factory signatures unchanged ✓"; - `FieldErrorCode` does not change; - `check-widening-tells --declaration no` judged `validation-message.ts` against its declared surface and found no widening tell. What a deployment gains is one more translation key it may override, `validation.field.invalid_time_zoned`. `@objectstack/spec` publishes `dist` (`files[]`), and the key ships in 4 `dist` files, next to `invalid_datetime` as a positive control. So the changeset lists `@objectstack/spec: patch`. **Reverted: `ClockTimeValueSchema` in `packages/spec/src/data/field-value.zod.ts`.** Commit `691bfabd6` narrowed it to refuse a zone, and `b5d95181d` reverts it with a normal revert commit. The arm stands without it. With the spec schema left wider, at `9b426f8ab`: - spec: 575 files, 16960 tests; - objectql: 336 files, 6679 tests; - rest, with live PostgreSQL: 228 files, 4420 passed / 22 skipped; - driver-memory: 63 files, 1451 tests; - runtime `action-params-enforcement.test.ts`: 5 / 5; - dogfood `field-zoo-value-shape.test.ts`: 45 / 45. All passed. No parity pin or gate reds on the difference. What the wider schema leaves open is reported to the seat as a finding rather than fixed here: - `FieldSchema` accepts `Field.time` with `defaultValue: '10:00Z'`; - with this PR, each `engine.insert` that falls back to that default is refused, 400 `invalid_time`, on a field the caller never sent (measured on `a596fad76`); - the action-param door (`validateActionParams`, strict under ADR-0104 D2) still admits `'10:00Z'` for a `time` param (measured `[]`). The readers of `ClockTimeValueSchema` are all through `valueSchemaFor`: `checkLiteralDefaultValue` (the `FieldSchema.defaultValue` gate and the action-param `defaultValue` gate), `validateActionParams` (runtime `action-execution.ts:1376`), and `import-mapping-target.ts`. The last reads only object-shaped schemas, so `time` never reaches it. The objectql scan's `shapeSchemaFor` never sees `time`. Metadata shipped in this repo authors no zoned `time` value: - 0 zoned time-of-day literals in `examples`, `packages/platform-objects`, `packages/create-objectstack` and `skills`; - positive control: 6 plain wall-clock literals in `examples`; - the population is 4 `time` field declarations in `examples` and 1 in `skills`, and none carries a `defaultValue`. The commit `691bfabd6` stays on this branch as a ready reference for the spec seat, with its pins. ## Tests - `packages/objectql/src/engine-time-write-zone-less.test.ts` (new, 5 tests, recording driver). - 9 zoned, 7 unread-instant and 9 already-refused values, each on insert, update and a multi-row update, and through `engine.validate`. Each asserts `code` `VALIDATION_FAILED`, `fields` exactly `slot` / `invalid_time`, and zero driver writes. - The sentence is asserted by the catalog key the refusal renders: the zone key for the 9, the plain key for the rest. The words themselves are not pinned. - The positive control has 12 values, a `Date` among them, and each reaches the driver as written. - A one-rule corpus pin: a string is refused as a written `time` exactly when core refuses it as a `time` comparand, except `{now}`. The number is asserted as the other write-only refusal. - `packages/objectql/src/validation/record-validator.test.ts`, one pin flipped. `'14:30:00Z'` and `'08:15:00+02:00'` were pinned as accepted; they are now refused with `invalid_time` and the zone sentence. That keeps a load-bearing assertion of the new rule. - `packages/rest/src/data-temporal-write-real-day-iso.test.ts`, a new `it` on the SQLite cell and the live PostgreSQL cell. - The card's values are 400 on create and on PATCH, with no write. - `"10:00"`, `"10:00:00"`, `"10:00:00.250"` and the two full instants read back identically. - `packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts` (new, 2 tests). Under `America/New_York`, each spelling the door admits is stored as its wall clock and found by it. **Reverse verification.** The fix was committed first. `scripts/ablation-replace.mjs` replaced the arm's `readable` line with one that admits every string and `Date`. The anchor went 1 → 0 and the blob `eb565fe32fe5` → `2b0d369b76c9`. objectql was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - objectql, the 2 files: 11 failed / 106 passed. The new file's 4 refusal tests went red and its positive control stayed green. The flipped pins went red too. - REST: 2 failed / 10 passed. The `[#20671]` `it` went red on SQLite and on live PostgreSQL, and every other `it` stayed green. - Restore leg: blob == HEAD and `git diff HEAD` is empty. After a rebuild, the preflight found the marker absent from all 14 built files and the tree clean. objectql went 117 / 117 and REST 12 / 12, both `[#20671]` cells included. ## Verification at `9b426f8ab` - The suite counts in the scope section above. - `typecheck` exit 0 for spec, objectql, rest and driver-memory. - The test-typecheck ledgers held: spec 53 files / 251 errors, objectql 40 / 234, rest 0. - `--listFiles` lists the new objectql test and the REST file. driver-memory's `tsconfig.json` includes `src/**/*`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands. 107 exited 0. - `check:skill-examples` first exited 3 because the client packages had no `dist`. It was re-run, exit 0, after building them. - `--ran` reads "110 derived famil(ies) accounted for — 107 run, 3 NOT-MEASURED". - `check:api-surface` answered "unchanged ✓", and `check:docs` "226 generated files in sync". - `check:nul-bytes` scanned 9333 files and found no raw control bytes. `check:driver-conformance` reads 50 covered cells, 0 DEBT. - `check-adr-0087-registration` reads the changeset as "BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)", exit 0. `check-changeset-no-major` and `check-empty-changeset` exited 0. - Lint, narrowed and declared (the repo-wide `pnpm lint` is CI's). `eslint --no-inline-config --format json` over the 6 changed `.ts` files: 6 files, 0 errors, 0 warnings. - Population: `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `packages/**` objects cover all 6. - Invariance: `--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. **NOT MEASURED:** - `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3, PREREQUISITE NOT MET: no whole-workspace `dist`. The container restarted twice during this run, so a whole-workspace build was not attempted. - Scoped reading: the CJS entries load: `@objectstack/objectql` `.` has 178 exports and `./core` 52, `@objectstack/spec/system` 400 and `@objectstack/spec/data` 528. - The four changed packages typecheck, as above. - `check:query-options-erasure`: it was killed with the container (exit 137) after its self-test passed, and it was not re-run. CI's `Lint & Repo Gates` runs it. - MySQL, turso and MongoDB: not provisioned. The refusal sits in the engine, in front of every driver. ## Acceptance notes (not filed) - **`/import`**: measured after the change on all three backends. - `10:00Z` and `10:00+08:00` time cells are per-row refusals, from the import's own reader, before this door. They were refused there before this PR too: `parseDateCell` runs first and never hands this arm a suffix. - An offset-bearing instant cell `9999-12-31T23:00:00-02:00` is stored as `01:00:00`, its UTC clock, while the write door refuses the same string. The import converts before the door, both answers can be defended, and this PR leaves it as it was. - An Invalid `Date` is still admitted by all three temporal arms, as before. Only an engine caller can send one (JSON cannot carry one), so no public door reaches it. - The changeset's "Who is affected" states the narrowing, including a zone-suffixed literal `defaultValue` on a `time` field. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 671d4c1 commit 63bfe69

8 files changed

Lines changed: 467 additions & 19 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(objectql)!: a `time` field is a zone-less wall clock — a time of day written with a `Z` or an offset (`"10:00Z"`, `"10:00+08:00"`), and an instant whose UTC year has no four-digit spelling (`"+010000-01-01T10:00:00Z"`), are refused with `VALIDATION_FAILED` / 400 (`invalid_time`) instead of being stored verbatim on memory and SQLite and read back differently, or failing with a 500, on PostgreSQL (#20671)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's time arm: no authorable key, spelling or stored shape of metadata moves, and no schema, type or export changes. `packages/spec` gains one sentence in the built-in validation-message catalog (`invalid_time_zoned`, a rendering variant of the existing `invalid_time` wire code, which is unchanged). A stored row keeps whatever it holds, and which wall clock a zone-suffixed time of day meant is not something a ledger entry can decide. The other categories are closed on facts: the packages publish (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`). -->
11+
12+
**BREAKING**: this narrows what a `time` field accepts 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).
13+
14+
The record validator's `time` arm now asks `@objectstack/core`'s one temporal rule, the same one the `time` filter comparand door asks, so a value is refused as a written `time` exactly when it is refused as a `time` comparand. It reads two things: a bare wall clock `HH:MM[:SS[.fraction]]` in range, and an instant in one of the ISO 8601 spellings a `datetime` is written in, on a calendar day that exists, whose UTC year has four digits (its UTC time of day is stored). Everything else is refused with `VALIDATION_FAILED` / 400 and the field code `invalid_time`, naming the field, on insert, update, a multi-row update and `engine.validate`, before anything is written.
15+
16+
Refused now, where they were accepted:
17+
18+
- **A time of day with a zone suffix**: `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"`, `"10:00:00.250Z"`. A `time` field carries no zone. The refusal has its own sentence, which `@objectstack/spec`'s validation-message catalog now carries in all four locales (`invalid_time_zoned`; English: "… is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant"). The wire code stays `invalid_time`.
19+
- **An instant the rule does not read as a time of day**: an extended year (`"+010000-01-01T10:00:00Z"`, or a `Date` of it), an instant whose UTC year is 10000 (`"9999-12-31T23:00:00-02:00"`), a day that does not exist (`"2026-02-30T10:00:00Z"`), and a spelling the `datetime` arm already refuses (`"2026-07-15 10:00Z"`, a space and a zone; `"2026-07-15t10:00:00z"`, lower case).
20+
21+
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`:
22+
23+
| written to a `time` | memory | SQLite | PostgreSQL | now, on all three |
24+
|:--|:--|:--|:--|:--|
25+
| `"+010000-01-01T10:00:00Z"`, `"9999-12-31T23:00:00-02:00"` | 201, read back as written | the same | 500 `DATABASE_ERROR` | 400 `invalid_time` |
26+
| `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"` | 201, read back as written | the same | 201, read back `"10:00:00"` | 400 `invalid_time`, the zone sentence |
27+
| `"2026-07-15 10:00Z"` | 201, `"10:00:00"` | the same | the same | 400 `invalid_time` |
28+
29+
**Who is affected.** A caller that writes a `time` field as a string with a `Z` or an offset, or as an out-of-range instant: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row already stored with such a value keeps it; nothing rewrites it. PostgreSQL stored a zone-suffixed time of day as its bare wall clock, so only a memory or SQLite deployment can hold one. An update that omits the field is not affected; one that sends the old value back is refused, naming the field. A `time` field whose literal `defaultValue` carries a `Z` or an offset has each insert that falls back to that default refused the same way. The server import (`POST /api/v1/data/:object/import`) turns a `time` cell into `HH:MM:SS` itself before the write, and already refused a zone-suffixed time-of-day cell, so its cells are unchanged.
30+
31+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST:
32+
33+
- a bare wall clock: `"10:00"` and `"10:00:00"` read back `"10:00:00"`, `"10:00:00.250"` reads back `"10:00:00.250"`;
34+
- a full ISO instant with a four-digit year, stored as its UTC time of day: `"2026-07-15T10:00:00Z"` and `"2026-07-15T18:00:00+08:00"` read back `"10:00:00"`;
35+
- a `Date` with a four-digit year, still accepted; an epoch-millisecond number, a `{placeholder}` and an out-of-range clock (`"25:00"`), still refused with `invalid_time`;
36+
- every `date` and `datetime` value, and every filter comparand.

‎content/docs/protocol/objectql/types.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -465,9 +465,11 @@ business_hours_start:
465465

466466
**Storage format:** `HH:MM:SS`, gaining a `.fff` millisecond suffix **only** when
467467
the milliseconds are non-zero (`14:30:00`, but `14:30:00.100`). Input accepts
468-
`HH:MM` or `HH:MM:SS` (with an optional fractional part and `Z`/offset); `14:30`
469-
is completed to `14:30:00`, so one wall clock can never split into several stored
470-
values. A `Date`, an epoch, or a full timestamp folds to its **UTC** time-of-day.
468+
`HH:MM` or `HH:MM:SS`, with an optional fractional part and no zone: `14:30Z` or
469+
`14:30+08:00` is refused with `invalid_time` (drop the suffix, or use a `datetime`
470+
field for an instant). `14:30` is completed to `14:30:00`, so one wall clock can
471+
never split into several stored values. A `Date` or a full ISO 8601 timestamp with
472+
a four-digit year folds to its **UTC** time-of-day; an epoch number is refused.
471473
A `time` is a wall-clock value, not an instant: it is validated as a time-of-day,
472474
not parsed as a date.
473475

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20671] What the `time` write door admits, this driver stores as the wall
5+
* clock it names, and reads back identically.
6+
*
7+
* The engine's record validator now refuses a time of day with a `Z` or an
8+
* offset (`"10:00Z"`, `"10:00+08:00"`) and an instant the `time` rule does not
9+
* read (`"+010000-01-01T10:00:00Z"`), with `VALIDATION_FAILED` / `invalid_time`
10+
* before any driver write (`packages/objectql/src/engine-time-write-zone-less.test.ts`).
11+
* Measured on the base through REST over this driver, the process in
12+
* America/New_York, both were stored verbatim here: `"10:00Z"` read back
13+
* `"10:00Z"`, while PostgreSQL read the same write back as `"10:00:00"`. The
14+
* refusal is therefore not this driver's. What it owes is the other half: a
15+
* plain `"10:00"` / `"10:00:00"`, and an ISO instant with a four-digit UTC
16+
* year, are stored as `HH:MM:SS` and found by it, with the process in a zone
17+
* that is not UTC so a host-zone reading would show. SQLite and PostgreSQL
18+
* read the same values back the same way in
19+
* `packages/rest/src/data-temporal-write-real-day-iso.test.ts`.
20+
*/
21+
22+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
23+
import { InMemoryDriver } from './memory-driver.js';
24+
25+
const OBJECT = 'schedule_time_20671';
26+
const FIELDS = { slot: { type: 'time' } };
27+
const HOST_ZONE = 'America/New_York';
28+
29+
/** Each spelling the `time` write door admits → the wall clock it is stored and read back as. */
30+
const ADMITTED: ReadonlyArray<readonly [string, unknown, string]> = [
31+
['hh-mm', '10:00', '10:00:00'],
32+
['hh-mm-ss', '10:00:00', '10:00:00'],
33+
['fraction', '10:00:00.250', '10:00:00.250'],
34+
['utc-instant', '2026-07-15T10:00:00Z', '10:00:00'],
35+
['offset-instant', '2026-07-15T18:00:00+08:00', '10:00:00'],
36+
['naive-instant', '2026-07-15 10:00', '10:00:00'],
37+
['a-date', new Date(Date.UTC(2026, 6, 15, 10)), '10:00:00'],
38+
];
39+
40+
const originalTz = process.env.TZ;
41+
42+
describe('[#20671] every spelling the time write door admits is stored as its wall clock and read back identically', () => {
43+
let driver: InMemoryDriver;
44+
45+
beforeAll(async () => {
46+
process.env.TZ = HOST_ZONE;
47+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE);
48+
driver = new InMemoryDriver({});
49+
await driver.connect();
50+
await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS });
51+
for (const [id, slot] of ADMITTED) await driver.create(OBJECT, { id, slot });
52+
await driver.create(OBJECT, { id: 'before', slot: '09:59:59' });
53+
});
54+
55+
afterAll(() => {
56+
if (originalTz === undefined) delete process.env.TZ;
57+
else process.env.TZ = originalTz;
58+
});
59+
60+
it('reads each one back as its wall clock', async () => {
61+
for (const [id, , stored] of ADMITTED) {
62+
expect((await driver.findOne(OBJECT, { where: { id } }))?.slot, id).toBe(stored);
63+
}
64+
});
65+
66+
it('finds each one by the wall clock it names, and orders it after the second before', async () => {
67+
const ids = async (where: Record<string, unknown>) =>
68+
(await driver.find(OBJECT, { where })).map((r) => r.id as string).sort();
69+
const ten = ADMITTED.filter(([, , stored]) => stored === '10:00:00').map(([id]) => id).sort();
70+
expect(await ids({ slot: { $eq: '10:00:00' } })).toEqual(ten);
71+
expect(await ids({ slot: { $eq: '10:00' } }), 'the HH:MM spelling').toEqual(ten);
72+
expect(await ids({ slot: { $gt: '09:59:59', $lt: '10:00:00.100' } })).toEqual(ten);
73+
});
74+
});
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20671] A `time` field is a zone-less wall clock, and the record validator's
5+
* `time` arm asks `@objectstack/core`'s one rule (`isUninterpretableTemporalComparand`,
6+
* the one the `time` comparand door asks). A value that rule does not read is
7+
* refused with `VALIDATION_FAILED` and the field's `invalid_time` code on insert,
8+
* update, a multi-row update and the dry-run `validate`, before any driver
9+
* write. A zone suffix on a time of day is refused in its own sentence (drop
10+
* the suffix, or use a `datetime` field for an instant).
11+
*
12+
* Measured on the base (`fa0a4b661`) through `POST /api/v1/data/:object` and a
13+
* read-back through `POST /api/v1/data/:object/query`, the process in
14+
* America/New_York and PostgreSQL 16 at Asia/Shanghai:
15+
*
16+
* | written to a `time` | memory | SQLite | PostgreSQL | now |
17+
* |:--|:--|:--|:--|:--|
18+
* | `"+010000-01-01T10:00:00Z"` | 201, read back verbatim | 201, verbatim | 500 `DATABASE_ERROR` | 400 |
19+
* | `"9999-12-31T23:00:00-02:00"` (UTC year 10000) | 201, verbatim | 201, verbatim | 500 | 400 |
20+
* | `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"` | 201, verbatim | 201, verbatim | 201, `"10:00:00"` | 400, the zone sentence |
21+
* | `"10:00:00.250Z"` | 201, verbatim | 201, verbatim | 201, `"10:00:00.250"` | 400, the zone sentence |
22+
* | `"2026-07-15 10:00Z"` (a space and a zone) | 201, `"10:00:00"` | the same | the same | 400 |
23+
* | `"10:00"`, `"10:00:00"` | 201, `"10:00:00"` | the same | the same | unchanged |
24+
* | `"2026-07-15T10:00:00Z"`, `"2026-07-15T18:00:00+08:00"` | 201, `"10:00:00"` | the same | the same | unchanged |
25+
* | `"07/15/2026 10:00"`, `"{now}"`, the number `36000000` | 400 `invalid_time` | the same | the same | unchanged |
26+
*
27+
* The REST door over SQLite and PostgreSQL is
28+
* `packages/rest/src/data-temporal-write-real-day-iso.test.ts`; the memory
29+
* driver's half is `memory-20671-time-write-zone-less.test.ts`. This file's
30+
* driver records writes and stores nothing, because the refusal sits in front
31+
* of every driver.
32+
*/
33+
34+
import { describe, it, expect, beforeEach } from 'vitest';
35+
import { isUninterpretableTemporalComparand } from '@objectstack/core';
36+
import { renderValidationMessage } from '@objectstack/spec/system';
37+
import { ObjectQL } from './engine.js';
38+
39+
const schedule = {
40+
name: 'schedule',
41+
label: 'Schedule',
42+
fields: {
43+
id: { name: 'id', type: 'text' as const, primaryKey: true },
44+
customer_id: { name: 'customer_id', type: 'text' as const },
45+
slot: { name: 'slot', label: 'Slot', type: 'time' as const },
46+
},
47+
};
48+
49+
/** A time of day with a `Z` or an offset: the zone sentence. Each was a 201 at the base, stored two ways by backend. */
50+
const ZONED: readonly string[] = [
51+
'10:00Z',
52+
'10:00+08:00',
53+
'10:00:00Z',
54+
'10:00:00+0800',
55+
'10:00:00.250Z',
56+
'10:00-05:30',
57+
'23:59:59-00:00',
58+
' 10:00Z ',
59+
'10:00z',
60+
];
61+
62+
/** An instant the `time` rule does not read: the plain sentence. Each was a 201 (or a 500 on PostgreSQL) at the base. */
63+
const UNREAD_INSTANT: readonly unknown[] = [
64+
'+010000-01-01T10:00:00Z',
65+
'9999-12-31T23:00:00-02:00',
66+
'-000001-01-01T10:00:00Z',
67+
'2026-02-30T10:00:00Z',
68+
'2026-07-15 10:00Z',
69+
'2026-07-15t10:00:00z',
70+
new Date(Date.parse('+010000-01-01T10:00:00Z')),
71+
];
72+
73+
/** Refused at the base already, kept refused, in the plain sentence. */
74+
const STILL_REFUSED: readonly unknown[] = [
75+
'25:00',
76+
'25:00Z',
77+
'14:60',
78+
'not-a-time',
79+
'14',
80+
'07/15/2026 10:00',
81+
'x2026-07-15T10:00:00Z',
82+
'{now}',
83+
36000000,
84+
];
85+
86+
/** A wall clock, or an ISO instant with a four-digit UTC year — each reaches the driver as written. */
87+
const ACCEPTED: readonly unknown[] = [
88+
'10:00',
89+
'10:00:00',
90+
'10:00:00.250',
91+
' 10:00 ',
92+
'00:00',
93+
'23:59:59.999',
94+
'2026-07-15T10:00:00Z',
95+
'2026-07-15T18:00:00+08:00',
96+
'2026-07-15T10:00:00-0530',
97+
'2026-07-15T10:00',
98+
'2026-07-15 10:00',
99+
new Date(Date.UTC(2026, 6, 15, 10)),
100+
];
101+
102+
const REFUSED: readonly unknown[] = [...ZONED, ...UNREAD_INSTANT, ...STILL_REFUSED];
103+
104+
/** A driver that records every write, and answers none. */
105+
function makeRecordingDriver() {
106+
const writes: Record<string, unknown>[] = [];
107+
const driver: any = {
108+
name: 'recording', version: '0.0.0', supports: {},
109+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
110+
async find() { return []; },
111+
async findOne() { return { id: 'r1' }; },
112+
async count() { return 0; },
113+
async aggregate() { return []; },
114+
async create(_o: string, data: Record<string, unknown>) { writes.push(data); return { ...data }; },
115+
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
116+
async updateMany(_o: string, _ast: unknown, data: Record<string, unknown>) { writes.push(data); return 0; },
117+
async delete() { return true; },
118+
async deleteMany() { return 0; },
119+
async bulkCreate(_o: string, batch: Record<string, unknown>[]) { writes.push(...batch); return batch; },
120+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
121+
async commit() {}, async rollback() {},
122+
};
123+
return { driver, writes };
124+
}
125+
126+
const refusalOf = async (p: Promise<unknown>) =>
127+
p.then(() => null, (e: any) => e as Error & { code?: string; fields?: Array<{ field: string; code: string; message: string }> });
128+
129+
const labelOf = (value: unknown) => (value instanceof Date ? `Date ${value.toISOString()}` : JSON.stringify(value));
130+
131+
/** The sentence a refusal of `value` carries: the zone sentence for a zone-suffixed time of day, else the plain one. */
132+
const sentenceFor = (value: unknown) =>
133+
renderValidationMessage({
134+
messageKey: ZONED.includes(value as string) ? 'invalid_time_zoned' : 'invalid_time',
135+
label: 'Slot',
136+
field: 'slot',
137+
});
138+
139+
describe('[#20671] the time write door — a zone-less wall clock, or an ISO instant with a four-digit UTC year, or VALIDATION_FAILED before any write', () => {
140+
let engine: ObjectQL;
141+
let writes: Record<string, unknown>[];
142+
143+
beforeEach(async () => {
144+
const rec = makeRecordingDriver();
145+
writes = rec.writes;
146+
engine = new ObjectQL();
147+
engine.registerDriver(rec.driver, true);
148+
await engine.init();
149+
engine.registry.registerObject(schedule, 'test');
150+
});
151+
152+
const doors = (value: unknown) => [
153+
['insert', () => engine.insert('schedule', { id: 'n1', customer_id: 'c1', slot: value })],
154+
['update', () => engine.update('schedule', { id: 'r1', slot: value })],
155+
['multi-row update', () => engine.update('schedule', { slot: value }, { where: { customer_id: 'c1' }, multi: true })],
156+
] as const;
157+
158+
it('refuses a zone suffix, an unread instant and the junk class on insert, update and a multi-row update, naming the field with invalid_time — and writes nothing', async () => {
159+
for (const value of REFUSED) {
160+
for (const [door, call] of doors(value)) {
161+
const err = await refusalOf(call());
162+
expect(err, `${door}, ${labelOf(value)}`).not.toBeNull();
163+
expect(err!.code, `${door}, ${labelOf(value)}`).toBe('VALIDATION_FAILED');
164+
expect(err!.fields?.map((f) => [f.field, f.code]), `${door}, ${labelOf(value)}`).toEqual([['slot', 'invalid_time']]);
165+
}
166+
}
167+
expect(writes, 'no write — every refusal precedes the driver').toHaveLength(0);
168+
});
169+
170+
it('a zone suffix on a time of day is refused in the zone sentence, every other refusal in the plain one', async () => {
171+
expect(sentenceFor('10:00Z'), 'the two sentences differ').not.toBe(sentenceFor('25:00'));
172+
for (const value of REFUSED) {
173+
const err = await refusalOf(engine.insert('schedule', { id: 'n1', slot: value }));
174+
expect(err!.fields?.[0]?.message, labelOf(value)).toBe(sentenceFor(value));
175+
}
176+
});
177+
178+
it('the dry-run validate predicts each refusal — and each accepted value as valid', async () => {
179+
for (const value of REFUSED) {
180+
const verdict = await engine.validate('schedule', { slot: value });
181+
expect(verdict.valid, labelOf(value)).toBe(false);
182+
expect(verdict.results[0]!.errors, labelOf(value)).toEqual([expect.objectContaining({ field: 'slot', code: 'invalid_time' })]);
183+
}
184+
for (const value of ACCEPTED) {
185+
expect((await engine.validate('schedule', { slot: value })).valid, labelOf(value)).toBe(true);
186+
}
187+
});
188+
189+
it('accepts a wall clock, an ISO instant with a four-digit UTC year and a Date on every door — the POSITIVE CONTROL', async () => {
190+
for (const value of ACCEPTED) {
191+
for (const [door, call] of doors(value)) {
192+
const before = writes.length;
193+
await expect(call(), `${door}, ${labelOf(value)}`).resolves.toBeDefined();
194+
expect(writes.length, `${door}, ${labelOf(value)} reached the driver`).toBe(before + 1);
195+
expect(writes.at(-1)!.slot, `${door}, ${labelOf(value)} reached it as written`).toEqual(value);
196+
}
197+
}
198+
});
199+
200+
it('one rule: a string is refused as a written time exactly when core refuses it as a time comparand, save the two write-only refusals', async () => {
201+
const corpus = [...REFUSED, ...ACCEPTED].filter((v): v is string => typeof v === 'string');
202+
// The comparand door exempts a `{placeholder}` (its resolver's vocabulary);
203+
// the write door refuses it, because a placeholder is not a value.
204+
const writeOnly = new Set(['{now}']);
205+
for (const value of corpus) {
206+
const refused = (await refusalOf(engine.insert('schedule', { id: 'n1', slot: value }))) !== null;
207+
const expected = writeOnly.has(value) || isUninterpretableTemporalComparand('time', value);
208+
expect(refused, value).toBe(expected);
209+
}
210+
// A number is the other write-only refusal: epoch milliseconds are a
211+
// comparand, never a written time.
212+
expect(isUninterpretableTemporalComparand('time', 36000000), 'core reads the number').toBe(false);
213+
expect(await refusalOf(engine.insert('schedule', { id: 'n1', slot: 36000000 })), 'the write door refuses it').not.toBeNull();
214+
});
215+
});

0 commit comments

Comments
 (0)