You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 22e584c
Browse filesBrowse the repository at this point in the historyBrowse files
fix(rest): /import reads a time cell by core's one time rule, so an exported 10:00:00.250 re-imports (#20722) (#20829)
Fixes#20722
Clause-②: no (narrowing)
`POST /api/v1/data/:object/import` now reads a `time` cell by
`@objectstack/core`'s one `time` rule. That is the rule the write door
has asked of a written `time` since #20671. So the `10:00:00.250` that
`/export` writes for a `time` with milliseconds re-imports as itself. A
cell that `@objectstack/core`'s `time` rule reads is now admitted
exactly when the write door admits it. The one reading the import keeps
and the write door refuses is the year-first date-time (#20534), named
below. A fraction is admitted on both. A `Z` / offset suffix on a time
of day is refused on both, with the same field code, `invalid_time`.
Base `96e724475` (claim) → merged with `origin/main` at `d2b188fb5` →
`0e624ca2b` → patch round 1 `99eb702d9` (changeset only).
## Reproduced first, then after
Measured through the routes: `POST /api/v1/data/:object` for the write
door, `POST .../import` for the import, `GET .../export` for the round
trip. The process ran in `TZ=America/New_York`. Backends were
`InMemoryDriver`, `SqlDriver` over SQLite, and `SqlDriver` over a
private PostgreSQL 16.13 at `Asia/Shanghai`. All three answered alike on
every row.
| `time` value | write door (unchanged here) | `/import` at base |
`/import` now |
|:--|:--|:--|:--|
| `10:00:00.250`, `23:59:59.999` (the card) | 201, as written | row
failed, `invalid_date`, `Clock: "10:00:00.250" is not a valid time` | as
written |
| `10:00:00.5`, `10:00:00.000` | `10:00:00.500`, `10:00:00` | row
failed, `invalid_date` | the write door's value |
| `2026-07-15T10:00:00.250Z`, `2026-07-15 10:00:00.250` | `10:00:00.250`
| `10:00:00`, fraction silently dropped | `10:00:00.250` |
| `9999-12-31T23:00:00-02:00` (UTC year 10000) | 400 `invalid_time` |
stored `01:00:00` | row failed, `invalid_time` |
| `10:00Z`, `10:00+08:00`, `10:00:00.250Z`, `+010000-01-01T10:00:00Z`,
`25:00`, `9:00`, `07/15/2026 10:00` | 400 `invalid_time` | row failed,
`invalid_date` | row failed, `invalid_time` |
| `10:00:00`, `10:00`, `2026-07-15T18:00:00+08:00` | `10:00:00` |
`10:00:00` | unchanged |
| export → import (CSV and JSON) of `10:00:00.250`, `23:59:59.999` | — |
both rows failed | stored as exported |
| export → import of the `10:00:00` control | — | stored as exported |
unchanged |
Base readings: the new pin run on the unfixed tree gave 48 failed / 30
passed on SQLite plus live PostgreSQL. On memory, an uncommitted copy of
the pin with `InMemoryDriver` gave 31 failed / 26 passed at base. For
the memory base leg, the base `import-coerce.ts` was restored under a
trap. The restore was proved by `git hash-object` equal to the HEAD blob
`944f05589` and an empty `git diff HEAD`. At head: 78 / 78 on SQLite
plus PostgreSQL, and 57 / 57 on memory.
## The change
- `packages/rest/src/import-coerce.ts`
- The private `TIME_OF_DAY` pattern is deleted. It had no fractional
part.
- A new `readTimeOfDayCell(s)` does two things. Its verdict is
`isUninterpretableTemporalComparand('time', s)`. Its stored value is
`temporalStorageForm(s, 'time')`. A `{placeholder}` is refused by
`classifyFilterToken`, the same `readable` gate the write door applies.
⛔ There is no second regex.
- `temporalStorageForm` alone is not a verdict, measured on core's
`dist`. It is total and never throws. For junk it hands the value back
unchanged. But it also returns a clock for values the verdict refuses:
`07/15/2026 10:00` gives `10:00:00` through `Date.parse` in the host
zone, and `2026-02-30T10:00:00Z` gives `10:00:00` after rolling the date
over.
- A `time` cell that core's rule refuses is read in the year-first form
alone (`2026/7/15 9:00` gives `09:00:00`). That is the one reading the
import has beyond the write door, from the maintainer ruling on #20534.
It is pinned as such. The ISO reader (`readIsoTemporalCell`) no longer
answers for a `time` cell. Core's rule is the whole verdict there.
- A refused `time` cell now reports `invalid_time`, the write door's
code for the same value, where it reported `invalid_date`. `date` /
`datetime` keep `invalid_date`. The row sentence (`import_invalid_time`)
is unchanged.
- `packages/runtime/src/dispatcher-error-vocabulary.ts`: one
`foreign-vocabulary` row for the new `invalid_time` stamp at
`import-coerce.ts`. It is the twin of that file's existing
`invalid_date` row. `check:dispatcher-error-vocabulary` was red without
it. The table is not in `@objectstack/runtime`'s published output. On a
fresh build, its text has 0 hits in `packages/runtime/dist`, against a
positive control of 105 hits for `HttpDispatcher`. So runtime takes no
changeset.
- `packages/rest/src/import-date-cell-iso-real-day.test.ts` (the #20534
pin): its one `t` row (`07/15/2026 10:00`) now expects `invalid_time`.
That is the code the write door gives the same value. `d` and `dt` rows
keep `invalid_date`.
- New pin `packages/rest/src/import-time-cell-fraction.test.ts`:
- the reader on two host zones;
- both doors cell for cell on SQLite, plus PostgreSQL where
`OS_TEST_POSTGRES_URL` is set (a named skip otherwise);
- the dry run;
- export → import in CSV and JSON of `10:00:00.250`, `23:59:59.999` and
the `10:00:00` control.
- `.changeset/20722-import-time-fraction.md`: `@objectstack/rest:
minor`, `Clause-②: no (narrowing)`, a `**BREAKING**` banner and an
ADR-0087 `not-required (no-migration-prescription)` disposition (patch
round 1, `99eb702d9`).
## Clause-② reading: `no (narrowing)`
1. **A narrowing half.** The import now refuses a `time` cell that is an
ISO instant whose UTC year has no four-digit spelling. For example,
`9999-12-31T23:00:00-02:00` was stored as `01:00:00`. The write door has
refused that value since #20671. This is the half the claim's Clause-②
reading asked to hear about. The other half widens toward what the write
door already admits: fractions, and a zone-naive `2026-07-15 24:00` into
a `time`, which is now `00:00:00` on both doors.
2. **The row code for a refused `time` cell moves from `invalid_date` to
`invalid_time`.** Triage 5899707588 asked for this: "refused with the
same code the write door gives". No code is minted. `invalid_time` is
already in `FieldErrorCode`. ADR-0114 records that nothing branches on a
field code: objectui reads `code` only as a last-resort text.
The seat took option B: body line 2 and the changeset both read
`Clause-②: no (narrowing)`. The options are in the `os-dev-report` on
#20722.
## PM hypotheses
- **H1 held.** At `96e724475` `import-coerce.ts:273` was the pattern the
card names, and `:506` was its only use. The round trip failed as
described, on all three backends.
- **H2.** `canonicalTimeOfDay` is a private function in
`packages/core/src/utils/temporal-storage-form.ts`. It is exported
nowhere. The public doors are `temporalStorageForm(value, kind)` and
`isUninterpretableTemporalComparand(kind, value)`. Both come from
`@objectstack/core`'s root: `index.ts` re-exports
`temporal-storage-form.js` and `temporal-comparand.js`. `packages/rest`
already depends on `@objectstack/core` and already imported
`temporalStorageForm`. No core edit was needed. `temporalStorageForm`
never throws and hands back unchanged what it cannot read, but it is not
a verdict (see above).
- **H3.** A live PostgreSQL 16.13 ran in this container: a private
cluster on a random port, at `Asia/Shanghai`, started and stopped by
this run. No CI job provisions PostgreSQL for `@objectstack/rest`. The
`Temporal Conformance (live PG + MySQL)` job runs driver-sql,
metadata-protocol and one runtime file. So the PostgreSQL cell of this
pin is a named skip in CI, as it is for the existing
`data-temporal-write-real-day-iso.test.ts`.
- **H4.** Measured on `dist`:
- spec `ClockTimeValueSchema` (`c9c182ed`) admits `10:00:00.250` and
refuses `10:00Z`, `10:00+08:00` and `10:00:00.250Z`;
- the write door (#20671) does the same;
- so does the import now.
- The three agree on "a fraction admitted, a Z or offset refused". One
difference was already there before this change: a full ISO instant
(`2026-07-15T10:00:00Z`) is admitted by the write door and the import,
which fold it to its UTC time of day (ADR-0053 D-C1).
`ClockTimeValueSchema` refuses it.
## Tests
On head `0e624ca2b` unless stated:
- `pnpm --filter @objectstack/rest exec vitest run --project local`,
with live PostgreSQL, on the merged tree `992b08a49`: 232 files, 4588
passed / 30 skipped. `0e624ca2b` differs from it only in the runtime
table.
- `pnpm --filter @objectstack/rest typecheck`: exit 0.
`check:test-typecheck` OK, 0 debt.
- `pnpm --filter @objectstack/runtime exec vitest run --project repo
src/error-envelope.conformance.test.ts`: 53 / 53. This file is the only
importer of the edited table (`git grep`).
- `pnpm --filter @objectstack/runtime typecheck`: exit 0.
- The new pin, verbose: 78 / 78. That is 21 cases on PostgreSQL, 21 on
SQLite and 36 reader cases.
- `require('@objectstack/rest')` and `require('@objectstack/runtime')`
from the freshly built CJS `dist` load. `dist/index.cjs` of rest carries
the new reader.
## Gates
`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--ran` on `0e624ca2b` gives 62 derived. 61 exited 0. One is NOT
MEASURED: `check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET. It
needs every package's `dist`, and this run built the rest and runtime
closures only. The targeted `require` reading above covers the two
packages this diff touches.
`pnpm lint` (the whole repo, `eslint . --no-inline-config`) exited 0 on
`0e624ca2b`.
The derivation's last read printed a stale-tree note: `origin/main` has
since moved past `d2b188fb5` by `.github/**/half-state-patrol*` and four
docs / objectql commits.
## Acceptance notes
Noted, not filed:
- A `Date` object reaching `parseDateCell(…, 'time')` still takes
`utcClock`, which drops milliseconds, where core's rule keeps them. No
public door hands the reader a `Date`: JSON carries none, and an xlsx
date cell is rendered to text first. Carrier: none.
- A zone-suffixed `time` cell on `/import` gets the import's own
sentence (`Clock: "10:00Z" is not a valid time`), not the write door's
prescription (drop the Z or offset, or use a datetime field). Reusing
that sentence needs a zone test the write door keeps private in
`objectql`. Carrier: none.
- The xlsx leg of the round trip was not measured. `/export` writes a
`time` cell as the stored string, and the import reads a string cell as
written.
- #20671's pending changeset says the import "turns a `time` cell into
`HH:MM:SS`". After this change it is `HH:MM:SS`, plus `.fff` when
non-zero.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(rest)!: `POST /api/v1/data/:object/import` reads a `time` cell by `@objectstack/core`'s one `time` rule, the rule the write door asks, so the `10:00:00.250` that `/export` writes for a `time` with milliseconds re-imports as itself instead of failing its row as `invalid_date`, and a cell the write door refuses is refused with the write door's code, `invalid_time` (#20722)
6
+
7
+
Clause-②: no (narrowing)
8
+
9
+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of an import CELL value at the `/import` reader's time arm, and the field code that refusal reports: no authorable key, spelling or stored shape of metadata moves, and no schema, type or export changes; `packages/spec` is untouched. The code a refused `time` cell reports moves between two existing members of the closed `FieldErrorCode` catalog (ADR-0114), so nothing is minted or retired, and nothing branches on a field code. An import file is caller input, not stored metadata, and a row already stored keeps whatever it holds, so there is nothing a ledger entry could rewrite. The other categories are closed on facts: `@objectstack/rest` publishes (not `unpublished`); no ADR-0087 id covers an import cell 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 `/import` accepts in a `time` cell. An ISO 8601 instant whose UTC year has no four-digit spelling (`"9999-12-31T23:00:00-02:00"`, which is UTC year 10000) was stored as its UTC time of day (`01:00:00`). It now fails its row with `invalid_time`, as the write door has refused the same value since #20671. A refused `time` cell's row code also moves from `invalid_date` to `invalid_time`, the code the write door gives for the same 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
+
The import's `time` reader was a private pattern with no fractional part. A `time` stored with milliseconds (`10:00:00.250`, which the write door accepts and `/export` writes as it is stored) failed its row on re-import with `Clock: "10:00:00.250" is not a valid time`, on every backend, so an export did not re-import. The reader now asks the same rule the write door asks of a written `time`: `isUninterpretableTemporalComparand('time', …)` for the verdict and `temporalStorageForm(…, 'time')` for the stored value. A cell is admitted exactly when the write door admits the same value, and stored as the same wall clock.
14
+
15
+
Through the route, the process in America/New_York, on memory, SQLite and PostgreSQL 16 (at `Asia/Shanghai`), before and after:
16
+
17
+
|`time` cell | before | now |
18
+
|:--|:--|:--|
19
+
|`10:00:00.250`, `23:59:59.999` (what `/export` writes) | row failed, `invalid_date`| stored as written |
|`2026/7/15 9:00` (the year-first form) |`09:00:00`| unchanged |
26
+
27
+
A zone-naive `2026-07-15 24:00` in a `time` cell, refused before, is now read as `00:00:00`: core's rule reads it so, and the write door admits it. A `date` or `datetime` cell keeps `invalid_date`, and the row's sentence is unchanged for every kind. A time of day with a `Z` or an offset stays refused: a `time` carries no zone. The year-first date-time (`2026/7/15 9:00`) is still read as its wall clock; it is the one reading the import has that the write door has not. The dry run reports the same verdicts.
28
+
29
+
**Who is affected.** A caller of `POST /api/v1/data/:object/import`, including the dry run, whose file carries such an instant in a `time` column now gets that row refused. A client that matched the row report's `code` for a refused `time` cell now reads `invalid_time`. Rows already stored are not rewritten.
30
+
31
+
This supersedes the note in the `@objectstack/objectql` entry for #20671 that the server import turns a `time` cell into `HH:MM:SS`: the import now keeps a non-zero fraction (`HH:MM:SS.fff`).
0 commit comments