Skip to content

Commit eb4b17c

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest)!: /import reads a date, datetime or time cell only in ISO 8601, the export shape or a year-first date, on a real day, with a four-digit year (#20534) (#20601)
Fixes #20534 Clause-②: no ## What changes `parseDateCell` in `packages/rest/src/import-coerce.ts` is the reader behind `POST /api/v1/data/:object/import` for `date`, `datetime` and `time` cells. It had four faults: - it rolled an impossible day into the next month; - it passed every cell it could not read itself to `new Date(s)`, which reads the cell in the server process's zone and reads `07/08/2026` month-first; - it spelled a `date` year below 1000 without padding; - it read a bare day into a `datetime` through `Date.UTC(y, …)`, which puts years 0..99 in the 1900s. This PR carries out triage's answer **A** (`5883900872`). The maintainer ruling on the card (`5885066497`) supersedes A on one point: year-first dates stay admitted. **The set that is read.** After trimming, a text cell is read only in these shapes: - ISO 8601 and the export shape: - `YYYY-MM-DD`; - `YYYY-MM-DDTHH:MM[:SS[.f]]`, then `Z`, `±HH:MM`, `±HHMM` or nothing; - `YYYY-MM-DD HH:MM[:SS[.f]]` with no zone, the export shape. xlsx date cells reach the reader in this shape. - These are the spellings the write door admits for a `datetime` string since #20525. - A year-first date, per the ruling: - `YYYY/M/D` or `YYYY-M-D`: a four-digit year, a one- or two-digit month and day, and the same separator in both places; - optionally followed by one space and `H:MM` or `H:MM:SS`, with a one- or two-digit hour; - no zone, no fraction and no `T` in this form. It is one closed grammar, `YEAR_FIRST_CELL`, beside `ISO_TEMPORAL_CELL`. - For a `time` cell, also a bare `HH:MM[:SS]`. **The rules every admitted cell keeps:** - **Real day.** The day must exist (`namesRealCalendarDay`, arithmetic, never a `Date` round trip). `2026-02-30` and `2026/2/30` are refused and never rolled over. - **Clock range.** On a zone-naive clock the hour runs 0..23 and the minute and second 00..59, so `24:00` is refused. `T24:00Z` names its instant and reads as before. - **Refusals.** Everything else is refused per row as `invalid_date`, with the existing `import_invalid_date` / `import_invalid_datetime` / `import_invalid_time` sentences. There is no new error code and no date-format option. Nothing is read in the host zone, and no field order is guessed: `07/15/2026`, `15/07/2026` and `26/7/15` stay refused. - **Four-digit year on every `date` branch.** - A text cell keeps the four digits it was written with, and a year-first day is stored padded (`2026/7/15` → `2026-07-15`, `0500/1/1` → `0500-01-01`). - An instant (a `Date`, or a zone-bearing cell) takes core's `temporalStorageForm` `date` rule, imported from `@objectstack/core`. - **Bare day into a `datetime`.** It is spelled from the day itself (`…T00:00:00.000Z`), for ISO and year-first alike, so `0001-01-01` is stored in year 1, not 1901. - **Year-first clocks.** A clock is a wall clock read exactly as the export shape's is: through core's `zonedWallClockToUtcMs` in the business zone for a `datetime`, verbatim for a `time` or a `date`. - **Unchanged.** A zone-naive ISO or export-shape cell is still read in the business timezone (#8485), and an offset-bearing cell is still honoured as written. One source file changes: `parseDateCell`, its docblock, and the private helpers beside it: `ISO_TEMPORAL_CELL`, `YEAR_FIRST_CELL`, `namesRealCalendarDay`, `readIsoTemporalCell`, `readYearFirstCell` and `utcClock`. `NAIVE_DATE_TIME` and `parseNaiveWallClock` are replaced. ## PM hypotheses, measured ### H0 holds Measured through the real `/import` route, JSON rows, no business timezone. The runs used `InMemoryDriver` and `SqlDriver` (better-sqlite3) under `TZ=America/New_York` and `TZ=Asia/Shanghai`, at base `f11b5f20a2`; the reader is byte-identical on today's `main` (`3a89d459af`). Memory and SQLite gave the same answer on every cell, at base and at head. | cell | kind | base, New York | base, Shanghai | head, both zones, both drivers | |:--|:--|:--|:--|:--| | `2026-02-30` | datetime | `2026-03-02T00:00:00.000Z` | same | refused `invalid_date` | | `2026-02-30 10:00` | datetime | `2026-03-02T10:00:00.000Z` | same | refused | | `2026-02-30T10:00:00Z` | datetime | `2026-03-02T10:00:00.000Z` | same | refused | | `2026-02-30T10:00:00Z` | date | `2026-03-02` | same | refused | | `07/15/2026 10:00` | datetime | `2026-07-15T14:00:00.000Z` | `2026-07-15T02:00:00.000Z` | refused | | `07/08/2026` | datetime | `2026-07-08T04:00:00.000Z` | `2026-07-07T16:00:00.000Z` | refused | | `07/15/2026`, `15 July 2026` | date | `2026-07-15` | `2026-07-14` | refused | | `07/15/2026 10:00` | time | `14:00:00` | `02:00:00` | refused | | `2026-07-15 24:00` | datetime | `2026-07-16T04:00:00.000Z` | `2026-07-15T16:00:00.000Z` | refused | | `0500-01-01`, `0001-01-01`, `0999-12-31` | date | refused (`500-01-01` reached the write door) | same | stored `0500-01-01`, `0001-01-01`, `0999-12-31` | | `2026/7/15`, `2026/07/15`, `2026-7-15` | date | `2026-07-15` | same | `2026-07-15` (unchanged) | | `2026/7/15 9:00` | datetime | `2026-07-15T09:00:00.000Z` | same | unchanged, and equal to what `2026-07-15 09:00:00` stores | | `2026/2/30` | date | refused by the write door (`2026-02-30` reached it) | same | refused by the reader | | `2026-07-15`, `2026-07-15T10:00:00Z`, `2026-07-15 10:00:00`, `2026-07-15T10:00:00+08:00` | both | unchanged | unchanged | unchanged | The write door takes `0500-01-01`: `POST /api/v1/data/:object` answers `201` and stores it as written. The import now stores the same value, and a pin asserts they agree. ### H1 holds: the census, `main` against head The census called `parseDateCell` directly. The old reader is `main`'s `import-coerce.ts` at `3a89d459af`, byte-identical to the base. The new reader is this head's `src`. It covered 111 shapes, the 3 kinds, 2 host zones and 2 business-zone settings (none, and `Asia/Shanghai`), 666 rows. That is the first round's 98 shapes (588 rows) plus 13 year-first edges. | | all 666 rows | the original 588 rows | |:--|:--|:--| | refused → admitted | **0** | **0** | | admitted → refused | 240 | 198 | | admitted, value changed | 42 | 36 | | rows whose answer differs by host zone: `main` → head | 114 → 0 | 100 → 0 | - **Admitted → refused.** Each is one of these: an impossible day, a locale or prose spelling, a reduced or expanded form, a zone after a space, lower-case `t`/`z`, a zone-naive `24:00`, a number, or a year-first date outside its one form. - The year-first forms `main` admitted that are now refused, each by the ruling: - `2026/2/30` (impossible day); - `2026/7-15` (mixed separator); - `2026/7/15 24:00`; - `2026/7/15T9:00` (`T`); - `2026/7/15 9:00Z` (zone); - `2026/7/15 9:00:00.5` and `2026/07/15 10:00:00.123` (fraction). - **Value changes, 42.** - 30 are the four-digit year on a `date` (`500-01-01` → `0500-01-01`, including `0500/1/1` and a `Date` of year 500), or the 1900s fix for a bare day into a `datetime` (`0050-01-01` was `1950-01-01T00:00:00.000Z`). - **12 are one named exception:** a year-first date with no clock, given to a `time` field. The cells are `2026/7/15`, `2026/07/15`, `2026-7-15`, `2026-07-5`, `2028/2/29` and `0500/1/1`, under both business-zone settings. - `main` read these through `new Date(s)` in the host zone: `04:00:00` in New York, `16:00:00` in Shanghai, and `04:56:02` / `15:54:17` for year 500. No host-independent reading can equal a value that differs by host. - Head reads them as a bare ISO day into a `time` field is read on `main` and on head alike: `00:00:00`. - **Year-first forms `main` admits.** Every one stores the same value at head, apart from the padding, the real-day refusals, the ruled refusals above and the `time` exception. ### H2: the year pad, and where each rule comes from - **Year pad: imported.** Instant-derived `date` branches call `@objectstack/core`'s `temporalStorageForm(…, 'date')`. ISO text branches never turn the year into a number; the grammar requires four digits and the cell's own digits are kept. The year-first branch pads month and day and keeps the four-digit year. - **Calendar check: mirrored.** The write door's `namesRealCalendarDay` is private to `record-validator.ts`, and `packages/objectql` is read-only for this claim. It is copied here, word for word in its arithmetic, as one private helper that both readers share. ### H3 holds, in the direction expected, in both rounds **Round 1: removing the refusals.** The ablation restored the `new Date(s)` fallback for every cell the reader refuses, at head `9b31e7bc76`. - The mutation landed: anchor 1 → 0, blob `5d87c0ac825f` → `f4cfe0c42335`. - Result: `64 failed | 171 passed (235)`. - Red: exactly the refusal assertions. Green: every control. - Restored: blob equals HEAD, and `git diff HEAD` is empty. **Round 2: removing the year-first branch.** The anchor `readIsoTemporalCell(s) ?? readYearFirstCell(s);` became `readIsoTemporalCell(s);`, via `scripts/ablation-replace.mjs` in wrap mode at head `279ca425fa`. - **The mutation landed.** Anchor 1 → 0, and blob `4d5fb1969259` → `d8a032952d0a`. - **Result.** `Tests 22 failed | 237 passed (259)`. - **Red: exactly the year-first admissions, 22 tests.** - The unit table's 11 year-first rows. - The 5 restored fixtures and assertions: - the `2026/6/3` line; - the `coerceRow` fixture; - the business-timezone `2026/08/01 06:00:00` line; - the integration CSV cell; - the integration xlsx text cell. - The route file's 2 year-first admission pins and its same-instant pin, under each host zone. - **Green: every refusal and every other control.** - All 42 unit refusals, including the 10 year-first edges. - The route file's `2026/2/30` refusal pins. - **Restore proven.** The blob after restore equals HEAD (`4d5fb1969259`), and `git diff HEAD` is empty. - **No build needed.** The pins reach `import-coerce.ts` through relative imports, never a `dist/`. ## Tests - **`packages/rest/src/import-date-cell-iso-real-day.test.ts`** (new). It uses the real `/import` route over `SqlDriver` (better-sqlite3 `:memory:`), under `TZ=America/New_York` and `TZ=Asia/Shanghai`. The zone switch is asserted with `Intl` and with the July offset. There are 28 cases per zone: - each of 12 refused rows is refused as its own row's `invalid_date`, a sibling row is still written, and nothing is stored for the refused row. The rows are the card's 11 plus `2026/2/30`. - 10 admitted cells store their value: - `0500-01-01`, `0001-01-01`, and a datetime `0001-01-01`; - the 2026 ISO, offset and export-shape controls; - a `date` `2026/7/15` stored as `2026-07-15`; - a `datetime` `2026/7/15 9:00`. - a year-first `2026/7/15 9:00` stores the same instant as `2026-07-15 09:00:00`; - an imported padded year agrees with what the create door stores; - a real export → import round trip through `GET /export`; - an xlsx date cell reads as before; - quoted CSV cells get the same verdicts; - the dry run predicts the refusals and persists nothing. - **`packages/rest/src/import-coerce.test.ts`.** The `[#20534]` table has 42 refused and 36 admitted cases, each asserted equal under both host zones. - The year-first rows are admission rows. - The refused edges are `2026/2/30`, `2026/7-15`, `2026/7/15 24:00`, `2026/7/15 9:60`, `2026/7/15T9:00`, `2026/7/15 9:00Z`, `2026/7/15 9:00:00.5`, `26/7/15` and `07/15/2026`. - **Fixtures restored to `main`'s spelling.** - The `coerceRow` fixture `due: '2026/07/01'`. - The `import-integration.test.ts` CSV cell `2026/06/30` and xlsx text cell `'2026/07/01'`. The xlsx row now also asserts its stored `due`, `2026-07-01`. - The assertions `parseDateCell('2026/6/3', 'date')` → `2026-06-03`, and the business-timezone `2026/08/01 06:00:00` → `CROSS_MONTH_UTC`. `import-business-timezone.test.ts` is byte-identical to `main` again. - **Package tests.** At `3b2e47349e`, `pnpm --filter @objectstack/rest test --maxWorkers=2` gave `Test Files 227 passed (227)` and `Tests 4382 passed | 50 skipped (4432)`. `test:repo` gave `1 passed (1)`, `8 passed (8)`. - **Typecheck.** `pnpm --filter @objectstack/rest typecheck` exits 0: `tsc --noEmit`, then `check:test-typecheck: OK`. - **Memory leg.** It is measured on base and head, not pinned (see Acceptance notes). Year-first cells were added: `2026/7/15`, `2026/7/15 9:00`, `2026/2/30`, `2026/7-15` and `2026-07-15 09:00:00`. Memory and SQLite agree on all 46 cells under both zones, and 0 cells differ between zones. ## Gates, at `3b2e47349e` - **Merges.** `origin/main` was merged twice this round: at `7a1faf1a5d`, then at `3a89d459af` (a version-packages commit and four others landed in between). - **Build.** `turbo run build` over all of `./packages/*` and `./packages/*/*`: 71/71 tasks. The tree was clean afterwards. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 61 commands. All 61 ran with exit 0, after the last commit. - Reconciled with `--ran`: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`. - **Roster rows that could apply**, all exit 0: - `check-changeset-fixed`; - `check:authz-resolver`, `check:filter-alias-parity`, `check:error-status-conformance`; - `check:route-ledger-census`, `check:tenant-chokepoint`; - `check-published-list-mirrors`, `check:published-readme-exports`; - `check:select-shard-packages`, `check:select-gate-families`. - `check-single-claim-paths` ran as a read with `PR_NUMBER=20601`: `PR #20601 modifies none of the 1 declared at-most-one-writer path(s)`. It read the PR's file list from GitHub. - **Other gates, exit 0.** - `pnpm lint`: the whole repository, no narrowing. - `node scripts/check-issue-citations.mjs --base origin/main`: 7 citations, all resolve. - **Changeset gates.** `check-adr-0087-registration` names the changeset `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. `check-changeset-no-major` reports no `major`. `check-empty-changeset` passes. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 --output-logs=errors-only pnpm --filter @objectstack/rest test --maxWorkers=2 pnpm --filter @objectstack/rest test:repo --maxWorkers=2 pnpm --filter @objectstack/rest typecheck pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 (the touched test files; both ablations' wrapped runs; the base run) (The entry point printed this wording once for each command above. It is pasted once here, with every command it covered.) ## Changeset `.changeset/20534-import-date-cell-iso-real-day.md` is `@objectstack/rest` `minor`, with a line-initial `Clause-②: no (narrowing)`. - **BREAKING paragraph.** Each refused shape gets a FROM spelling and an admitted TO spelling. The year-first edges are listed: a mixed separator, a `T` or a zone, a fraction, and `2026/7/15 24:00`. - **Kept.** A "Kept: year-first dates" paragraph says that `2026/7/15`, Excel's default in zh-CN and ja-JP, stays admitted, now with the real-day check and stored padded. It also names the `time` exception. - **ADR-0087 disposition.** `not-required (no-migration-prescription)`. The shape follows PR #20517. The exported `coerceRow` narrows with the door, and the changeset says so. ## Acceptance notes - **The `InMemoryDriver` leg is measured, not pinned. This departs from triage's pin list, as PR #20517 did.** - `@objectstack/driver-memory`'s test consumers are a ruled, ledgered set (`pnpm check:driver-memory-census`), so the census is not widened here. - The cell is judged by the import's own reader before any driver is reached, and memory and SQLite agree on every measured cell (H0, and the Tests section). - **Year-first dates stay admitted, by the maintainer ruling (`5885066497`).** They are Excel's default in zh-CN and ja-JP. They now keep the rules every other admitted cell keeps, and they read the same as on `main` except for the ruled refusals and the `time` exception in the census. - **The `time` branch is covered too.** Its fallback was the same `new Date(s)`: `07/15/2026 10:00` into a `time` field was stored as `14:00:00` on a New York host and `02:00:00` on a Shanghai host. It is in `parseDateCell`, inside the claim's file surface, and it is the same host-zone reading triage ruled out. - **A zone-naive `24:00` is now refused, in both the ISO and the year-first form.** It used to fall through to `new Date(s)`, in the host zone. `2026-07-15T24:00:00Z` names its instant and reads as before. - **objectui's Import Wizard preview disagrees with the server on non-ISO, non-year-first dates.** - The preview judges a `date` or `datetime` cell with a bare `Date.parse` (`packages/plugin-grid/src/ImportWizard.tsx` `validateValue` in objectui), so it marks `07/15/2026` valid while the server refuses it. - The wizard's server-side "Validate data" dry run gives the server's verdict. - I read this in objectui's source and did not measure it through the UI. There is no carrier, so it is noted here only. - **The import-mappings doc could name the accepted spellings.** `content/docs/data-modeling/import-mappings.mdx` says date cells are "parsed to storage form" and lists no spellings. There is no carrier, so it is noted here only. - **Two year-below-1000 defects outside this claim's surface are filed, not fixed here.** - **#20602:** `/export` writes a year-500 row unpadded (`500-01-01`), so the export does not re-import. - **#20599:** core's `zonedWallClockToUtcMs` stores a zone-naive `0050-01-01 10:00:00` as `1950-01-01T10:00:00.000Z`. This PR leaves that reading as it was, for ISO and year-first cells alike: `packages/core/**` is read-only for this claim. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f6dcac commit eb4b17c

5 files changed

Lines changed: 681 additions & 70 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest): `POST /api/v1/data/:object/import` reads a `date`, `datetime` or `time` cell only in ISO 8601, the export's own `YYYY-MM-DD HH:mm:ss` or a year-first date (`2026/7/15`), on a calendar day that exists, and keeps a `date`'s year at four digits (#20534)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the import door.** A text cell for a `date`,
10+
`datetime` or `time` field is now read only in one of these spellings, after
11+
trimming:
12+
13+
- `YYYY-MM-DD`;
14+
- `YYYY-MM-DDTHH:MM[:SS[.fraction]]`, then `Z`, a `+HH:MM` / `-HH:MM` /
15+
`+HHMM` offset, or nothing (a wall clock, read in the importing user's
16+
business timezone, as before);
17+
- `YYYY-MM-DD HH:MM[:SS[.fraction]]` with no offset, which is what the export
18+
writes for a `datetime` cell;
19+
- a year-first date, `YYYY/M/D` or `YYYY-M-D` (a four-digit year, a one- or
20+
two-digit month and day, the same separator twice), optionally followed by
21+
one space and `H:MM` or `H:MM:SS` with no offset, read exactly as the
22+
export shape is;
23+
- for a `time` field, also a bare `HH:MM` / `HH:MM:SS`.
24+
25+
The day must exist. Every other cell is that row's `invalid_date` error, with
26+
the importer's existing sentence ("is not a valid date" / "datetime" /
27+
"time"). The reader used to hand such a cell to the JavaScript date parser,
28+
which read it in the SERVER PROCESS's timezone and month-first, and rolled an
29+
impossible day into the next month, so the import reported success and stored
30+
a different value. For each shape, change the cell FROM the refused spelling
31+
TO an admitted one:
32+
33+
- **An impossible day.** FROM `2026-02-30`, `2026-02-29`, `2026-04-31` in any
34+
spelling (a `datetime` `2026-02-30` was stored as 2 March, and so was a
35+
`date` written `2026-02-30T10:00:00Z`) TO the day you mean. Nothing is
36+
rolled over.
37+
- **A locale or prose date.** FROM `07/15/2026`, `07/15/2026 10:00`,
38+
`07/08/2026`, `15 July 2026`, `Jul 15 2026 10:00` (stored hours apart on a
39+
New York and a Shanghai server, a `date` a day apart, and `07/08/2026` read
40+
as 8 July) TO `2026-07-15`, `2026-07-15 10:00`, `2026-07-08` or
41+
`2026-08-07`. No timezone and no field order is guessed. Converting a
42+
spreadsheet column to ISO (in Excel, the cell format `yyyy-mm-dd` or
43+
`yyyy-mm-dd hh:mm:ss`) before export is the fix.
44+
- **A year-first date outside its one form.** FROM a mixed separator
45+
(`2026/7-15`) TO `2026/7/15` or `2026-07-15`. FROM a `T` or a zone on the
46+
year-first form (`2026/7/15T9:00`, `2026/7/15 9:00Z`) TO `2026/7/15 9:00`
47+
(a wall clock in the business timezone) or the ISO `2026-07-15T09:00:00Z`.
48+
FROM a fraction of a second (`2026/07/15 10:00:00.123`) TO
49+
`2026-07-15 10:00:00.123`. A two-digit year (`26/7/15`) is refused, as it
50+
was.
51+
- **A zone after a space, or lower-case `t` / `z`.** FROM
52+
`2026-07-15 10:00Z`, `2026-07-15 10:00:00+08:00`, `2026-07-15t10:00:00z` TO
53+
`2026-07-15T10:00Z`, `2026-07-15T10:00:00+08:00`, `2026-07-15T10:00:00Z`,
54+
the spellings the create and update doors take.
55+
- **A zone-naive `24:00`.** FROM `2026-07-15 24:00` or `2026/7/15 24:00` (read
56+
in the server's zone) TO `2026-07-16 00:00` or `2026/7/16 0:00`.
57+
`2026-07-15T24:00:00Z`, which names its instant, reads as before.
58+
- **A number, reduced or expanded forms.** FROM a JSON number such as `2026`
59+
or an Excel serial, `2026`, `2026-07`, `+002026-07-15` TO `2026-01-01`,
60+
`2026-07-01`, `2026-07-15`.
61+
62+
**Kept: year-first dates.** `2026/7/15`, `2026/07/15`, `2026-7-15`,
63+
`2026/7/15 9:00` and `2026/08/01 06:00:00`, Excel's default short date in
64+
zh-CN and ja-JP, stay admitted. They are now held to the same rules as every
65+
other cell: the day must exist (`2026/2/30` is refused, never rolled into
66+
March), the hour runs 0 to 23, and the day is stored in its padded ISO form
67+
(`2026/7/15` is stored as `2026-07-15`). A year-first date with no clock
68+
given to a `time` field reads as `00:00:00`, as an ISO day does; it used to
69+
read the server's zone (`04:00:00` on a New York server).
70+
71+
**The year keeps four digits.** A `date` cell for a year from 0001 to 0999
72+
(`0500-01-01`) used to leave the reader as `500-01-01`, which the write door
73+
refuses, so the import refused a day the create door takes. It is stored as
74+
written now. A bare day read into a `datetime` field in years 0001 to 0099
75+
(`0050-01-01`) used to be stored in the 1900s (`1950-01-01T00:00:00.000Z`); it
76+
is stored in its own year now.
77+
78+
**What is not affected.** Every ISO 8601 cell and every export-shape cell on
79+
a real day reads exactly as before, including a zone-naive cell read in the
80+
business timezone and an offset-bearing cell honoured as written. An xlsx
81+
cell that Excel stores as a date is unaffected: it reaches the reader as the
82+
export shape. The dry run answers the same verdicts as the real write. A
83+
refused cell fails only its own row, and the rest of the batch imports as
84+
before. The same narrowing applies to the exported `coerceRow` helper.
85+
86+
**If you are refused.** The row's result carries `code: 'invalid_date'` and
87+
quotes the cell, so the file can be corrected and re-imported.
88+
89+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. The import door's cell reader accepts fewer spellings of a date inside an imported file, which is data, not metadata, so `objectstack migrate meta` has nothing to reach. The row's invalid_date error quotes the refused cell, and the repair is to write the date in ISO 8601 or as a year-first date. -->

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

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* `coerceRow` driven by a fake reference resolver.
77
*/
88

9-
import { describe, it, expect } from 'vitest';
9+
import { describe, it, expect, afterEach } from 'vitest';
1010
import {
1111
parseBooleanCell,
1212
parseNumberCell,
@@ -99,6 +99,119 @@ describe('parseDateCell', () => {
9999
});
100100
});
101101

102+
/**
103+
* [#20534] A text cell is read only in ISO 8601, the export's own
104+
* `YYYY-MM-DD HH:mm:ss` or a year-first date (`2026/7/15`, `2026/7/15 9:00`,
105+
* 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
107+
* read in the host's zone and never read month-first. A `date`'s year keeps
108+
* four digits. Every case runs under two host zones that disagree by twelve
109+
* hours, and must answer the same under both — the host-zone reading this
110+
* removes answered differently (`07/15/2026 10:00` was `…T14:00Z` in New York
111+
* and `…T02:00Z` in Shanghai).
112+
*/
113+
describe('[#20534] parseDateCell — ISO 8601, the export shape or a year-first date, on a real day', () => {
114+
const HOST_ZONES = ['America/New_York', 'Asia/Shanghai'];
115+
const originalTz = process.env.TZ;
116+
afterEach(() => {
117+
if (originalTz === undefined) delete process.env.TZ;
118+
else process.env.TZ = originalTz;
119+
});
120+
121+
/** Run `fn` under each host zone; the answers must agree. */
122+
function onBothHosts(fn: () => string | undefined): string | undefined {
123+
const answers = HOST_ZONES.map((tz) => {
124+
process.env.TZ = tz;
125+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(tz);
126+
return fn();
127+
});
128+
expect(answers[1]).toBe(answers[0]);
129+
return answers[0];
130+
}
131+
132+
type Kind = 'date' | 'datetime' | 'time';
133+
134+
const REFUSED: ReadonlyArray<readonly [cell: unknown, kind: Kind]> = [
135+
// An impossible day, in every shape and on every branch — was rolled over.
136+
['2026-02-30', 'date'], ['2026-02-30', 'datetime'], ['2026-02-30', 'time'],
137+
['2026-02-29', 'datetime'], ['2026-04-31', 'datetime'],
138+
['2026-02-30 10:00', 'datetime'], ['2026-02-30 10:00', 'date'], ['2026-02-30 10:00', 'time'],
139+
['2026-02-30T10:00:00Z', 'datetime'], ['2026-02-30T10:00:00Z', 'date'], ['2026-02-30T10:00:00Z', 'time'],
140+
// Locale and prose spellings — were read in the host zone, and month-first.
141+
['07/15/2026 10:00', 'datetime'], ['07/15/2026 10:00', 'date'], ['07/15/2026 10:00', 'time'],
142+
['07/08/2026', 'datetime'], ['07/08/2026', 'date'],
143+
['07/15/2026', 'date'], ['15 July 2026', 'date'], ['15 July 2026', 'datetime'],
144+
['Jul 15 2026 10:00', 'time'], ['Wed, 15 Jul 2026 10:00:00 GMT', 'datetime'],
145+
// Year-first, outside its one form: an impossible day, a mixed separator,
146+
// a clock out of range, a `T`, a zone, a fraction, a two-digit year.
147+
['2026/2/30', 'date'], ['2026/2/30', 'datetime'], ['2026/7-15', 'date'],
148+
['2026/7/15 24:00', 'datetime'], ['2026/7/15 9:60', 'datetime'],
149+
['2026/7/15T9:00', 'datetime'], ['2026/7/15 9:00Z', 'datetime'],
150+
['2026/7/15 9:00:00.5', 'datetime'], ['26/7/15', 'date'], ['07/15/2026', 'datetime'],
151+
// Reduced / expanded forms, a zone after a space, lower-case `t` / `z`.
152+
['2026', 'date'], ['2026-07', 'datetime'], ['+002026-07-15', 'date'],
153+
['2026-07-15 10:00Z', 'datetime'], ['2026-07-15 10:00:00+08:00', 'datetime'],
154+
['2026-07-15t10:00:00z', 'datetime'],
155+
// A zone-naive 24:00 — was handed to `new Date(s)`, in the host zone.
156+
['2026-07-15 24:00', 'datetime'], ['2026-07-15T24:00:00', 'date'],
157+
// A number — `new Date(String(n))` read it as a year, in the host zone.
158+
[2026, 'date'], [45000, 'datetime'], [45000, 'time'],
159+
];
160+
161+
it.each(REFUSED)('refuses %j as a %s cell on every host', (cell, kind) => {
162+
expect(onBothHosts(() => parseDateCell(cell, kind))).toBeUndefined();
163+
});
164+
165+
const ADMITTED: ReadonlyArray<readonly [cell: unknown, kind: Kind, stored: string]> = [
166+
// ISO 8601 and the export shape keep their readings.
167+
['2026-07-15', 'date', '2026-07-15'],
168+
['2026-07-15', 'datetime', '2026-07-15T00:00:00.000Z'],
169+
['2028-02-29', 'date', '2028-02-29'],
170+
['2028-02-29T10:00:00Z', 'datetime', '2028-02-29T10:00:00.000Z'],
171+
['2026-07-15 10:00:00', 'datetime', '2026-07-15T10:00:00.000Z'],
172+
['2026-07-15 10:00:00', 'date', '2026-07-15'],
173+
['2026-07-15 10:00:00', 'time', '10:00:00'],
174+
['2026-07-15T10:00', 'datetime', '2026-07-15T10:00:00.000Z'],
175+
['2026-07-15 10:00:00.123', 'datetime', '2026-07-15T10:00:00.123Z'],
176+
['2026-07-15T10:00:00Z', 'datetime', '2026-07-15T10:00:00.000Z'],
177+
['2026-07-15T10:00:00+08:00', 'datetime', '2026-07-15T02:00:00.000Z'],
178+
['2026-07-15T10:00:00+0800', 'datetime', '2026-07-15T02:00:00.000Z'],
179+
['2026-07-15T02:00:00+08:00', 'date', '2026-07-14'],
180+
['2026-07-15T10:00:00+08:00', 'time', '02:00:00'],
181+
['2026-07-15T24:00:00Z', 'datetime', '2026-07-16T00:00:00.000Z'],
182+
[' 2026-07-15 ', 'date', '2026-07-15'],
183+
['10:00', 'time', '10:00:00'],
184+
// A year-first date (Excel's zh-CN / ja-JP short date): the padded ISO day,
185+
// and a clock read as a wall clock exactly as the export shape's is.
186+
['2026/6/3', 'date', '2026-06-03'],
187+
['2026/07/15', 'date', '2026-07-15'],
188+
['2026-7-15', 'date', '2026-07-15'],
189+
['2026/7/15', 'datetime', '2026-07-15T00:00:00.000Z'],
190+
['2026/7/15 9:00', 'datetime', '2026-07-15T09:00:00.000Z'],
191+
['2026/7/15 9:00', 'date', '2026-07-15'],
192+
['2026/7/15 9:00', 'time', '09:00:00'],
193+
['2026/08/01 06:00:00', 'datetime', '2026-08-01T06:00:00.000Z'],
194+
['2026-07-15 9:00', 'datetime', '2026-07-15T09:00:00.000Z'],
195+
['2028/2/29', 'date', '2028-02-29'],
196+
['0500/1/1', 'date', '0500-01-01'],
197+
// The year keeps four digits on every `date` branch.
198+
['0500-01-01', 'date', '0500-01-01'],
199+
['0001-01-01', 'date', '0001-01-01'],
200+
['0999-12-31 10:00:00', 'date', '0999-12-31'],
201+
['0050-01-01T10:00:00Z', 'date', '0050-01-01'],
202+
[new Date('0500-01-01T00:00:00Z'), 'date', '0500-01-01'],
203+
// A bare day into a `datetime` is spelled from the day, never `Date.UTC(y, …)`,
204+
// which read years 0..99 as 1900..1999 (`0001-01-01` was stored as 1901).
205+
['0001-01-01', 'datetime', '0001-01-01T00:00:00.000Z'],
206+
['0050-01-01', 'datetime', '0050-01-01T00:00:00.000Z'],
207+
['0500-01-01', 'datetime', '0500-01-01T00:00:00.000Z'],
208+
];
209+
210+
it.each(ADMITTED)('reads %j as a %s cell as %j on every host', (cell, kind, stored) => {
211+
expect(onBothHosts(() => parseDateCell(cell, kind))).toBe(stored);
212+
});
213+
});
214+
102215
describe('matchOption', () => {
103216
const options = [{ label: '高', value: 'high' }, { label: '低', value: 'low' }];
104217
it('matches by option value (code)', () => {

0 commit comments

Comments
 (0)