From 926142284b68338ed75c632a393bb53c236a8082 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:05:08 +0000 Subject: [PATCH 1/4] fix(rest): /import reads a time cell by core's one time rule, so an exported 10:00:00.250 re-imports The import's time reader was a private pattern with no fractional part, so the 10:00:00.250 that /export writes for a time with milliseconds failed its row as invalid_date on every backend. It now asks the rule the write door asks of a written time: isUninterpretableTemporalComparand('time', ...) for the verdict and temporalStorageForm(..., 'time') for the stored value. The year-first date-time stays the import's own reading. A refused time cell reports the write door's code for the same value, invalid_time. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20722-import-time-fraction.md | 21 ++ packages/rest/src/import-coerce.test.ts | 2 +- packages/rest/src/import-coerce.ts | 81 ++++- .../src/import-date-cell-iso-real-day.test.ts | 11 +- .../src/import-time-cell-fraction.test.ts | 294 ++++++++++++++++++ 5 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 .changeset/20722-import-time-fraction.md create mode 100644 packages/rest/src/import-time-cell-fraction.test.ts diff --git a/.changeset/20722-import-time-fraction.md b/.changeset/20722-import-time-fraction.md new file mode 100644 index 00000000000..124a7528624 --- /dev/null +++ b/.changeset/20722-import-time-fraction.md @@ -0,0 +1,21 @@ +--- +"@objectstack/rest": patch +--- + +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` (#20722) + +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. + +Through the route, the process in America/New_York, SQLite and PostgreSQL 16 (at `Asia/Shanghai`), before and after: + +| `time` cell | before | now | +|:--|:--|:--| +| `10:00:00.250`, `23:59:59.999` (what `/export` writes) | row failed, `invalid_date` | stored as written | +| `10:00:00.5`, `10:00:00.000` | row failed, `invalid_date` | `10:00:00.500`, `10:00:00` | +| `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 | +| `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 | +| `10:00Z`, `10:00+08:00`, `07/15/2026 10:00`, `25:00` | row failed, `invalid_date` | row failed, `invalid_time` | +| `10:00`, `10:00:00`, `2026-07-15T18:00:00+08:00` | `10:00:00` | unchanged | +| `2026/7/15 9:00` (the year-first form) | `09:00:00` | unchanged | + +A refused `time` cell now reports the field code the write door gives for the same value, `invalid_time`, where it reported `invalid_date`; the row's sentence is unchanged. A `date` or `datetime` cell keeps `invalid_date`. 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. diff --git a/packages/rest/src/import-coerce.test.ts b/packages/rest/src/import-coerce.test.ts index e1c409fb02b..dfa6bc1c1f2 100644 --- a/packages/rest/src/import-coerce.test.ts +++ b/packages/rest/src/import-coerce.test.ts @@ -103,7 +103,7 @@ describe('parseDateCell', () => { * [#20534] A text cell is read only in ISO 8601, the export's own * `YYYY-MM-DD HH:mm:ss` or a year-first date (`2026/7/15`, `2026/7/15 9:00`, * by the maintainer ruling on the card), on a calendar day that exists; - * everything else is refused (`undefined`, so the row's `invalid_date`), never rolled over, never + * everything else is refused (`undefined`, so the row's `invalid_date`, a `time` cell's `invalid_time`), never rolled over, never * read in the host's zone and never read month-first. A `date`'s year keeps * four digits. Every case runs under two host zones that disagree by twelve * hours, and must answer the same under both — the host-zone reading this diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts index abdd17abc6f..944f0558988 100644 --- a/packages/rest/src/import-coerce.ts +++ b/packages/rest/src/import-coerce.ts @@ -16,7 +16,7 @@ * - number / currency / percent / rating / slider → a finite `number` * - boolean / toggle → a real `boolean` * - date / datetime → an ISO-8601 string - * - time → `HH:MM` / `HH:MM:SS` + * - time → `HH:MM:SS`, `.fff` when non-zero * - select / radio → an option *value* * - multiselect / checkboxes / tags → an array of option values * - lookup / master_detail / user / reference → a record id (resolved async) @@ -31,7 +31,12 @@ * untouched, so an import stays byte-identical to the pre-coercion behaviour. */ -import { temporalStorageForm, zonedWallClockToUtcMs, type WallClockParts } from '@objectstack/core'; +import { + isUninterpretableTemporalComparand, + temporalStorageForm, + zonedWallClockToUtcMs, + type WallClockParts, +} from '@objectstack/core'; import type { ExportFieldMeta } from './export-format.js'; import { SINGLE_OPTION_TYPES as OPTION_TYPES, @@ -43,6 +48,7 @@ import { IMPORT_BOOLEAN_TRUE_TOKENS, IMPORT_BOOLEAN_FALSE_TOKENS, IMPORT_REFERENCE_TYPES, + classifyFilterToken, } from '@objectstack/spec/data'; import type { FieldErrorCode } from '@objectstack/spec/api'; import { @@ -270,7 +276,38 @@ function pad2(n: number): string { return n < 10 ? `0${n}` : String(n); } -const TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/; +/** + * [#20722] A `time` cell read by `@objectstack/core`'s one `time` rule, or + * `undefined` when that rule refuses it. It is the rule the write door asks of + * a written `time` (#20671) and the comparand door asks of a filter value, so a + * cell is admitted here exactly when the same value is admitted there, and is + * stored as the same wall clock: + * + * - the verdict is `isUninterpretableTemporalComparand('time', …)`: a bare + * `HH:MM[:SS[.f…]]` in range, or an instant in an ISO 8601 spelling a + * `datetime` is written in, on a day that exists, whose UTC year has four + * digits. A time of day with a `Z` or an offset (`10:00Z`) is refused: a + * `time` carries no zone (ADR-0053 D-C1); + * - the value is `temporalStorageForm(…, 'time')`: `HH:MM:SS`, `.fff` kept + * when non-zero (a fraction past milliseconds truncated), and an instant's + * UTC time of day, fraction included. + * + * The storage form is not a verdict on its own: it hands a string it cannot + * read back unchanged, and it reads `07/15/2026 10:00` through `Date.parse` + * in the host's zone and rolls `2026-02-30T10:00:00Z` over to March 2, both of + * which the verdict refuses. A `{placeholder}` is filter vocabulary, which the + * verdict steps around rather than judges, so it is refused here, as the write + * door refuses it. + * + * This replaced a private pattern with no fractional part, which refused the + * `10:00:00.250` that `/export` writes for a `time` with milliseconds, so such + * a row did not re-import. + */ +function readTimeOfDayCell(s: string): string | undefined { + if (classifyFilterToken(s) !== null || isUninterpretableTemporalComparand('time', s)) return undefined; + const form = temporalStorageForm(s, 'time'); + return typeof form === 'string' ? form : undefined; +} /** * [#20534] The text shapes a `date` / `datetime` / `time` cell is read in, @@ -416,10 +453,12 @@ function utcClock(t: Date): string { * Coerce a cell into the string shape the engine accepts for a date-ish field: * - `date` → `YYYY-MM-DD` * - `datetime` → full ISO-8601 (`toISOString`) - * - `time` → `HH:MM` / `HH:MM:SS` + * - `time` → `HH:MM:SS`, `.fff` when non-zero ({@link readTimeOfDayCell}) * Returns `undefined` when the cell is not a recognisable date/time, and the - * caller fails the row with `invalid_date` (`import_invalid_date` / - * `import_invalid_datetime` / `import_invalid_time`). + * caller fails the row with the write door's code for the same cell: + * `invalid_date` for a `date` / `datetime` (`import_invalid_date` / + * `import_invalid_datetime`), `invalid_time` for a `time` + * (`import_invalid_time`). * * ## Which text is read at all (#20534) * @@ -427,8 +466,11 @@ function utcClock(t: Date): string { * or the export's own `YYYY-MM-DD HH:mm:ss` — or as a year-first date * ({@link YEAR_FIRST_CELL}: `2026/7/15`, `2026/7/15 9:00`, stored padded, the * clock read exactly as the export shape's), and only on a calendar day that - * exists. A `time` cell may also be a bare `HH:MM` / `HH:MM:SS`. Every other - * cell is refused on every branch; nothing reaches `new Date(s)`: + * exists. A `time` cell is read by core's one `time` rule instead + * ({@link readTimeOfDayCell}, #20722): a bare `HH:MM[:SS[.f…]]` or an ISO + * 8601 instant, as the write door reads it, and otherwise in the year-first + * form. Every other cell is refused on every branch; nothing reaches + * `new Date(s)`: * * - **an impossible day** (`2026-02-30`, `2026-02-29`, `2026-04-31`, * `2026/2/30`, in any of the shapes) — `Date.UTC` and `Date.parse` rolled it @@ -482,9 +524,10 @@ function utcClock(t: Date): string { * see it. * * For a naive cell landing in a `date` or `time` field the typed components are - * taken verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`), which is - * both zone-free and host-`TZ`-free. An offset-bearing cell landing in either - * takes the UTC calendar day or UTC clock of the instant it names. + * taken verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`, a `time` + * keeping a non-zero fraction), which is both zone-free and host-`TZ`-free. An + * offset-bearing cell landing in either takes the UTC calendar day or UTC + * clock of the instant it names. */ export function parseDateCell( raw: unknown, @@ -503,9 +546,15 @@ export function parseDateCell( const s = String(raw).trim(); if (s === '') return undefined; - if (kind === 'time' && TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s; + if (kind === 'time') { + const clock = readTimeOfDayCell(s); + if (clock !== undefined) return clock; + } - const cell = readIsoTemporalCell(s) ?? readYearFirstCell(s); + // [#20722] Core's rule is the whole verdict on an ISO 8601 `time` cell, so a + // `time` cell it refuses is read in the year-first form alone — the one + // reading the import has beyond the write door (#20534, maintainer ruling). + const cell = (kind === 'time' ? undefined : readIsoTemporalCell(s)) ?? readYearFirstCell(s); if (!cell) return undefined; if (cell.wall) { @@ -593,9 +642,11 @@ export async function coerceFieldValue( // The business timezone an offset-free datetime cell is read in (#8485). const d = parseDateCell(raw, t, ctx.timezone); if (d === undefined) { - // One code, three sentences — a `time` cell is not "not a valid date". + // [#20722] The write door's code for the same cell — `invalid_time` for a + // `time`, `invalid_date` for a `date` / `datetime` — and three sentences. + const code = t === 'time' ? 'invalid_time' : 'invalid_date'; const key = t === 'datetime' ? 'import_invalid_datetime' : t === 'time' ? 'import_invalid_time' : 'import_invalid_date'; - return coerceError(meta, field, 'invalid_date', key, raw, ctx); + return coerceError(meta, field, code, key, raw, ctx); } return { value: d }; } diff --git a/packages/rest/src/import-date-cell-iso-real-day.test.ts b/packages/rest/src/import-date-cell-iso-real-day.test.ts index 0f264bde917..1cee021d483 100644 --- a/packages/rest/src/import-date-cell-iso-real-day.test.ts +++ b/packages/rest/src/import-date-cell-iso-real-day.test.ts @@ -76,6 +76,9 @@ const REFUSED: ReadonlyArray = [ ['d', '2026/2/30'], ]; +/** [#20722] The write door's field code for a refused cell: `invalid_time` for a `time`. */ +const codeOf = (field: Field) => (field === 't' ? 'invalid_time' : 'invalid_date'); + /** Admitted cells and what they store — the padding, the ISO control and the export shape. */ const ADMITTED: ReadonlyArray = [ ['d', '0500-01-01', '0500-01-01'], @@ -180,7 +183,7 @@ describe.each(HOST_ZONES)('[#20534] /import date cells, host TZ=$tz', ({ tz, jul else process.env.TZ = originalTz; }); - it.each(REFUSED)('refuses the %s cell %j as that row\'s invalid_date and writes its sibling row', async (field, cell) => { + 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) => { const res = await ctx.importRows({ format: 'json', writeMode: 'insert', 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 expect(res._status ?? 200).toBe(200); expect(res._json).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 }); expect(res._json.results[0]).toMatchObject({ - row: 1, ok: false, action: 'failed', field, code: 'invalid_date', + row: 1, ok: false, action: 'failed', field, code: codeOf(field), }); // Refused, not stored as some other day or instant. 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 expect(res._json).toMatchObject({ total: REFUSED.length, ok: 0, errors: REFUSED.length }); const failedFields = res._json.results.map((r: any) => [r.field, r.code]); - expect(failedFields).toEqual(REFUSED.map(([field]) => [field, 'invalid_date'])); + expect(failedFields).toEqual(REFUSED.map(([field]) => [field, codeOf(field)])); }); 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 expect(res._json).toMatchObject({ dryRun: true, total: REFUSED.length, ok: 0, errors: REFUSED.length }); for (const [i, r] of res._json.results.entries()) { - expect(r).toMatchObject({ ok: false, field: REFUSED[i][0], code: 'invalid_date' }); + expect(r).toMatchObject({ ok: false, field: REFUSED[i][0], code: codeOf(REFUSED[i][0]) }); } for (const [i] of REFUSED.entries()) { expect(await ctx.engine.findOne(OBJECT, { where: { id: `d${i}` } })).toBeNull(); diff --git a/packages/rest/src/import-time-cell-fraction.test.ts b/packages/rest/src/import-time-cell-fraction.test.ts new file mode 100644 index 00000000000..59c1ea645ba --- /dev/null +++ b/packages/rest/src/import-time-cell-fraction.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20722] `POST /api/v1/data/:object/import` reads a `time` cell by + * `@objectstack/core`'s one `time` rule, the rule the write door asks since + * #20671. So the cell `/export` writes for a `time` with milliseconds + * (`10:00:00.250`) re-imports as itself, and the two doors agree cell for cell: + * a fraction is admitted on both, and a `Z` / offset suffix on a time of day is + * refused on both with the same field code, `invalid_time`. + * + * MEASURED_TABLE + * + * ## What is pinned here + * + * - **the reader** (`parseDateCell(cell, 'time')`) on two host zones twelve + * hours apart; + * - **the two doors, cell for cell**: each cell is written through + * `POST /api/v1/data/:object` and imported through + * `POST /api/v1/data/:object/import`, and both answer the same, a stored + * value read back through the engine or a refusal with field code + * `invalid_time`. The year-first date-time (`2026/7/15 9:00`) is the one + * reading the import has that the write door has not (the maintainer ruling + * on #20534), and it is pinned as that; + * - **export → import**: rows written through the create door with + * `10:00:00.250`, `23:59:59.999` and the `10:00:00` control, exported as CSV + * and JSON, and re-imported, store what they were exported from. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL cell runs where + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. It owns one + * table, dropped before and after. An in-memory cell is not here: + * `@objectstack/driver-memory` has no binding in this package, and a new one + * is a census decision (`scripts/driver-memory-census.ledger.json`), not a + * test's. The import's verdict is formed by its own reader before any driver + * is reached, and the memory driver's write half is pinned in + * `packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import { parseDateCell } from './import-coerce.js'; + +const OBJECT = 'import_time_fraction_20722'; +const HOST_ZONE = 'America/New_York'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20722', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + t: { name: 't', type: 'time' as const, label: 'Clock' }, + }, +}; + +const MAPPING = { ID: 'id', Clock: 't' }; + +/** A cell both doors admit, and the wall clock both store. */ +const ADMITTED: ReadonlyArray = [ + // The card: what `/export` writes for a `time` with milliseconds. + ['10:00:00.250', '10:00:00.250'], + ['23:59:59.999', '23:59:59.999'], + ['10:00:00.5', '10:00:00.500'], + // The controls, which already round-tripped. + ['10:00:00', '10:00:00'], + ['10:00', '10:00:00'], + // A zero fraction is the form without one. + ['10:00:00.000', '10:00:00'], + // An ISO instant keeps its UTC time of day, fraction included (ADR-0053 D-C1). + ['2026-07-15T10:00:00.250Z', '10:00:00.250'], + ['2026-07-15T18:00:00+08:00', '10:00:00'], + ['2026-07-15 10:00:00.250', '10:00:00.250'], +]; + +/** A cell both doors refuse, with field code `invalid_time`. */ +const REFUSED: readonly string[] = [ + // A time of day carries no zone (#20671). + '10:00Z', + '10:00+08:00', + '10:00:00.250Z', + // An instant whose UTC year has no four-digit spelling. + '9999-12-31T23:00:00-02:00', + '+010000-01-01T10:00:00Z', + // Out of range, a one-digit hour, a month-first date-time. + '25:00', + '9:00', + '07/15/2026 10:00', +]; + +// --------------------------------------------------------------------------- +// The reader, on two hosts. +// --------------------------------------------------------------------------- + +const originalTz = process.env.TZ; +afterEach(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +describe.each([HOST_ZONE, 'Asia/Shanghai'])('[#20722] parseDateCell(cell, "time") on a %s host', (host) => { + it.each(ADMITTED)('reads %j as %j', (cell, stored) => { + process.env.TZ = host; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); + expect(parseDateCell(cell, 'time')).toBe(stored); + }); + + it.each(REFUSED)('refuses %j', (cell) => { + process.env.TZ = host; + expect(parseDateCell(cell, 'time')).toBeUndefined(); + }); + + it('reads the year-first date-time, the import\'s own reading (#20534), as its wall clock', () => { + process.env.TZ = host; + expect(parseDateCell('2026/7/15 9:00', 'time')).toBe('09:00:00'); + }); +}); + +// --------------------------------------------------------------------------- +// The routes, over a real engine. +// --------------------------------------------------------------------------- + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const chunks: string[] = []; + const res: any = { + write: (c: unknown) => { chunks.push(Buffer.isBuffer(c) ? c.toString('utf8') : String(c)); return true; }, + end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + res._text = () => chunks.join(''); + return res; +} + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#20722] /import reads a time cell as the write door does — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let engine: ObjectQL; + let driver: any; + let send: (method: string, path: string, req: Record) => Promise; + const stored = async (id: string) => (await engine.findOne(OBJECT, { where: { id } }))?.t; + const create = (row: Record) => send('POST', '/api/v1/data/:object', { body: row }); + const importBody = async (body: Record) => + (await send('POST', '/api/v1/data/:object/import', { body: { writeMode: 'insert', ...body } }))._json; + + beforeAll(async () => { + // A host whose zone is not UTC, so a host-zone reading would show. + process.env.TZ = HOST_ZONE; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE); + + driver = new SqlDriver(config as any); + if (cell.id !== 'sqlite') await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + send = async (method, path, req) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path) as any; + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route.handler({ params: { object: OBJECT }, query: {}, headers: {}, ...req } as any, res); + return res; + }; + }); + + afterAll(async () => { + if (cell.id !== 'sqlite') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + try { await engine?.destroy(); } catch { /* noop */ } + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; + }); + + it.each(ADMITTED.map(([c, s], i) => [c, s, i] as const))('stores %j as %j through both doors', async (value, want, i) => { + const created = await create({ id: `w${i}`, t: value }); + expect(created._status ?? 201, `create ${value}: ${JSON.stringify(created._json)}`).toBe(201); + const summary = await importBody({ format: 'json', rows: [{ id: `i${i}`, t: value }] }); + expect(summary, `import ${value}`).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 }); + expect(await stored(`w${i}`), `write door ${value}`).toBe(want); + expect(await stored(`i${i}`), `import ${value}`).toBe(want); + }); + + it.each(REFUSED.map((c, i) => [c, i] as const))('refuses %j with invalid_time through both doors, and writes nothing', async (value, i) => { + const created = await create({ id: `rw${i}`, t: value }); + expect(created._status, `create ${value}`).toBe(400); + expect(created._json).toMatchObject({ code: 'VALIDATION_FAILED' }); + const doorCodes = created._json.fields.map((f: any) => [f.field, f.code]); + expect(doorCodes, `create ${value}`).toEqual([['t', 'invalid_time']]); + + const summary = await importBody({ format: 'json', rows: [{ id: `ri${i}`, t: value }, { id: `rg${i}`, t: '10:00' }] }); + expect(summary, `import ${value}`).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 }); + expect(summary.results[0]).toMatchObject({ row: 1, ok: false, action: 'failed', field: 't' }); + // The code the write door gave for the same cell. + expect([[summary.results[0].field, summary.results[0].code]], `import ${value}`).toEqual(doorCodes); + expect(await engine.findOne(OBJECT, { where: { id: `rw${i}` } })).toBeNull(); + expect(await engine.findOne(OBJECT, { where: { id: `ri${i}` } })).toBeNull(); + expect(await stored(`rg${i}`), 'the sibling row is written').toBe('10:00:00'); + }); + + it('dry run predicts the same verdicts and persists nothing', async () => { + const rows = [ + ...ADMITTED.map(([value], i) => ({ id: `dA${i}`, t: value })), + ...REFUSED.map((value, i) => ({ id: `dR${i}`, t: value })), + ]; + const summary = await importBody({ format: 'json', dryRun: true, rows }); + expect(summary).toMatchObject({ dryRun: true, total: rows.length, ok: ADMITTED.length, errors: REFUSED.length }); + expect(summary.results.slice(ADMITTED.length).map((r: any) => [r.field, r.code])).toEqual(REFUSED.map(() => ['t', 'invalid_time'])); + for (const row of rows) expect(await engine.findOne(OBJECT, { where: { id: row.id } })).toBeNull(); + }); + + it('reads the year-first date-time (#20534) as its wall clock, which the write door refuses', async () => { + const created = await create({ id: 'yw', t: '2026/7/15 9:00' }); + expect(created._status).toBe(400); + expect(created._json.fields.map((f: any) => [f.field, f.code])).toEqual([['t', 'invalid_time']]); + const summary = await importBody({ format: 'json', rows: [{ id: 'yi', t: '2026/7/15 9:00' }] }); + expect(summary).toMatchObject({ total: 1, ok: 1, errors: 0 }); + expect(await stored('yi')).toBe('09:00:00'); + }); + + describe.each(['csv', 'json'] as const)('GET /export then POST /import, format %s', (format) => { + const SOURCE = [ + { id: `x-${format}-ms`, t: '10:00:00.250' }, + { id: `x-${format}-max`, t: '23:59:59.999' }, + { id: `x-${format}-control`, t: '10:00:00' }, + ]; + + it('re-imports every exported row as the wall clock it was exported from', async () => { + for (const row of SOURCE) { + const created = await create(row); + expect(created._status ?? 201, `create ${row.id}`).toBe(201); + } + const exported = await send('GET', '/api/v1/data/:object/export', { query: { format } }); + expect(exported._status ?? 200).toBe(200); + + const ids = new Set(SOURCE.map((r) => r.id)); + const back = (id: string) => id.replace(/^x-/, 'back-'); + let body: Record; + if (format === 'json') { + const rows = (JSON.parse(exported._text()) as Array>).filter((r) => ids.has(String(r.id))); + // The export writes each wall clock as stored, fraction included. + expect(Object.fromEntries(rows.map((r) => [r.id, r.t]))).toEqual(Object.fromEntries(SOURCE.map((r) => [r.id, r.t]))); + body = { format, rows: rows.map((r) => ({ ...r, id: back(String(r.id)) })) }; + } else { + const [header, ...lines] = exported._text().split('\r\n').filter((l: string) => l.length > 0); + expect(header).toBe('ID,Clock'); + const mine = lines.filter((l: string) => ids.has(l.split(',')[0])); + expect([...mine].sort()).toEqual(SOURCE.map((r) => `${r.id},${r.t}`).sort()); + body = { format, csv: [header, ...mine.map((l: string) => l.replace(/^x-/, 'back-'))].join('\r\n'), mapping: MAPPING }; + } + + const summary = await importBody(body); + expect(summary, JSON.stringify(summary?.results)).toMatchObject({ total: SOURCE.length, ok: SOURCE.length, errors: 0 }); + for (const row of SOURCE) { + expect(await stored(row.id), `source ${row.id}`).toBe(row.t); + expect(await stored(back(row.id)), `re-imported ${row.id}`).toBe(row.t); + } + }); + }); + }, + ); +} From bb97fdc011e8a86f7d25e63b1b860399d22928db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:18:42 +0000 Subject: [PATCH 2/4] test(rest): record the base-vs-now readings of the /import time pin on memory, SQLite and PostgreSQL Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../src/import-time-cell-fraction.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/import-time-cell-fraction.test.ts b/packages/rest/src/import-time-cell-fraction.test.ts index 59c1ea645ba..e272a8e5433 100644 --- a/packages/rest/src/import-time-cell-fraction.test.ts +++ b/packages/rest/src/import-time-cell-fraction.test.ts @@ -8,7 +8,24 @@ * a fraction is admitted on both, and a `Z` / offset suffix on a time of day is * refused on both with the same field code, `invalid_time`. * - * MEASURED_TABLE + * Measured through the routes at the base (`96e72447`) and after, the process + * in America/New_York, on InMemoryDriver, SqlDriver over SQLite and SqlDriver + * over PostgreSQL 16.13 at `Asia/Shanghai` — the three answered alike on every + * row. The write door's column is unchanged by this fix. + * + * | `time` value | write door | `/import`, base | `/import`, now | + * |:--|:--|:--|:--| + * | `10:00:00.250`, `23:59:59.999` | 201, as written | row failed, `invalid_date` | 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`, the fraction dropped | `10:00:00.250` | + * | `9999-12-31T23:00:00-02:00` | 400 `invalid_time` | `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 of `10:00:00.250`, `23:59:59.999` (CSV, JSON) | — | both rows failed | as exported | + * | export → import of the `10:00:00` control | — | as exported | unchanged | + * + * The InMemoryDriver column came from a copy of this file with that driver in + * place of `SqlDriver`, not committed (see the dialect axis below). * * ## What is pinned here * From 0e624ca2bf5d05f28f52cc307e311c682b7a234e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:54:06 +0000 Subject: [PATCH 3/4] chore(runtime): classify the import's invalid_time stamp in the dispatcher error vocabulary import-coerce.ts now refuses a time cell with the write door's field code, invalid_time, through coerceError; the table carries the same foreign-vocabulary row its invalid_date twin has (ADR-0114 field catalog, never error.code). Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../runtime/src/dispatcher-error-vocabulary.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 9535357d0ff..bad0e1e0899 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -1031,6 +1031,20 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row ' + 'can be owed for it (ADR-0112 D6).', }, + { + code: 'invalid_time', + file: 'packages/rest/src/import-coerce.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'import-coerce\'s `coerceError(meta, field, code: FieldErrorCode, …)` builds the per-cell ' + + '`FieldCoerceError` an import row reports. Its `code` parameter is typed `code: FieldErrorCode`, ' + + 'so the value is a member of the closed ADR-0114 D2 catalog by construction; \'invalid_time\' is ' + + 'one of them — a refused `time` cell takes the code the write door gives the same value. It ' + + 'reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be owed for ' + + 'it (ADR-0112 D6).', + }, { code: 'invalid_number', file: 'packages/rest/src/import-coerce.ts', From 99eb702d917ffbf0c2a58e91d60e8aa59436b86d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:42:38 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(rest)!:=20declare=20the=20/import=20tim?= =?UTF-8?q?e=20arm's=20narrowing=20in=20its=20changeset=20=E2=80=94=20mino?= =?UTF-8?q?r,=20Clause-=E2=91=A1=20no=20(narrowing),=20BREAKING=20banner?= =?UTF-8?q?=20and=20ADR-0087=20disposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader now refuses a time cell that is an ISO instant with no four-digit UTC year, as the write door does, and a refused time cell's row code moves from invalid_date to invalid_time. The changeset states both, ships as minor under the launch-window convention, and records that the import keeps a non-zero fraction where #20671's entry said HH:MM:SS. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20722-import-time-fraction.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.changeset/20722-import-time-fraction.md b/.changeset/20722-import-time-fraction.md index 124a7528624..e598023ef09 100644 --- a/.changeset/20722-import-time-fraction.md +++ b/.changeset/20722-import-time-fraction.md @@ -1,12 +1,18 @@ --- -"@objectstack/rest": patch +"@objectstack/rest": minor --- -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` (#20722) +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) + +Clause-②: no (narrowing) + + + +**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). 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. -Through the route, the process in America/New_York, SQLite and PostgreSQL 16 (at `Asia/Shanghai`), before and after: +Through the route, the process in America/New_York, on memory, SQLite and PostgreSQL 16 (at `Asia/Shanghai`), before and after: | `time` cell | before | now | |:--|:--|:--| @@ -18,4 +24,8 @@ Through the route, the process in America/New_York, SQLite and PostgreSQL 16 (at | `10:00`, `10:00:00`, `2026-07-15T18:00:00+08:00` | `10:00:00` | unchanged | | `2026/7/15 9:00` (the year-first form) | `09:00:00` | unchanged | -A refused `time` cell now reports the field code the write door gives for the same value, `invalid_time`, where it reported `invalid_date`; the row's sentence is unchanged. A `date` or `datetime` cell keeps `invalid_date`. 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. +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. + +**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. + +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`).