Skip to content

Commit 22e584c

Browse 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>
1 parent 15b586d commit 22e584c

6 files changed

Lines changed: 430 additions & 20 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
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 |
20+
| `10:00:00.5`, `10:00:00.000` | row failed, `invalid_date` | `10:00:00.500`, `10:00:00` |
21+
| `2026-07-15T10:00:00.250Z`, `2026-07-15 10:00:00.250` | stored `10:00:00`, the fraction dropped | `10:00:00.250`, as the write door stores it |
22+
| `9999-12-31T23:00:00-02:00` (an instant in UTC year 10000) | stored `01:00:00` | row failed, `invalid_time`, as the write door refuses it |
23+
| `10:00Z`, `10:00+08:00`, `07/15/2026 10:00`, `25:00` | row failed, `invalid_date` | row failed, `invalid_time` |
24+
| `10:00`, `10:00:00`, `2026-07-15T18:00:00+08:00` | `10:00:00` | unchanged |
25+
| `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`).

‎packages/rest/src/import-coerce.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('parseDateCell', () => {
103103
* [#20534] A text cell is read only in ISO 8601, the export's own
104104
* `YYYY-MM-DD HH:mm:ss` or a year-first date (`2026/7/15`, `2026/7/15 9:00`,
105105
* by the maintainer ruling on the card), on a calendar day that exists;
106-
* everything else is refused (`undefined`, so the row's `invalid_date`), never rolled over, never
106+
* everything else is refused (`undefined`, so the row's `invalid_date`, a `time` cell's `invalid_time`), never rolled over, never
107107
* read in the host's zone and never read month-first. A `date`'s year keeps
108108
* four digits. Every case runs under two host zones that disagree by twelve
109109
* hours, and must answer the same under both — the host-zone reading this

‎packages/rest/src/import-coerce.ts‎

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* - number / currency / percent / rating / slider → a finite `number`
1717
* - boolean / toggle → a real `boolean`
1818
* - date / datetime → an ISO-8601 string
19-
* - time → `HH:MM` / `HH:MM:SS`
19+
* - time → `HH:MM:SS`, `.fff` when non-zero
2020
* - select / radio → an option *value*
2121
* - multiselect / checkboxes / tags → an array of option values
2222
* - lookup / master_detail / user / reference → a record id (resolved async)
@@ -31,7 +31,12 @@
3131
* untouched, so an import stays byte-identical to the pre-coercion behaviour.
3232
*/
3333

34-
import { temporalStorageForm, zonedWallClockToUtcMs, type WallClockParts } from '@objectstack/core';
34+
import {
35+
isUninterpretableTemporalComparand,
36+
temporalStorageForm,
37+
zonedWallClockToUtcMs,
38+
type WallClockParts,
39+
} from '@objectstack/core';
3540
import type { ExportFieldMeta } from './export-format.js';
3641
import {
3742
SINGLE_OPTION_TYPES as OPTION_TYPES,
@@ -43,6 +48,7 @@ import {
4348
IMPORT_BOOLEAN_TRUE_TOKENS,
4449
IMPORT_BOOLEAN_FALSE_TOKENS,
4550
IMPORT_REFERENCE_TYPES,
51+
classifyFilterToken,
4652
} from '@objectstack/spec/data';
4753
import type { FieldErrorCode } from '@objectstack/spec/api';
4854
import {
@@ -270,7 +276,38 @@ function pad2(n: number): string {
270276
return n < 10 ? `0${n}` : String(n);
271277
}
272278

273-
const TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
279+
/**
280+
* [#20722] A `time` cell read by `@objectstack/core`'s one `time` rule, or
281+
* `undefined` when that rule refuses it. It is the rule the write door asks of
282+
* a written `time` (#20671) and the comparand door asks of a filter value, so a
283+
* cell is admitted here exactly when the same value is admitted there, and is
284+
* stored as the same wall clock:
285+
*
286+
* - the verdict is `isUninterpretableTemporalComparand('time', …)`: a bare
287+
* `HH:MM[:SS[.f…]]` in range, or an instant in an ISO 8601 spelling a
288+
* `datetime` is written in, on a day that exists, whose UTC year has four
289+
* digits. A time of day with a `Z` or an offset (`10:00Z`) is refused: a
290+
* `time` carries no zone (ADR-0053 D-C1);
291+
* - the value is `temporalStorageForm(…, 'time')`: `HH:MM:SS`, `.fff` kept
292+
* when non-zero (a fraction past milliseconds truncated), and an instant's
293+
* UTC time of day, fraction included.
294+
*
295+
* The storage form is not a verdict on its own: it hands a string it cannot
296+
* read back unchanged, and it reads `07/15/2026 10:00` through `Date.parse`
297+
* in the host's zone and rolls `2026-02-30T10:00:00Z` over to March 2, both of
298+
* which the verdict refuses. A `{placeholder}` is filter vocabulary, which the
299+
* verdict steps around rather than judges, so it is refused here, as the write
300+
* door refuses it.
301+
*
302+
* This replaced a private pattern with no fractional part, which refused the
303+
* `10:00:00.250` that `/export` writes for a `time` with milliseconds, so such
304+
* a row did not re-import.
305+
*/
306+
function readTimeOfDayCell(s: string): string | undefined {
307+
if (classifyFilterToken(s) !== null || isUninterpretableTemporalComparand('time', s)) return undefined;
308+
const form = temporalStorageForm(s, 'time');
309+
return typeof form === 'string' ? form : undefined;
310+
}
274311

275312
/**
276313
* [#20534] The text shapes a `date` / `datetime` / `time` cell is read in,
@@ -416,19 +453,24 @@ function utcClock(t: Date): string {
416453
* Coerce a cell into the string shape the engine accepts for a date-ish field:
417454
* - `date` → `YYYY-MM-DD`
418455
* - `datetime` → full ISO-8601 (`toISOString`)
419-
* - `time` → `HH:MM` / `HH:MM:SS`
456+
* - `time` → `HH:MM:SS`, `.fff` when non-zero ({@link readTimeOfDayCell})
420457
* Returns `undefined` when the cell is not a recognisable date/time, and the
421-
* caller fails the row with `invalid_date` (`import_invalid_date` /
422-
* `import_invalid_datetime` / `import_invalid_time`).
458+
* caller fails the row with the write door's code for the same cell:
459+
* `invalid_date` for a `date` / `datetime` (`import_invalid_date` /
460+
* `import_invalid_datetime`), `invalid_time` for a `time`
461+
* (`import_invalid_time`).
423462
*
424463
* ## Which text is read at all (#20534)
425464
*
426465
* A text cell is read only in an {@link ISO_TEMPORAL_CELL} shape — ISO 8601,
427466
* or the export's own `YYYY-MM-DD HH:mm:ss` — or as a year-first date
428467
* ({@link YEAR_FIRST_CELL}: `2026/7/15`, `2026/7/15 9:00`, stored padded, the
429468
* clock read exactly as the export shape's), and only on a calendar day that
430-
* exists. A `time` cell may also be a bare `HH:MM` / `HH:MM:SS`. Every other
431-
* cell is refused on every branch; nothing reaches `new Date(s)`:
469+
* exists. A `time` cell is read by core's one `time` rule instead
470+
* ({@link readTimeOfDayCell}, #20722): a bare `HH:MM[:SS[.f…]]` or an ISO
471+
* 8601 instant, as the write door reads it, and otherwise in the year-first
472+
* form. Every other cell is refused on every branch; nothing reaches
473+
* `new Date(s)`:
432474
*
433475
* - **an impossible day** (`2026-02-30`, `2026-02-29`, `2026-04-31`,
434476
* `2026/2/30`, in any of the shapes) — `Date.UTC` and `Date.parse` rolled it
@@ -482,9 +524,10 @@ function utcClock(t: Date): string {
482524
* see it.
483525
*
484526
* For a naive cell landing in a `date` or `time` field the typed components are
485-
* taken verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`), which is
486-
* both zone-free and host-`TZ`-free. An offset-bearing cell landing in either
487-
* takes the UTC calendar day or UTC clock of the instant it names.
527+
* taken verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`, a `time`
528+
* keeping a non-zero fraction), which is both zone-free and host-`TZ`-free. An
529+
* offset-bearing cell landing in either takes the UTC calendar day or UTC
530+
* clock of the instant it names.
488531
*/
489532
export function parseDateCell(
490533
raw: unknown,
@@ -503,9 +546,15 @@ export function parseDateCell(
503546
const s = String(raw).trim();
504547
if (s === '') return undefined;
505548

506-
if (kind === 'time' && TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s;
549+
if (kind === 'time') {
550+
const clock = readTimeOfDayCell(s);
551+
if (clock !== undefined) return clock;
552+
}
507553

508-
const cell = readIsoTemporalCell(s) ?? readYearFirstCell(s);
554+
// [#20722] Core's rule is the whole verdict on an ISO 8601 `time` cell, so a
555+
// `time` cell it refuses is read in the year-first form alone — the one
556+
// reading the import has beyond the write door (#20534, maintainer ruling).
557+
const cell = (kind === 'time' ? undefined : readIsoTemporalCell(s)) ?? readYearFirstCell(s);
509558
if (!cell) return undefined;
510559

511560
if (cell.wall) {
@@ -593,9 +642,11 @@ export async function coerceFieldValue(
593642
// The business timezone an offset-free datetime cell is read in (#8485).
594643
const d = parseDateCell(raw, t, ctx.timezone);
595644
if (d === undefined) {
596-
// One code, three sentences — a `time` cell is not "not a valid date".
645+
// [#20722] The write door's code for the same cell — `invalid_time` for a
646+
// `time`, `invalid_date` for a `date` / `datetime` — and three sentences.
647+
const code = t === 'time' ? 'invalid_time' : 'invalid_date';
597648
const key = t === 'datetime' ? 'import_invalid_datetime' : t === 'time' ? 'import_invalid_time' : 'import_invalid_date';
598-
return coerceError(meta, field, 'invalid_date', key, raw, ctx);
649+
return coerceError(meta, field, code, key, raw, ctx);
599650
}
600651
return { value: d };
601652
}

‎packages/rest/src/import-date-cell-iso-real-day.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ const REFUSED: ReadonlyArray<readonly [field: Field, cell: string]> = [
7676
['d', '2026/2/30'],
7777
];
7878

79+
/** [#20722] The write door's field code for a refused cell: `invalid_time` for a `time`. */
80+
const codeOf = (field: Field) => (field === 't' ? 'invalid_time' : 'invalid_date');
81+
7982
/** Admitted cells and what they store — the padding, the ISO control and the export shape. */
8083
const ADMITTED: ReadonlyArray<readonly [field: Field, cell: string, stored: string]> = [
8184
['d', '0500-01-01', '0500-01-01'],
@@ -180,7 +183,7 @@ describe.each(HOST_ZONES)('[#20534] /import date cells, host TZ=$tz', ({ tz, jul
180183
else process.env.TZ = originalTz;
181184
});
182185

183-
it.each(REFUSED)('refuses the %s cell %j as that row\'s invalid_date and writes its sibling row', async (field, cell) => {
186+
it.each(REFUSED)('refuses the %s cell %j as that row\'s invalid_date (a time cell: invalid_time) and writes its sibling row', async (field, cell) => {
184187
const res = await ctx.importRows({
185188
format: 'json', writeMode: 'insert',
186189
rows: [{ id: 'bad', [field]: cell }, { id: 'good', d: '2026-07-15' }],
@@ -189,7 +192,7 @@ describe.each(HOST_ZONES)('[#20534] /import date cells, host TZ=$tz', ({ tz, jul
189192
expect(res._status ?? 200).toBe(200);
190193
expect(res._json).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 });
191194
expect(res._json.results[0]).toMatchObject({
192-
row: 1, ok: false, action: 'failed', field, code: 'invalid_date',
195+
row: 1, ok: false, action: 'failed', field, code: codeOf(field),
193196
});
194197
// Refused, not stored as some other day or instant.
195198
expect(await ctx.engine.findOne(OBJECT, { where: { id: 'bad' } })).toBeNull();
@@ -267,7 +270,7 @@ describe.each(HOST_ZONES)('[#20534] /import date cells, host TZ=$tz', ({ tz, jul
267270

268271
expect(res._json).toMatchObject({ total: REFUSED.length, ok: 0, errors: REFUSED.length });
269272
const failedFields = res._json.results.map((r: any) => [r.field, r.code]);
270-
expect(failedFields).toEqual(REFUSED.map(([field]) => [field, 'invalid_date']));
273+
expect(failedFields).toEqual(REFUSED.map(([field]) => [field, codeOf(field)]));
271274
});
272275

273276
it('dry run predicts the same refusals and persists nothing', async () => {
@@ -278,7 +281,7 @@ describe.each(HOST_ZONES)('[#20534] /import date cells, host TZ=$tz', ({ tz, jul
278281

279282
expect(res._json).toMatchObject({ dryRun: true, total: REFUSED.length, ok: 0, errors: REFUSED.length });
280283
for (const [i, r] of res._json.results.entries()) {
281-
expect(r).toMatchObject({ ok: false, field: REFUSED[i][0], code: 'invalid_date' });
284+
expect(r).toMatchObject({ ok: false, field: REFUSED[i][0], code: codeOf(REFUSED[i][0]) });
282285
}
283286
for (const [i] of REFUSED.entries()) {
284287
expect(await ctx.engine.findOne(OBJECT, { where: { id: `d${i}` } })).toBeNull();

0 commit comments

Comments
 (0)