Skip to content

Commit 124be20

Browse files
hotlongclaude
andcommitted
fix(rest)!: /import reads a date, datetime or time cell only in ISO 8601 or the export shape, on a real day (#20534)
parseDateCell refuses an impossible day on every branch instead of rolling it over, refuses every text cell that is neither ISO 8601 nor the export's YYYY-MM-DD HH:mm:ss instead of reading it in the server process's zone and month-first, and pads a date's year to four digits. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f11b5f2 commit 124be20

1 file changed

Lines changed: 140 additions & 69 deletions

File tree

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

Lines changed: 140 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* untouched, so an import stays byte-identical to the pre-coercion behaviour.
3232
*/
3333

34-
import { zonedWallClockToUtcMs, type WallClockParts } from '@objectstack/core';
34+
import { temporalStorageForm, zonedWallClockToUtcMs, type WallClockParts } from '@objectstack/core';
3535
import type { ExportFieldMeta } from './export-format.js';
3636
import {
3737
SINGLE_OPTION_TYPES as OPTION_TYPES,
@@ -273,71 +273,159 @@ function pad2(n: number): string {
273273
const TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
274274

275275
/**
276-
* A date-time cell carrying **no offset**: `YYYY-MM-DD HH:mm[:ss[.sss]]`, `T`
277-
* or space separated, `/` accepted for `-` like the date fast path. Anchored at
278-
* both ends, so a trailing `Z` or `+08:00` does NOT match — that cell already
279-
* names an instant and is left to `Date.parse` (#8485 ruling: an explicit offset
280-
* keeps being honoured exactly as written).
276+
* [#20534] The text shapes a `date` / `datetime` / `time` cell is read in,
277+
* after trimming — ISO 8601's extended calendar date and date-time, and the
278+
* platform's own export shape:
279+
*
280+
* - `YYYY-MM-DD` — a calendar day;
281+
* - `YYYY-MM-DDTHH:MM[:SS[.f…]]`, then `Z`, a `±HH:MM` / `±HHMM` offset, or
282+
* nothing (a zone-naive wall clock);
283+
* - `YYYY-MM-DD HH:MM[:SS[.f…]]`, zone-naive only — the `YYYY-MM-DD HH:mm:ss`
284+
* the export writes for a `datetime` cell and an xlsx date cell is read as.
285+
*
286+
* A four-digit year, two-digit month, day, hour, minute and second, an
287+
* upper-case `T` and `Z`: the spellings the write door admits for a `datetime`
288+
* string (`@objectstack/objectql`'s `record-validator.ts`, #20525), each of
289+
* which names one day and one clock whatever host reads it. Every other
290+
* spelling is refused, never guessed: `07/15/2026` and `15 July 2026` went to
291+
* `new Date(s)`, which reads them in the SERVER PROCESS's zone (the same cell
292+
* stored as `2026-07-15T14:00Z` on a New York host and `…T02:00Z` on a
293+
* Shanghai one, a `date` a day apart) and reads `07/08/2026` month-first; a
294+
* year-first spelling that is not ISO (`2026/7/15`, `2026-7-15`) is refused
295+
* with them. A space before a zone (`2026-07-15 10:00Z`) is refused as the
296+
* write door refuses it.
297+
*/
298+
const ISO_TEMPORAL_CELL =
299+
/^(\d{4})-(\d{2})-(\d{2})(?:(T| )(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
300+
301+
/**
302+
* [#20534] Does `year-month-day` name a calendar day that exists — month
303+
* 01..12, day 01 to that month's length, February 29 only in a leap year?
304+
* Arithmetic, never a `Date` round trip: `Date.UTC` and `Date.parse` ROLL an
305+
* impossible day over (`2026-02-30` is March 2), which is the defect this
306+
* refuses, and `Date.UTC` reads a year 0..99 as 1900..1999. The same rule as
307+
* the write door's `namesRealCalendarDay` (#20525), which is private there.
281308
*/
282-
const NAIVE_DATE_TIME =
283-
/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T ](\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?:\.(\d{1,3})\d*)?$/;
309+
function namesRealCalendarDay(year: number, month: number, day: number): boolean {
310+
if (month < 1 || month > 12 || day < 1) return false;
311+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
312+
const length = month === 2 ? (leap ? 29 : 28) : month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
313+
return day <= length;
314+
}
284315

285316
/**
286-
* Read an offset-free cell as the wall clock it is, or `undefined` when the
287-
* shape does not match (caller falls through to `Date.parse`). Out-of-range
288-
* components are rejected here rather than silently rolled over by `Date.UTC`.
317+
* A cell in one of the {@link ISO_TEMPORAL_CELL} shapes, on a day that exists.
318+
* `day` is the cell's own `YYYY-MM-DD` text — the year keeps the four digits it
319+
* was written with, so `0500-01-01` is never re-spelled `500-01-01`. Exactly
320+
* one reading follows it: none (a bare day), `wall` (a zone-naive clock) or
321+
* `instantMs` (the instant a zone-bearing cell names).
289322
*/
290-
function parseNaiveWallClock(s: string): WallClockParts | undefined {
291-
const m = NAIVE_DATE_TIME.exec(s);
323+
interface IsoTemporalCell {
324+
day: string;
325+
wall?: WallClockParts & { hour: number; minute: number; second: number; millisecond: number };
326+
instantMs?: number;
327+
}
328+
329+
/**
330+
* Read a trimmed cell as an {@link ISO_TEMPORAL_CELL} shape, or `undefined`.
331+
* An impossible day, an out-of-range clock (`24:00` zone-naive, `10:60`) and a
332+
* zone after a space are `undefined` too — refused, never rolled over and
333+
* never handed to `new Date(s)`. A zone-bearing cell is read by `Date.parse`,
334+
* which reads each of these ISO spellings as the same instant on every host
335+
* (`T24:00Z` is the next day's midnight, as ISO 8601 has it).
336+
*/
337+
function readIsoTemporalCell(s: string): IsoTemporalCell | undefined {
338+
const m = ISO_TEMPORAL_CELL.exec(s);
292339
if (!m) return undefined;
293-
const parts: WallClockParts = {
294-
year: Number(m[1]),
295-
month: Number(m[2]),
296-
day: Number(m[3]),
297-
hour: Number(m[4]),
298-
minute: Number(m[5]),
299-
second: m[6] ? Number(m[6]) : 0,
300-
millisecond: m[7] ? Number(m[7].padEnd(3, '0')) : 0,
340+
const [, y, mo, d, sep, hh, mi, ss, frac, zone] = m;
341+
if (!namesRealCalendarDay(Number(y), Number(mo), Number(d))) return undefined;
342+
const day = `${y}-${mo}-${d}`;
343+
if (sep === undefined) return { day };
344+
if (zone !== undefined) {
345+
if (sep !== 'T') return undefined;
346+
const instantMs = Date.parse(s);
347+
return Number.isNaN(instantMs) ? undefined : { day, instantMs };
348+
}
349+
const wall = {
350+
year: Number(y),
351+
month: Number(mo),
352+
day: Number(d),
353+
hour: Number(hh),
354+
minute: Number(mi),
355+
second: ss ? Number(ss) : 0,
356+
millisecond: frac ? Number(frac.slice(0, 3).padEnd(3, '0')) : 0,
301357
};
302-
if (parts.month < 1 || parts.month > 12) return undefined;
303-
if (parts.day < 1 || parts.day > 31) return undefined;
304-
if ((parts.hour ?? 0) > 23) return undefined;
305-
return parts;
358+
if (wall.hour > 23 || wall.minute > 59 || wall.second > 59) return undefined;
359+
return { day, wall };
360+
}
361+
362+
/** The `HH:MM:SS` UTC clock of an instant. */
363+
function utcClock(t: Date): string {
364+
return `${pad2(t.getUTCHours())}:${pad2(t.getUTCMinutes())}:${pad2(t.getUTCSeconds())}`;
306365
}
307366

308367
/**
309368
* Coerce a cell into the string shape the engine accepts for a date-ish field:
310369
* - `date` → `YYYY-MM-DD`
311370
* - `datetime` → full ISO-8601 (`toISOString`)
312371
* - `time` → `HH:MM` / `HH:MM:SS`
313-
* Returns `undefined` when the cell is not a recognisable date/time.
372+
* Returns `undefined` when the cell is not a recognisable date/time, and the
373+
* caller fails the row with `invalid_date` (`import_invalid_date` /
374+
* `import_invalid_datetime` / `import_invalid_time`).
375+
*
376+
* ## Which text is read at all (#20534)
377+
*
378+
* A text cell is read only in an {@link ISO_TEMPORAL_CELL} shape — ISO 8601,
379+
* or the export's own `YYYY-MM-DD HH:mm:ss` — and only on a calendar day that
380+
* exists. A `time` cell may also be a bare `HH:MM` / `HH:MM:SS`. Every other
381+
* cell is refused on every branch; nothing reaches `new Date(s)`:
382+
*
383+
* - **an impossible day** (`2026-02-30`, `2026-02-29`, `2026-04-31`, in any
384+
* of the shapes) — `Date.UTC` and `Date.parse` rolled it into the next
385+
* month, so a `datetime` was stored as March 2 and a `date` given in the
386+
* `T…Z` spelling likewise; never rolled over now;
387+
* - **a locale or prose spelling** (`07/15/2026 10:00`, `07/08/2026`,
388+
* `15 July 2026`, `2026/7/15`, `2026-7-15`) — `new Date(s)` read it in the
389+
* server process's zone and month-first, so the stored instant, and a
390+
* `date`'s day, were properties of the deployment host; no zone and no
391+
* field order is guessed now;
392+
* - **a number** (`2026`, an Excel serial) — `new Date(String(n))` read it
393+
* as a year, in the process zone.
394+
*
395+
* An xlsx date cell is unaffected: `import-prepare.ts` renders it as the
396+
* export shape before it gets here. A `Date` (a programmatic caller's) names
397+
* an instant and is read as before, save that a `date`'s year is padded.
314398
*
315-
* Unambiguous `YYYY-MM-DD` / `YYYY/MM/DD` inputs are normalised directly to
316-
* avoid timezone drift; everything else falls back to `Date.parse` (which
317-
* covers ISO datetimes and locale-default `MM/DD/YYYY`).
399+
* The year is padded to four digits on every `date` branch: a text cell keeps
400+
* the four digits it was written with, and an instant takes core's
401+
* `temporalStorageForm` `date` rule, which pads 0001..0999. `0500-01-01` used
402+
* to leave here as `500-01-01`, which the write door refuses, so the import
403+
* refused a day the write door takes. A bare day read into a `datetime` is
404+
* midnight UTC spelled from the day itself, never `Date.UTC(y, …)`, which read
405+
* `0050-01-01` as 1950.
318406
*
319407
* ## Which clock an offset-free cell is read in (#8485)
320408
*
321409
* A spreadsheet cell like `2026-08-01 06:00:00` carries no offset, so it is a
322-
* **wall clock**, not an instant — and `new Date(s)` resolves it against the
410+
* **wall clock**, not an instant — and `new Date(s)` resolved it against the
323411
* **process** `TZ`. That made the stored instant a property of the deployment
324412
* host: the same file, same tenant, same cell landed eight hours apart on two
325413
* hosts, decided by a setting nobody authoring the spreadsheet can see. Since
326414
* export renders `datetime` cells in the business timezone (#8373), the
327415
* advertised export → edit → re-import round trip was lossless only where the
328416
* host `TZ` happened to equal that zone.
329417
*
330-
* So a naive **datetime** cell is now read in `timezone` — the caller's
418+
* So a naive **datetime** cell is read in `timezone` — the caller's
331419
* `ExecutionContext.timezone`, the same value the export renders in — through
332420
* `@objectstack/core`'s `zonedWallClockToUtcMs` (DST-safe via the platform tz
333421
* database, and the primitive the date-bucket drill path already used in its
334422
* date-only form). Three things deliberately do NOT change:
335423
*
336424
* - **an offset-bearing cell** (`…Z`, `…+08:00`) already names one instant and
337-
* is honoured exactly as written — `NAIVE_DATE_TIME` cannot match it;
338-
* - **the date-only fast path** stays UTC (ECMAScript reads a date-only form as
339-
* UTC, and a `date` is a timezone-naive calendar day under ADR-0053 — moving
340-
* it would re-time every date-only import to fix nothing);
425+
* is honoured exactly as written;
426+
* - **a bare day** stays UTC midnight for a `datetime` (a `date` is a
427+
* timezone-naive calendar day under ADR-0053 — moving it would re-time every
428+
* date-only import to fix nothing);
341429
* - **no resolved timezone ⇒ UTC**, never the process clock. That is the
342430
* fallback the export cell path takes when no zone resolves, so the round
343431
* trip stays exact for deployments that configure none — and a process-`TZ`
@@ -346,8 +434,8 @@ function parseNaiveWallClock(s: string): WallClockParts | undefined {
346434
*
347435
* For a naive cell landing in a `date` or `time` field the typed components are
348436
* taken verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`), which is
349-
* both zone-free and host-`TZ`-free; previously those two branches also read the
350-
* cell through the process clock and could report the wrong calendar day.
437+
* both zone-free and host-`TZ`-free. An offset-bearing cell landing in either
438+
* takes the UTC calendar day or UTC clock of the instant it names.
351439
*/
352440
export function parseDateCell(
353441
raw: unknown,
@@ -359,47 +447,30 @@ export function parseDateCell(
359447
// to re-interpret, so no zone question to answer.
360448
if (Number.isNaN(raw.getTime())) return undefined;
361449
if (kind === 'datetime') return raw.toISOString();
362-
if (kind === 'date') return `${raw.getUTCFullYear()}-${pad2(raw.getUTCMonth() + 1)}-${pad2(raw.getUTCDate())}`;
363-
return `${pad2(raw.getUTCHours())}:${pad2(raw.getUTCMinutes())}:${pad2(raw.getUTCSeconds())}`;
450+
// [#20534] Core's `date` storage rule: the UTC calendar day, the year padded.
451+
if (kind === 'date') return String(temporalStorageForm(raw, 'date'));
452+
return utcClock(raw);
364453
}
365454
const s = String(raw).trim();
366455
if (s === '') return undefined;
367456

368-
const wall = parseNaiveWallClock(s);
457+
if (kind === 'time' && TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s;
369458

370-
if (kind === 'time') {
371-
if (TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s;
372-
// A full datetime for a time field: take its clock component. Offset-free →
373-
// the clock as typed; offset-bearing → the instant's UTC clock, as before.
374-
if (wall) return `${pad2(wall.hour ?? 0)}:${pad2(wall.minute ?? 0)}:${pad2(wall.second ?? 0)}`;
375-
const t = new Date(s);
376-
if (!Number.isNaN(t.getTime())) return `${pad2(t.getUTCHours())}:${pad2(t.getUTCMinutes())}:${pad2(t.getUTCSeconds())}`;
377-
return undefined;
378-
}
459+
const cell = readIsoTemporalCell(s);
460+
if (!cell) return undefined;
379461

380-
// Fast path: bare calendar date, no timezone games.
381-
const ymd = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
382-
if (ymd) {
383-
const y = Number(ymd[1]);
384-
const mo = Number(ymd[2]);
385-
const d = Number(ymd[3]);
386-
if (mo < 1 || mo > 12 || d < 1 || d > 31) return undefined;
387-
if (kind === 'date') return `${y}-${pad2(mo)}-${pad2(d)}`;
388-
return new Date(Date.UTC(y, mo - 1, d)).toISOString();
389-
}
390-
391-
if (wall) {
392-
if (kind === 'date') return `${wall.year}-${pad2(wall.month)}-${pad2(wall.day)}`;
393-
const ms = zonedWallClockToUtcMs(wall, timezone);
462+
if (cell.wall) {
463+
if (kind === 'date') return cell.day;
464+
if (kind === 'time') return `${pad2(cell.wall.hour)}:${pad2(cell.wall.minute)}:${pad2(cell.wall.second)}`;
465+
const ms = zonedWallClockToUtcMs(cell.wall, timezone);
394466
return Number.isNaN(ms) ? undefined : new Date(ms).toISOString();
395467
}
396468

397-
const parsed = new Date(s);
398-
if (Number.isNaN(parsed.getTime())) return undefined;
399-
if (kind === 'date') {
400-
return `${parsed.getUTCFullYear()}-${pad2(parsed.getUTCMonth() + 1)}-${pad2(parsed.getUTCDate())}`;
401-
}
402-
return parsed.toISOString();
469+
// A bare day is midnight UTC; a zone-bearing cell is the instant it names.
470+
const instant = new Date(cell.instantMs ?? Date.parse(`${cell.day}T00:00:00.000Z`));
471+
if (kind === 'datetime') return instant.toISOString();
472+
if (kind === 'date') return cell.instantMs === undefined ? cell.day : String(temporalStorageForm(instant, 'date'));
473+
return utcClock(instant);
403474
}
404475

405476
// ── options (select / multiselect) ─────────────────────────────────

0 commit comments

Comments
 (0)