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' ;
3535import type { ExportFieldMeta } from './export-format.js' ;
3636import {
3737 SINGLE_OPTION_TYPES as OPTION_TYPES ,
@@ -273,71 +273,159 @@ function pad2(n: number): string {
273273const TIME_OF_DAY = / ^ ( [ 0 1 ] \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 */
352440export 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