Commit eb4b17c
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
- .changeset
- packages/rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
102 | 215 | | |
103 | 216 | | |
104 | 217 | | |
| |||
0 commit comments