Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .changeset/20534-import-date-cell-iso-real-day.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
'@objectstack/rest': minor
---

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)

Clause-②: no (narrowing)

**BREAKING for callers of the import door.** A text cell for a `date`,
`datetime` or `time` field is now read only in one of these spellings, after
trimming:

- `YYYY-MM-DD`;
- `YYYY-MM-DDTHH:MM[:SS[.fraction]]`, then `Z`, a `+HH:MM` / `-HH:MM` /
`+HHMM` offset, or nothing (a wall clock, read in the importing user's
business timezone, as before);
- `YYYY-MM-DD HH:MM[:SS[.fraction]]` with no offset, which is what the export
writes for a `datetime` cell;
- a year-first date, `YYYY/M/D` or `YYYY-M-D` (a four-digit year, a one- or
two-digit month and day, the same separator twice), optionally followed by
one space and `H:MM` or `H:MM:SS` with no offset, read exactly as the
export shape is;
- for a `time` field, also a bare `HH:MM` / `HH:MM:SS`.

The day must exist. Every other cell is that row's `invalid_date` error, with
the importer's existing sentence ("is not a valid date" / "datetime" /
"time"). The reader used to hand such a cell to the JavaScript date parser,
which read it in the SERVER PROCESS's timezone and month-first, and rolled an
impossible day into the next month, so the import reported success and stored
a different value. For each shape, change the cell FROM the refused spelling
TO an admitted one:

- **An impossible day.** FROM `2026-02-30`, `2026-02-29`, `2026-04-31` in any
spelling (a `datetime` `2026-02-30` was stored as 2 March, and so was a
`date` written `2026-02-30T10:00:00Z`) TO the day you mean. Nothing is
rolled over.
- **A locale or prose date.** FROM `07/15/2026`, `07/15/2026 10:00`,
`07/08/2026`, `15 July 2026`, `Jul 15 2026 10:00` (stored hours apart on a
New York and a Shanghai server, a `date` a day apart, and `07/08/2026` read
as 8 July) TO `2026-07-15`, `2026-07-15 10:00`, `2026-07-08` or
`2026-08-07`. No timezone and no field order is guessed. Converting a
spreadsheet column to ISO (in Excel, the cell format `yyyy-mm-dd` or
`yyyy-mm-dd hh:mm:ss`) before export is the fix.
- **A year-first date outside its one form.** FROM a mixed separator
(`2026/7-15`) TO `2026/7/15` or `2026-07-15`. FROM a `T` or a zone on the
year-first form (`2026/7/15T9:00`, `2026/7/15 9:00Z`) TO `2026/7/15 9:00`
(a wall clock in the business timezone) or the ISO `2026-07-15T09:00:00Z`.
FROM a fraction of a second (`2026/07/15 10:00:00.123`) TO
`2026-07-15 10:00:00.123`. A two-digit year (`26/7/15`) is refused, as it
was.
- **A zone after a space, or lower-case `t` / `z`.** FROM
`2026-07-15 10:00Z`, `2026-07-15 10:00:00+08:00`, `2026-07-15t10:00:00z` TO
`2026-07-15T10:00Z`, `2026-07-15T10:00:00+08:00`, `2026-07-15T10:00:00Z`,
the spellings the create and update doors take.
- **A zone-naive `24:00`.** FROM `2026-07-15 24:00` or `2026/7/15 24:00` (read
in the server's zone) TO `2026-07-16 00:00` or `2026/7/16 0:00`.
`2026-07-15T24:00:00Z`, which names its instant, reads as before.
- **A number, reduced or expanded forms.** FROM a JSON number such as `2026`
or an Excel serial, `2026`, `2026-07`, `+002026-07-15` TO `2026-01-01`,
`2026-07-01`, `2026-07-15`.

**Kept: year-first dates.** `2026/7/15`, `2026/07/15`, `2026-7-15`,
`2026/7/15 9:00` and `2026/08/01 06:00:00`, Excel's default short date in
zh-CN and ja-JP, stay admitted. They are now held to the same rules as every
other cell: the day must exist (`2026/2/30` is refused, never rolled into
March), the hour runs 0 to 23, and the day is stored in its padded ISO form
(`2026/7/15` is stored as `2026-07-15`). A year-first date with no clock
given to a `time` field reads as `00:00:00`, as an ISO day does; it used to
read the server's zone (`04:00:00` on a New York server).

**The year keeps four digits.** A `date` cell for a year from 0001 to 0999
(`0500-01-01`) used to leave the reader as `500-01-01`, which the write door
refuses, so the import refused a day the create door takes. It is stored as
written now. A bare day read into a `datetime` field in years 0001 to 0099
(`0050-01-01`) used to be stored in the 1900s (`1950-01-01T00:00:00.000Z`); it
is stored in its own year now.

**What is not affected.** Every ISO 8601 cell and every export-shape cell on
a real day reads exactly as before, including a zone-naive cell read in the
business timezone and an offset-bearing cell honoured as written. An xlsx
cell that Excel stores as a date is unaffected: it reaches the reader as the
export shape. The dry run answers the same verdicts as the real write. A
refused cell fails only its own row, and the rest of the batch imports as
before. The same narrowing applies to the exported `coerceRow` helper.

**If you are refused.** The row's result carries `code: 'invalid_date'` and
quotes the cell, so the file can be corrected and re-imported.

<!-- 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. -->
115 changes: 114 additions & 1 deletion packages/rest/src/import-coerce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* `coerceRow` driven by a fake reference resolver.
*/

import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import {
parseBooleanCell,
parseNumberCell,
Expand Down Expand Up @@ -99,6 +99,119 @@ 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
* 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
* removes answered differently (`07/15/2026 10:00` was `…T14:00Z` in New York
* and `…T02:00Z` in Shanghai).
*/
describe('[#20534] parseDateCell — ISO 8601, the export shape or a year-first date, on a real day', () => {
const HOST_ZONES = ['America/New_York', 'Asia/Shanghai'];
const originalTz = process.env.TZ;
afterEach(() => {
if (originalTz === undefined) delete process.env.TZ;
else process.env.TZ = originalTz;
});

/** Run `fn` under each host zone; the answers must agree. */
function onBothHosts(fn: () => string | undefined): string | undefined {
const answers = HOST_ZONES.map((tz) => {
process.env.TZ = tz;
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(tz);
return fn();
});
expect(answers[1]).toBe(answers[0]);
return answers[0];
}

type Kind = 'date' | 'datetime' | 'time';

const REFUSED: ReadonlyArray<readonly [cell: unknown, kind: Kind]> = [
// An impossible day, in every shape and on every branch — was rolled over.
['2026-02-30', 'date'], ['2026-02-30', 'datetime'], ['2026-02-30', 'time'],
['2026-02-29', 'datetime'], ['2026-04-31', 'datetime'],
['2026-02-30 10:00', 'datetime'], ['2026-02-30 10:00', 'date'], ['2026-02-30 10:00', 'time'],
['2026-02-30T10:00:00Z', 'datetime'], ['2026-02-30T10:00:00Z', 'date'], ['2026-02-30T10:00:00Z', 'time'],
// Locale and prose spellings — were read in the host zone, and month-first.
['07/15/2026 10:00', 'datetime'], ['07/15/2026 10:00', 'date'], ['07/15/2026 10:00', 'time'],
['07/08/2026', 'datetime'], ['07/08/2026', 'date'],
['07/15/2026', 'date'], ['15 July 2026', 'date'], ['15 July 2026', 'datetime'],
['Jul 15 2026 10:00', 'time'], ['Wed, 15 Jul 2026 10:00:00 GMT', 'datetime'],
// Year-first, outside its one form: an impossible day, a mixed separator,
// a clock out of range, a `T`, a zone, a fraction, a two-digit year.
['2026/2/30', 'date'], ['2026/2/30', 'datetime'], ['2026/7-15', 'date'],
['2026/7/15 24:00', 'datetime'], ['2026/7/15 9:60', 'datetime'],
['2026/7/15T9:00', 'datetime'], ['2026/7/15 9:00Z', 'datetime'],
['2026/7/15 9:00:00.5', 'datetime'], ['26/7/15', 'date'], ['07/15/2026', 'datetime'],
// Reduced / expanded forms, a zone after a space, lower-case `t` / `z`.
['2026', 'date'], ['2026-07', 'datetime'], ['+002026-07-15', 'date'],
['2026-07-15 10:00Z', 'datetime'], ['2026-07-15 10:00:00+08:00', 'datetime'],
['2026-07-15t10:00:00z', 'datetime'],
// A zone-naive 24:00 — was handed to `new Date(s)`, in the host zone.
['2026-07-15 24:00', 'datetime'], ['2026-07-15T24:00:00', 'date'],
// A number — `new Date(String(n))` read it as a year, in the host zone.
[2026, 'date'], [45000, 'datetime'], [45000, 'time'],
];

it.each(REFUSED)('refuses %j as a %s cell on every host', (cell, kind) => {
expect(onBothHosts(() => parseDateCell(cell, kind))).toBeUndefined();
});

const ADMITTED: ReadonlyArray<readonly [cell: unknown, kind: Kind, stored: string]> = [
// ISO 8601 and the export shape keep their readings.
['2026-07-15', 'date', '2026-07-15'],
['2026-07-15', 'datetime', '2026-07-15T00:00:00.000Z'],
['2028-02-29', 'date', '2028-02-29'],
['2028-02-29T10:00:00Z', 'datetime', '2028-02-29T10:00:00.000Z'],
['2026-07-15 10:00:00', 'datetime', '2026-07-15T10:00:00.000Z'],
['2026-07-15 10:00:00', 'date', '2026-07-15'],
['2026-07-15 10:00:00', 'time', '10:00:00'],
['2026-07-15T10:00', 'datetime', '2026-07-15T10:00:00.000Z'],
['2026-07-15 10:00:00.123', 'datetime', '2026-07-15T10:00:00.123Z'],
['2026-07-15T10:00:00Z', 'datetime', '2026-07-15T10:00:00.000Z'],
['2026-07-15T10:00:00+08:00', 'datetime', '2026-07-15T02:00:00.000Z'],
['2026-07-15T10:00:00+0800', 'datetime', '2026-07-15T02:00:00.000Z'],
['2026-07-15T02:00:00+08:00', 'date', '2026-07-14'],
['2026-07-15T10:00:00+08:00', 'time', '02:00:00'],
['2026-07-15T24:00:00Z', 'datetime', '2026-07-16T00:00:00.000Z'],
[' 2026-07-15 ', 'date', '2026-07-15'],
['10:00', 'time', '10:00:00'],
// A year-first date (Excel's zh-CN / ja-JP short date): the padded ISO day,
// and a clock read as a wall clock exactly as the export shape's is.
['2026/6/3', 'date', '2026-06-03'],
['2026/07/15', 'date', '2026-07-15'],
['2026-7-15', 'date', '2026-07-15'],
['2026/7/15', 'datetime', '2026-07-15T00:00:00.000Z'],
['2026/7/15 9:00', 'datetime', '2026-07-15T09:00:00.000Z'],
['2026/7/15 9:00', 'date', '2026-07-15'],
['2026/7/15 9:00', 'time', '09:00:00'],
['2026/08/01 06:00:00', 'datetime', '2026-08-01T06:00:00.000Z'],
['2026-07-15 9:00', 'datetime', '2026-07-15T09:00:00.000Z'],
['2028/2/29', 'date', '2028-02-29'],
['0500/1/1', 'date', '0500-01-01'],
// The year keeps four digits on every `date` branch.
['0500-01-01', 'date', '0500-01-01'],
['0001-01-01', 'date', '0001-01-01'],
['0999-12-31 10:00:00', 'date', '0999-12-31'],
['0050-01-01T10:00:00Z', 'date', '0050-01-01'],
[new Date('0500-01-01T00:00:00Z'), 'date', '0500-01-01'],
// A bare day into a `datetime` is spelled from the day, never `Date.UTC(y, …)`,
// which read years 0..99 as 1900..1999 (`0001-01-01` was stored as 1901).
['0001-01-01', 'datetime', '0001-01-01T00:00:00.000Z'],
['0050-01-01', 'datetime', '0050-01-01T00:00:00.000Z'],
['0500-01-01', 'datetime', '0500-01-01T00:00:00.000Z'],
];

it.each(ADMITTED)('reads %j as a %s cell as %j on every host', (cell, kind, stored) => {
expect(onBothHosts(() => parseDateCell(cell, kind))).toBe(stored);
});
});

describe('matchOption', () => {
const options = [{ label: '高', value: 'high' }, { label: '低', value: 'low' }];
it('matches by option value (code)', () => {
Expand Down
Loading
Loading