From 1f0ee1791f4b3bab450502dc56a2d5a7dace375f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 20:07:21 +0000 Subject: [PATCH] fix(rest): read an offset-free import cell in the business timezone (#8485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseDateCell` ended in `new Date(s)`, which resolves an offset-free date-time form against the process `TZ` — so the instant bulk import stored was decided by the deployment host, not by the tenant's resolved business timezone. Since the export renders datetime cells in the business timezone (#8373), the advertised export/edit/re-import round trip was lossless only where the host `TZ` happened to equal that zone. - `@objectstack/core` gains `zonedWallClockToUtcMs`, the general wall clock → instant direction; `zonedDateStartToUtcMs` becomes its midnight special case, so one DST-safe implementation serves both. Milliseconds no longer leak into the offset read (`formatToParts` resolves to whole seconds). - `parseDateCell` takes the caller's timezone and reads a naive datetime cell in it; an explicit offset is still honoured as written and the date-only fast path still stays UTC. No zone resolved ⇒ UTC, matching the export's fallback. - A naive cell landing in a `date`/`time` field takes the typed components verbatim instead of re-reading them through the process clock. - `import-prepare.ts` flattens an xlsx date cell to the sheet's own wall clock rather than stamping a `Z` the file never had. - Both degenerate DST readings resolve to the earlier candidate instant: a gap reading lands just before the gap (01:30 EST, not 03:30 EDT — the opposite of Temporal's 'compatible' disambiguation), an ambiguous reading on its first occurrence. Measured and pinned, including the local clock each lands on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn --- ...import-naive-datetime-business-timezone.md | 73 +++ packages/core/src/utils/datetime.test.ts | 153 +++++- packages/core/src/utils/datetime.ts | 93 +++- .../rest/src/import-business-timezone.test.ts | 473 ++++++++++++++++++ packages/rest/src/import-coerce.ts | 97 +++- packages/rest/src/import-prepare.ts | 27 +- packages/rest/src/import-runner.ts | 7 + 7 files changed, 911 insertions(+), 12 deletions(-) create mode 100644 .changeset/import-naive-datetime-business-timezone.md create mode 100644 packages/rest/src/import-business-timezone.test.ts diff --git a/.changeset/import-naive-datetime-business-timezone.md b/.changeset/import-naive-datetime-business-timezone.md new file mode 100644 index 0000000000..cd35c345f7 --- /dev/null +++ b/.changeset/import-naive-datetime-business-timezone.md @@ -0,0 +1,73 @@ +--- +"@objectstack/core": patch +"@objectstack/rest": patch +--- + +fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485) + +`parseDateCell` ended in `new Date(s)`. A spreadsheet cell like +`2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the +**process** timezone, and the instant bulk import stored became a property of +the deployment host: + +``` +TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z +TZ=UTC → 2026-08-01T06:00:00.000Z +``` + +Same file, same tenant, same cell — eight hours apart, decided by a setting +nobody authoring the spreadsheet can see, and never consulting the business +timezone the route had already resolved one frame up +(`ExecutionContext.timezone`, the platform-default → global → tenant cascade). + +Since the export renders `datetime` cells in that business timezone (#8373), the +advertised export → edit in a spreadsheet → re-import round trip was lossless +only where the host `TZ` happened to equal the business zone. `import-coerce.ts` +opens by calling itself "the inverse of `export-format.ts`"; it now is one, and +the regression proof asserts inverse-ness on the **pair** — every fixture under +a host `TZ` deliberately different from the business timezone, because a test +that runs only under a matching `TZ` cannot fail. + +**An offset-free datetime cell is now read in the caller's business timezone**, +through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall +clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill +path) is now the midnight special case of. One implementation of zone +arithmetic, `Intl` offsets from the platform tz database, never hand-rolled; +generalising the existing one rather than hand-rolling a second in `rest` is +what keeps the export and import halves of this seam from drifting apart again. +Two wall clocks are not a bijection with instants, and both degenerate DST +readings resolve to the earlier candidate instant — a gap reading lands just +before the gap, an ambiguous reading on its first occurrence (pinned, measured). + +Three things deliberately do **not** move: + +- **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one + instant and is honoured exactly as written. This change affects naive cells + only. +- **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and + a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the + zoned handling to make the code look uniform would silently re-time every + date-only import to fix nothing. +- **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback + the export's cell path takes in the same case, so the round trip stays exact + for deployments that configure no zone — and a process-`TZ` fallback would + preserve the defect for exactly the deployments that cannot see it. This is + the one **behaviour change for existing deployments**: a host with a non-UTC + `TZ` and no resolved business timezone previously read naive cells in the host + clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved + zone, not a missing one. + +Two adjacent legs of the same defect, both on the naive-cell path: + +- **A naive cell landing in a `date` or `time` field** now takes the typed + components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`). + Those branches also read the process clock, so a host east of the cell stored + the *previous calendar day* for a `date` column. +- **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS + materialises it as a `Date` whose UTC components are the sheet's wall clock, + and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the + file never had. That fabricated offset then outranked the business timezone by + the very carve-out above, so every real date cell in a user-authored workbook + imported as UTC whatever the tenant's zone. It now flattens to the same + offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that + function's contract already claimed to produce. diff --git a/packages/core/src/utils/datetime.test.ts b/packages/core/src/utils/datetime.test.ts index 1dd4d22e3f..cb01628980 100644 --- a/packages/core/src/utils/datetime.test.ts +++ b/packages/core/src/utils/datetime.test.ts @@ -6,7 +6,12 @@ // database, and midnight must land exactly on the day boundary in that zone. import { describe, it, expect } from 'vitest'; -import { zonedDateStartToUtcMs, calendarPartsInTz, nextUtcCalendarDay } from './datetime.js'; +import { + zonedDateStartToUtcMs, + zonedWallClockToUtcMs, + calendarPartsInTz, + nextUtcCalendarDay, +} from './datetime.js'; const iso = (s: string) => Date.parse(s); @@ -61,6 +66,152 @@ describe('zonedDateStartToUtcMs — round-trips to the day boundary in the zone' } }); +// --------------------------------------------------------------------------- +// [#8485] The general wall-clock → instant direction. `zonedDateStartToUtcMs` +// is now its midnight special case, so everything above is also coverage of +// this function's date-only path. +// +// Every fixture below straddles a **month** boundary or a DST edge on purpose: +// a mid-day, mid-month wall clock converts correctly under a surprising number +// of wrong implementations (including the process-`TZ` read this exists to +// replace), so it proves nothing. +// --------------------------------------------------------------------------- + +const SHANGHAI = 'Asia/Shanghai'; +const NEW_YORK = 'America/New_York'; + +describe('zonedWallClockToUtcMs — a wall clock is not an instant until a zone says so', () => { + it('the reported cell: 2026-08-01 06:00 in +08 is the previous MONTH in UTC', () => { + expect(zonedWallClockToUtcMs({ year: 2026, month: 8, day: 1, hour: 6 }, SHANGHAI)).toBe( + iso('2026-07-31T22:00:00Z'), + ); + }); + + it('cross-month at the other end: 2026-09-01 00:30 +08 is 2026-08-31 in UTC', () => { + expect( + zonedWallClockToUtcMs({ year: 2026, month: 9, day: 1, hour: 0, minute: 30 }, SHANGHAI), + ).toBe(iso('2026-08-31T16:30:00Z')); + }); + + it('the same wall clock is a different instant in summer and in winter (DST zone)', () => { + // EDT (−04) in June, EST (−05) in January — one hand-rolled fixed offset + // would get exactly one of these two right. + expect(zonedWallClockToUtcMs({ year: 2026, month: 6, day: 15, hour: 12 }, NEW_YORK)).toBe( + iso('2026-06-15T16:00:00Z'), + ); + expect(zonedWallClockToUtcMs({ year: 2026, month: 1, day: 15, hour: 12 }, NEW_YORK)).toBe( + iso('2026-01-15T17:00:00Z'), + ); + }); + + it('a sub-minute offset zone (Asia/Kathmandu, +05:45)', () => { + expect(zonedWallClockToUtcMs({ year: 2026, month: 8, day: 1, hour: 6 }, 'Asia/Kathmandu')).toBe( + iso('2026-08-01T00:15:00Z'), + ); + }); + + it('milliseconds survive the conversion', () => { + // `formatToParts` resolves to whole seconds, so an offset read at the + // untruncated instant carried the milliseconds INTO the offset and shifted + // the answer by them — measured while generalising the date-only form, + // where ms was always 0 and the bug could not appear. + expect( + zonedWallClockToUtcMs( + { year: 2026, month: 8, day: 1, hour: 6, minute: 0, second: 0, millisecond: 123 }, + SHANGHAI, + ), + ).toBe(iso('2026-07-31T22:00:00.123Z')); + }); + + it('both degenerate DST readings resolve to the earlier candidate instant', () => { + // The local clock reading an instant shows in NEW_YORK, e.g. '01:30 EST'. + const localAt = (ms: number) => + new Intl.DateTimeFormat('en-US', { + timeZone: NEW_YORK, hourCycle: 'h23', + hour: '2-digit', minute: '2-digit', timeZoneName: 'short', + }).format(new Date(ms)); + + // Spring forward: 02:30 never happens on 2026-03-08 in New York. The + // two-pass settles on the POST-transition offset (EDT, −04), which places + // the answer just BEFORE the gap — 01:30 EST, not 03:30 EDT. (The opposite + // of Temporal's 'compatible' disambiguation; pinned here because it is the + // deterministic, host-independent answer, which is what an import needs.) + const gap = zonedWallClockToUtcMs({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, NEW_YORK); + expect(gap).toBe(iso('2026-03-08T06:30:00Z')); + expect(calendarPartsInTz(new Date(gap), NEW_YORK)).toEqual({ year: 2026, month: 3, day: 8 }); + expect(localAt(gap)).toBe('01:30 EST'); + // …and it is the EARLIER of the two candidates: reading 02:30 through the + // pre-transition offset (EST, −05) instead would have given 07:30Z. + expect(gap).toBeLessThan(iso('2026-03-08T07:30:00Z')); + + // Fall back: 01:30 happens TWICE on 2026-11-01. The first (EDT, −04) wins. + const ambiguous = zonedWallClockToUtcMs( + { year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, NEW_YORK, + ); + expect(ambiguous).toBe(iso('2026-11-01T05:30:00Z')); + expect(localAt(ambiguous)).toBe('01:30 EDT'); + // The second occurrence (EST, −05) is 06:30Z — the later candidate. + expect(ambiguous).toBeLessThan(iso('2026-11-01T06:30:00Z')); + }); + + it('no zone / UTC / an unknown zone reads the wall clock as UTC — never the process clock', () => { + // The pinned fallback (#8485): the export renderer writes UTC when no + // business timezone resolves, so import must read UTC to stay its inverse. + const noon = { year: 2026, month: 8, day: 1, hour: 6, minute: 0 }; + expect(zonedWallClockToUtcMs(noon)).toBe(iso('2026-08-01T06:00:00Z')); + expect(zonedWallClockToUtcMs(noon, 'UTC')).toBe(iso('2026-08-01T06:00:00Z')); + expect(zonedWallClockToUtcMs(noon, 'Not/AZone')).toBe(iso('2026-08-01T06:00:00Z')); + }); + + it('an impossible parts object is NaN, as Date.UTC gives', () => { + expect(Number.isNaN(zonedWallClockToUtcMs({ year: NaN, month: 1, day: 1 }, SHANGHAI))).toBe(true); + }); + + it('is the exact generalisation: midnight parts === zonedDateStartToUtcMs', () => { + for (const [ymd, tz] of [ + ['2026-06-01', SHANGHAI], + ['2026-06-01', NEW_YORK], + ['2026-01-01', NEW_YORK], + ['2026-03-08', NEW_YORK], + ['2026-11-01', NEW_YORK], + ['2026-02-15', 'UTC'], + ['2026-02-15', 'Not/AZone'], + ] as Array<[string, string]>) { + const [year, month, day] = ymd.split('-').map(Number); + expect(zonedWallClockToUtcMs({ year, month, day }, tz)).toBe(zonedDateStartToUtcMs(ymd, tz)); + } + }); + + it('does NOT widen zonedDateStartToUtcMs — that one is still date-only', () => { + // The generalisation is a NEW entry point, not a loosened old one: the + // bucket-drill callers pass canonical `YYYY-MM-DD` keys and a datetime + // slipping through would silently scope a drill range to a mid-day instant. + expect(Number.isNaN(zonedDateStartToUtcMs('2026-06-01 12:00:00', SHANGHAI))).toBe(true); + expect(Number.isNaN(zonedDateStartToUtcMs('2026-06-01T12:00:00Z', SHANGHAI))).toBe(true); + }); + + it('round-trips: the instant shows exactly that wall clock in the zone', () => { + for (const [tz, parts] of [ + [SHANGHAI, { year: 2026, month: 8, day: 1, hour: 6, minute: 0, second: 0 }], + [NEW_YORK, { year: 2026, month: 6, day: 15, hour: 23, minute: 59, second: 59 }], + [NEW_YORK, { year: 2026, month: 12, day: 31, hour: 23, minute: 30, second: 0 }], + ['Pacific/Kiritimati', { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }], + ] as Array<[string, Required[0], 'millisecond'>>]>) { + const d = new Date(zonedWallClockToUtcMs(parts, tz)); + const back = new Intl.DateTimeFormat('en-US', { + timeZone: tz, hourCycle: 'h23', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }).formatToParts(d); + const g = (k: string) => Number(back.find((p) => p.type === k)?.value); + expect({ + year: g('year'), month: g('month'), day: g('day'), + hour: g('hour'), minute: g('minute'), second: g('second'), + }).toEqual(parts); + } + }); +}); + describe('nextUtcCalendarDay — re-exported from @objectstack/spec (ADR-0053 D-D)', () => { it('is still reachable from this package, with the same semantics', () => { // The rule itself now lives in spec (six backends share it, and diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index bced1cb7ab..2707233d14 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -23,6 +23,21 @@ export interface CalendarParts { day: number; } +/** + * A wall clock as a human writes it — calendar day plus an optional + * time-of-day, with **no zone attached**. `2026-08-01 06:00:00` is this shape: + * it names a reading on a clock, and only a reference timezone turns it into an + * instant. Omitted time components default to 0, so {@link CalendarParts} alone + * is midnight. + */ +export interface WallClockParts extends CalendarParts { + /** 0-23. */ + hour?: number; + minute?: number; + second?: number; + millisecond?: number; +} + /** * The year/month/day an instant falls on in `tz`. Throws if `tz` is not a * valid IANA zone (callers treat that as a fall-through to UTC). @@ -71,15 +86,79 @@ export function calendarPartsInTzOrUtc(d: Date, tz?: string): CalendarParts { * * Used by date-bucket drill ranges (#1752): a `datetime` field buckets on the * reference-tz calendar, so its bucket boundary is that tz's midnight instant. + * + * Date-only by contract: a `YYYY-MM-DD HH:mm:ss` argument is still `NaN` here. + * Callers holding a wall clock with a time-of-day want + * {@link zonedWallClockToUtcMs}, which this delegates its zone arithmetic to. */ export function zonedDateStartToUtcMs(ymd: string, tz?: string): number { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); - const wallAsUtc = m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN; + if (!m) return NaN; + return zonedWallClockToUtcMs( + { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) }, + tz, + ); +} + +/** + * The UTC instant (epoch ms) at which a **wall clock** reading happens in + * reference timezone `tz` — the general inverse of {@link calendarPartsInTz}, + * of which {@link zonedDateStartToUtcMs} is the midnight special case. + * + * `2026-08-01 06:00:00` in `Asia/Shanghai` is `2026-07-31T22:00:00Z`: a + * different day, month and quarter. That gap is why this direction exists as a + * shared primitive at all — bulk import (#8485) reads offset-free spreadsheet + * cells, which are wall clocks and nothing more, and `new Date(cell)` resolves + * them against the **process** `TZ`, i.e. a host setting rather than the + * tenant's configured zone. + * + * DST-safe: the zone offset is read from the platform tz database via + * `Intl.DateTimeFormat` (never hand-computed), and a two-pass resolution settles + * the case where the offset differs side-to-side of the target instant. Two + * wall clocks are not a bijection with instants, and this function resolves + * both degenerate cases to the **earlier candidate instant** — in both, the + * final pass reads the offset on the DST side of the transition (measured, not + * merely intended — `datetime.test.ts` pins both): + * - a clock reading the zone **skips** (spring forward: `02:30` on a US + * spring-forward day) settles on the *post*-transition offset (EDT, −04), + * which places the instant just **before** the gap: it reads `01:30` EST + * locally, not `03:30` EDT. Note this is the opposite of Temporal's + * `'compatible'` disambiguation, which pushes a gap reading forward; + * - a clock reading that happens **twice** (fall back: `01:30` on a US + * fall-back day) resolves to its first occurrence, the one still on the + * pre-transition DST offset (EDT, −04). + * + * A spreadsheet cell naming a wall clock that its zone never had is ambiguous + * by construction; what matters for an import is that the answer is + * deterministic and host-independent, which both branches above are. + * + * FALLBACK — an unset, `'UTC'`, or invalid `tz` reads the wall clock **as UTC**, + * never as the process-local clock. Every caller of this family already degrades + * that way ({@link zonedDateStartToUtcMs}, and the export renderer's cell path), + * and a host `TZ` fallback would reintroduce exactly the deployment-dependent + * instant this primitive exists to remove. A parts object that produces an + * invalid date (`NaN` components) returns `NaN`, as `Date.UTC` does. + */ +export function zonedWallClockToUtcMs(parts: WallClockParts, tz?: string): number { + const wallAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour ?? 0, + parts.minute ?? 0, + parts.second ?? 0, + parts.millisecond ?? 0, + ); if (!tz || tz === 'UTC' || Number.isNaN(wallAsUtc)) return wallAsUtc; try { // The tz offset (local − UTC, in ms) at instant `t`: read t's wall clock in - // `tz`, re-interpret those parts as UTC, and subtract t. + // `tz`, re-interpret those parts as UTC, and subtract t. `formatToParts` + // resolves no finer than a second, so `t` is truncated to a whole second + // first — otherwise a sub-second wall clock leaks its milliseconds into the + // "offset" and shifts the answer by them (a real defect while generalising + // this from the date-only form, where ms was always 0). const offsetAt = (t: number): number => { + const whole = Math.floor(t / 1000) * 1000; const p = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', @@ -89,16 +168,18 @@ export function zonedDateStartToUtcMs(ymd: string, tz?: string): number { hour: '2-digit', minute: '2-digit', second: '2-digit', - }).formatToParts(new Date(t)); + }).formatToParts(new Date(whole)); const g = (k: string) => Number(p.find((x) => x.type === k)?.value); - return Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second')) - t; + return ( + Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second')) - whole + ); }; - // Want U such that localParts(U) == midnight, i.e. U = wallAsUtc − offset(U). + // Want U such that localParts(U) == the wall clock, i.e. U = wallAsUtc − offset(U). // Iterate from the zero-offset guess; converges in ≤2 steps off a DST edge. const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc)); return wallAsUtc - off1; } catch { - return wallAsUtc; // unknown zone → UTC midnight + return wallAsUtc; // unknown zone → the wall clock read as UTC } } diff --git a/packages/rest/src/import-business-timezone.test.ts b/packages/rest/src/import-business-timezone.test.ts new file mode 100644 index 0000000000..0f4441aa94 --- /dev/null +++ b/packages/rest/src/import-business-timezone.test.ts @@ -0,0 +1,473 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8485] A spreadsheet cell with no offset is a WALL CLOCK, and the import + * path must read it in the tenant's business timezone — not in the deployment + * host's `TZ`. + * + * `parseDateCell` ended in `new Date(s)`, which resolves an offset-free + * date-time form against the **process** timezone (ECMAScript; date-only forms + * are UTC, which is why the fast path above it is fine). So the stored instant + * was a property of the host: `2026-08-01 06:00:00` became `2026-07-31T22:00Z` + * on a `TZ=Asia/Shanghai` host and `2026-08-01T06:00Z` on a `TZ=UTC` one — + * eight hours apart for the same file, same tenant, same cell, decided by a + * setting nobody authoring the spreadsheet can see. + * + * Since export renders `datetime` cells in the business timezone (#8373), the + * advertised export → edit → re-import round trip was lossless **only** where + * the host `TZ` happened to equal that zone. Every fixture here therefore runs + * under a host `TZ` deliberately different from the business timezone — a test + * that runs only under a matching `TZ` cannot fail — and every datetime fixture + * straddles a **month** boundary, because a mid-day instant survives most wrong + * implementations untouched. + * + * Four contracts are pinned: + * + * 1. a naive `datetime` cell is read in `ExecutionContext.timezone`, and the + * answer does not move when the host `TZ` does; + * 2. a cell that carries an explicit offset (`…Z`, `…+08:00`) is honoured + * exactly as written — this change touches naive cells only; + * 3. the date-only fast path stays UTC (ADR-0053: a `date` is a + * timezone-naive calendar day, and ECMAScript already reads `YYYY-MM-DD` + * as UTC); + * 4. **no timezone resolved ⇒ UTC**, matching what the export writes in that + * case, so the round trip stays exact for deployments that configure none. + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import ExcelJS from 'exceljs'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import { parseDateCell, coerceFieldValue, coerceRow } from './import-coerce.js'; +import { parseXlsxToRows } from './import-prepare.js'; +import { formatCellValue } from './export-format.js'; +import type { ExportFieldMeta } from './export-format.js'; + +// The instant at the heart of the report, shared with `export-business-timezone.test.ts`: +// 2026-08-01 06:00 in +08 is 2026-07-31 22:00 UTC — a different day, MONTH and quarter. +const CROSS_MONTH_UTC = '2026-07-31T22:00:00.000Z'; +const IN_SHANGHAI = '2026-08-01 06:00:00'; +const SHANGHAI = 'Asia/Shanghai'; +const NEW_YORK = 'America/New_York'; + +const DATETIME_META: ExportFieldMeta = { name: 'scanned_at', type: 'datetime' }; +const DATE_META: ExportFieldMeta = { name: 'due', type: 'date' }; +const TIME_META: ExportFieldMeta = { name: 'opens_at', type: 'time' }; + +// --------------------------------------------------------------------------- +// Host-`TZ` control. Node re-reads `process.env.TZ` on the next Date operation, +// so a test can stand in for a deployment host without a second process. Every +// host below is chosen to DISAGREE with the business timezone under test. +// --------------------------------------------------------------------------- + +const HOSTS = ['UTC', 'America/Los_Angeles', SHANGHAI, 'Pacific/Kiritimati']; +const originalTz = process.env.TZ; + +afterEach(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +/** Run `fn` once per host timezone, returning what each host produced. */ +function onEveryHost(fn: () => T): T[] { + return HOSTS.map((tz) => { + process.env.TZ = tz; + // The host really did change under us — otherwise this file asserts one + // configuration four times and every "host-independent" claim below is + // vacuous. Node re-reads TZ lazily, so this is also what forces the switch. + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(tz); + return fn(); + }); +} + +/** The process clock's own reading of an offset-free cell — what the fix removes. */ +const processClockReading = (cell: string) => new Date(cell).toISOString(); + +describe('parseDateCell — an offset-free datetime cell reads in the business timezone', () => { + it('the reported cell lands in the month the tenant sees, on every host', () => { + const answers = onEveryHost(() => parseDateCell(IN_SHANGHAI, 'datetime', SHANGHAI)); + expect(answers).toEqual(HOSTS.map(() => CROSS_MONTH_UTC)); + }); + + it('the host TZ no longer decides the instant — that IS the defect', () => { + const cell = '2026-09-01 00:30:00'; + // Before the fix this array held FOUR different instants, one per host… + const hostReadings = onEveryHost(() => processClockReading(cell)); + expect(new Set(hostReadings).size).toBe(HOSTS.length); + // …and now one, whatever the host. + const answers = onEveryHost(() => parseDateCell(cell, 'datetime', SHANGHAI)); + expect(new Set(answers).size).toBe(1); + // Cross-month in the other direction: 00:30 on Sep 1 (+08) is Aug 31 UTC. + expect(answers[0]).toBe('2026-08-31T16:30:00.000Z'); + }); + + it('honours a DST zone on both sides of the year', () => { + process.env.TZ = SHANGHAI; // a host that agrees with neither answer + expect(parseDateCell('2026-06-15 12:00:00', 'datetime', NEW_YORK)).toBe('2026-06-15T16:00:00.000Z'); + expect(parseDateCell('2026-01-15 12:00:00', 'datetime', NEW_YORK)).toBe('2026-01-15T17:00:00.000Z'); + }); + + it('accepts the shapes a spreadsheet actually writes (T or space, optional seconds/millis, slashes)', () => { + process.env.TZ = 'America/Los_Angeles'; + expect(parseDateCell('2026-08-01T06:00:00', 'datetime', SHANGHAI)).toBe(CROSS_MONTH_UTC); + expect(parseDateCell('2026-08-01 06:00', 'datetime', SHANGHAI)).toBe(CROSS_MONTH_UTC); + expect(parseDateCell('2026/08/01 06:00:00', 'datetime', SHANGHAI)).toBe(CROSS_MONTH_UTC); + expect(parseDateCell('2026-08-01 06:00:00.123', 'datetime', SHANGHAI)).toBe('2026-07-31T22:00:00.123Z'); + }); + + it('an unparseable cell is still a coercion failure, not an invalid instant', () => { + expect(parseDateCell('not a date', 'datetime', SHANGHAI)).toBeUndefined(); + expect(parseDateCell('2026-13-45 99:00:00', 'datetime', SHANGHAI)).toBeUndefined(); + }); +}); + +describe('parseDateCell — what deliberately does NOT move', () => { + it('an explicit offset is honoured exactly as written', () => { + // The cell already names one instant; a business timezone has no say. + for (const cell of ['2026-08-01T06:00:00Z', '2026-08-01T06:00:00+08:00', '2026-08-01T06:00:00-05:00']) { + const answers = onEveryHost(() => [ + parseDateCell(cell, 'datetime', SHANGHAI), + parseDateCell(cell, 'datetime', NEW_YORK), + parseDateCell(cell, 'datetime'), + ]); + const flat = answers.flat(); + expect(new Set(flat).size).toBe(1); + expect(flat[0]).toBe(new Date(cell).toISOString()); + } + }); + + it('the date-only fast path stays UTC in every zone (ADR-0053)', () => { + const answers = onEveryHost(() => [ + parseDateCell('2026-08-01', 'datetime', SHANGHAI), + parseDateCell('2026-08-01', 'datetime', NEW_YORK), + parseDateCell('2026-08-01', 'datetime'), + parseDateCell('2026-08-01', 'date', SHANGHAI), + ]); + for (const [dtShanghai, dtNewYork, dtNone, dateShanghai] of answers) { + expect(dtShanghai).toBe('2026-08-01T00:00:00.000Z'); + expect(dtNewYork).toBe('2026-08-01T00:00:00.000Z'); + expect(dtNone).toBe('2026-08-01T00:00:00.000Z'); + expect(dateShanghai).toBe('2026-08-01'); + } + }); + + it('a `date` field takes the typed calendar day, never a re-projected one', () => { + // A naive datetime cell aimed at a `date` column: the day the author typed, + // on every host. (This branch used to read the process clock too, so + // `2026-08-01 06:00:00` stored `2026-07-31` on any host east of the cell.) + const answers = onEveryHost(() => parseDateCell(IN_SHANGHAI, 'date', NEW_YORK)); + expect(answers).toEqual(HOSTS.map(() => '2026-08-01')); + }); + + it('a `time` field takes the typed clock, never a re-projected one', () => { + const answers = onEveryHost(() => parseDateCell(IN_SHANGHAI, 'time', NEW_YORK)); + expect(answers).toEqual(HOSTS.map(() => '06:00:00')); + expect(parseDateCell('06:00', 'time', NEW_YORK)).toBe('06:00:00'); + }); +}); + +describe('parseDateCell — the no-zone-resolves fallback is UTC, and pinned', () => { + it('no timezone ⇒ the wall clock is read as UTC, on every host', () => { + // The deliberate choice (#8485): the export renderer writes UTC when no + // business timezone resolves, so import reads UTC and the round trip stays + // an inverse. Falling back to the process clock would have preserved the + // defect for exactly the deployments that never configured a zone. + const answers = onEveryHost(() => parseDateCell(IN_SHANGHAI, 'datetime')); + expect(answers).toEqual(HOSTS.map(() => '2026-08-01T06:00:00.000Z')); + }); + + it('a zone the platform does not know degrades to UTC rather than failing the row', () => { + const answers = onEveryHost(() => parseDateCell(IN_SHANGHAI, 'datetime', 'Not/AZone')); + expect(answers).toEqual(HOSTS.map(() => '2026-08-01T06:00:00.000Z')); + }); + + it("an explicit 'UTC' is a RESOLVED zone, and agrees", () => { + expect(parseDateCell(IN_SHANGHAI, 'datetime', 'UTC')).toBe('2026-08-01T06:00:00.000Z'); + }); +}); + +describe('coerceFieldValue / coerceRow — the timezone reaches the cell', () => { + it('CoerceContext.timezone drives the datetime branch', async () => { + process.env.TZ = 'America/Los_Angeles'; + await expect(coerceFieldValue(IN_SHANGHAI, DATETIME_META, { timezone: SHANGHAI })) + .resolves.toEqual({ value: CROSS_MONTH_UTC }); + await expect(coerceFieldValue(IN_SHANGHAI, DATETIME_META, {})) + .resolves.toEqual({ value: '2026-08-01T06:00:00.000Z' }); + }); + + it('a whole row coerces its date-ish columns in one zone', async () => { + process.env.TZ = 'Pacific/Kiritimati'; + const metaMap = new Map([ + ['scanned_at', DATETIME_META], + ['due', DATE_META], + ['opens_at', TIME_META], + ]); + const { data, errors } = await coerceRow( + { scanned_at: IN_SHANGHAI, due: '2026-08-01', opens_at: '09:30' }, + metaMap, + { timezone: SHANGHAI }, + ); + expect(errors).toEqual([]); + expect(data).toEqual({ + scanned_at: CROSS_MONTH_UTC, + due: '2026-08-01', + opens_at: '09:30:00', + }); + }); +}); + +// --------------------------------------------------------------------------- +// The acceptance criterion: `import-coerce.ts` opens by declaring itself "the +// inverse of `export-format.ts`". Inverse-ness is a property of the PAIR, so it +// is asserted on the pair — under hosts that agree with neither the business +// timezone nor UTC. +// --------------------------------------------------------------------------- + +describe('export → import is an identity on the instant', () => { + it('every zone × every host: the cell the export wrote re-imports to the same instant', () => { + for (const businessTz of [SHANGHAI, NEW_YORK, 'Asia/Kathmandu', 'UTC', undefined]) { + onEveryHost(() => { + for (const instant of [ + CROSS_MONTH_UTC, // crosses a month in +08 + '2026-01-01T04:30:00.000Z', // crosses a YEAR in −05 + '2026-06-15T16:00:00.000Z', // DST summer in New York + '2026-01-15T17:00:00.000Z', // DST winter in New York + '2026-03-08T07:30:00.000Z', // the hour after a US spring-forward + '2026-11-01T05:30:00.000Z', // the first of the two US fall-back 01:30s + ]) { + const cell = String(formatCellValue(instant, DATETIME_META, businessTz)); + expect(parseDateCell(cell, 'datetime', businessTz)).toBe(instant); + } + }); + } + }); + + it('the export cell really is offset-free — otherwise the round trip proves nothing', () => { + // If the export ever started writing an offset, the assertion above would + // pass through the honoured-offset branch instead and stop covering this. + expect(formatCellValue(CROSS_MONTH_UTC, DATETIME_META, SHANGHAI)).toBe(IN_SHANGHAI); + expect(IN_SHANGHAI).not.toMatch(/(Z|[+-]\d{2}:?\d{2})$/); + }); +}); + +// --------------------------------------------------------------------------- +// xlsx: an Excel serial date carries NO zone. ExcelJS materialises it as a Date +// whose UTC components are the sheet's wall clock, so rendering it with +// `toISOString()` stamped a `Z` the file never had — and a written offset is +// honoured by contract, which silently outranked the business timezone for +// every real date cell in a user-authored workbook. +// --------------------------------------------------------------------------- + +describe('xlsx import — a sheet date cell is a wall clock, not a UTC instant', () => { + async function xlsxWithDateCell(): Promise { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('rows'); + ws.addRow(['id', 'scanned_at']); + // What Excel shows in the cell: 2026-08-01 06:00:00, no zone anywhere. + ws.addRow(['1', new Date(Date.UTC(2026, 7, 1, 6, 0, 0))]); + return Buffer.from(await wb.xlsx.writeBuffer()); + } + + it('the parsed cell is the sheet wall clock, and coerces in the business timezone', async () => { + const buf = await xlsxWithDateCell(); + process.env.TZ = 'America/Los_Angeles'; + const rows = await parseXlsxToRows(buf); + expect(rows[0].scanned_at).toBe(IN_SHANGHAI); + + const metaMap = new Map([['scanned_at', DATETIME_META]]); + const { data, errors } = await coerceRow(rows[0], metaMap, { timezone: SHANGHAI }); + expect(errors).toEqual([]); + expect(data.scanned_at).toBe(CROSS_MONTH_UTC); + }); + + it('with no timezone resolved it is UTC — the same fallback as every other cell', async () => { + const buf = await xlsxWithDateCell(); + process.env.TZ = SHANGHAI; + const rows = await parseXlsxToRows(buf); + const metaMap = new Map([['scanned_at', DATETIME_META]]); + const { data } = await coerceRow(rows[0], metaMap, {}); + expect(data.scanned_at).toBe('2026-08-01T06:00:00.000Z'); + }); + + it('a RAW Excel serial — what a user-authored file actually stores — reads the same', async () => { + // The fixtures above hand ExcelJS a `Date` and let it encode the serial, so + // they would still pass if its encode and decode were wrong symmetrically. + // A real .xlsx holds a serial number plus a date `numFmt` and no zone at + // all, so build that shape directly: days since the 1899-12-30 epoch, +0.25 + // of a day for 06:00. Measured host-independent — ExcelJS reads the serial + // into a `Date` whose UTC components are the sheet's wall clock. + const serial = + Math.round((Date.UTC(2026, 7, 1) - Date.UTC(1899, 11, 30)) / 86400000) + 0.25; + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('rows'); + ws.addRow(['id', 'scanned_at']); + const row = ws.addRow(['1', serial]); + row.getCell(2).numFmt = 'yyyy-mm-dd hh:mm:ss'; + const buf = Buffer.from(await wb.xlsx.writeBuffer()); + + const metaMap = new Map([['scanned_at', DATETIME_META]]); + for (const host of HOSTS) { + process.env.TZ = host; + const rows = await parseXlsxToRows(buf); + expect(rows[0].scanned_at).toBe(IN_SHANGHAI); + const { data, errors } = await coerceRow(rows[0], metaMap, { timezone: SHANGHAI }); + expect(errors).toEqual([]); + expect(data.scanned_at).toBe(CROSS_MONTH_UTC); + } + }); +}); + +// --------------------------------------------------------------------------- +// Route level — the REAL export route feeding the REAL import route over a REAL +// engine, sqlite `:memory:` and the real metadata accessor. The only stub is +// `resolveExecCtx`, standing in for the identity + localization cascade +// (`resolveLocalizationContext` → `ExecutionContext.timezone`), exactly as +// `export-business-timezone.test.ts` does. +// --------------------------------------------------------------------------- + +const SHIFT = { + name: 'shift', + label: 'Shift', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + scanned_at: { name: 'scanned_at', type: 'datetime' as const, label: '扫码时间' }, + due: { name: 'due', type: 'date' as const, label: '截止' }, + }, +}; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => {}, close: async () => {}, + }; +} + +function makeStreamRes() { + const chunks: string[] = []; + const res: any = { + write: (s: string) => { chunks.push(typeof s === 'string' ? s : String(s)); return true; }, + end: () => {}, + header: () => res, + status: () => res, + json: () => res, + }; + return { res, text: () => chunks.join('') }; +} + +function makeJsonRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot(timezone?: string) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerObject(SHIFT as any); + await engine.syncSchemas(); + await engine.insert('shift', { id: '1', scanned_at: CROSS_MONTH_UTC, due: '2026-08-01' }); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ + userId: 'test-user', + ...(timezone ? { timezone } : {}), + }); + rest.registerRoutes(); + const routes = rest.getRoutes(); + return { + engine, + exportRoute: routes.find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export') as any, + importRoute: routes.find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import') as any, + }; +} + +/** + * The stored instant, refusing to guess. A storage layer that handed back an + * offset-free string would make `new Date()` read the HOST clock here — the + * very confusion under test — so that shape fails loudly instead. + */ +function storedInstant(value: unknown): string { + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'number') return new Date(value).toISOString(); + const s = String(value); + expect(s).toMatch(/(Z|[+-]\d{2}:?\d{2})$/); + return new Date(s).toISOString(); +} + +describe('POST /data/:object/import — the round trip a customer actually runs', () => { + beforeEach(() => { + // A host that is neither the business timezone nor UTC: the configuration + // where today's import is wrong, and the only one where this can fail. + process.env.TZ = 'America/Los_Angeles'; + }); + + it('export → edit the id → re-import stores the SAME instant', async () => { + const { engine, exportRoute, importRoute } = await boot(SHANGHAI); + + const { res, text } = makeStreamRes(); + await exportRoute.handler({ params: { object: 'shift' }, query: { format: 'csv' } } as any, res); + const lines = text().split('\r\n').filter((l) => l.length > 0); + const header = lines[0].split(','); + const cells = lines[1].split(','); + // What the customer opens in Excel: business-timezone wall clock, no offset. + expect(cells[1]).toBe(IN_SHANGHAI); + + // The one edit a customer makes before re-importing: a new key. + const csv = [lines[0], ['2', cells[1], cells[2]].join(',')].join('\n'); + const mapping: Record = {}; + header.forEach((h, i) => { mapping[h] = ['id', 'scanned_at', 'due'][i]; }); + + const jsonRes = makeJsonRes(); + await importRoute.handler( + { params: { object: 'shift' }, body: { format: 'csv', csv, mapping, writeMode: 'insert' } } as any, + jsonRes, + ); + expect(jsonRes._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 }); + + const original = await engine.findOne('shift', { where: { id: '1' } }); + const reimported = await engine.findOne('shift', { where: { id: '2' } }); + expect(storedInstant(reimported.scanned_at)).toBe(storedInstant(original.scanned_at)); + expect(storedInstant(reimported.scanned_at)).toBe(CROSS_MONTH_UTC); + // Stated as the report does: the row is still in the month it was exported + // from. Under the process clock it stored 2026-08-01T13:00Z on this host. + expect(storedInstant(reimported.scanned_at)).not.toBe('2026-08-01T13:00:00.000Z'); + expect(String(reimported.due)).toContain('2026-08-01'); + }); + + it('the same file imported by a UTC tenant is a different instant — the zone decides, not the host', async () => { + const { engine, importRoute } = await boot(); // no business timezone resolved + const csv = ['ID,扫码时间', `2,${IN_SHANGHAI}`].join('\n'); + const jsonRes = makeJsonRes(); + await importRoute.handler( + { + params: { object: 'shift' }, + body: { format: 'csv', csv, mapping: { ID: 'id', 扫码时间: 'scanned_at' }, writeMode: 'insert' }, + } as any, + jsonRes, + ); + expect(jsonRes._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 }); + const stored = await engine.findOne('shift', { where: { id: '2' } }); + expect(storedInstant(stored.scanned_at)).toBe('2026-08-01T06:00:00.000Z'); + }); +}); diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts index df80641612..4c1384ef44 100644 --- a/packages/rest/src/import-coerce.ts +++ b/packages/rest/src/import-coerce.ts @@ -31,6 +31,7 @@ * untouched, so an import stays byte-identical to the pre-coercion behaviour. */ +import { zonedWallClockToUtcMs, type WallClockParts } from '@objectstack/core'; import type { ExportFieldMeta } from './export-format.js'; import { SINGLE_OPTION_TYPES as OPTION_TYPES, @@ -125,6 +126,13 @@ export interface CoerceContext { locale?: string; /** `II18nService.t`-compatible lookup for message overrides (#3957). */ translate?: ValidationMessageTranslator; + /** + * Business timezone of the importing principal (`ExecutionContext.timezone`, + * the platform-default → global → tenant cascade). The clock an offset-free + * datetime cell is read in (#8485) — see {@link parseDateCell}. Absent → the + * cell is read as UTC, matching what the export writes when no zone resolves. + */ + timezone?: string; } /** A per-field coercion failure, shaped like the engine's validation errors. */ @@ -235,6 +243,39 @@ function pad2(n: number): string { const TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/; +/** + * A date-time cell carrying **no offset**: `YYYY-MM-DD HH:mm[:ss[.sss]]`, `T` + * or space separated, `/` accepted for `-` like the date fast path. Anchored at + * both ends, so a trailing `Z` or `+08:00` does NOT match — that cell already + * names an instant and is left to `Date.parse` (#8485 ruling: an explicit offset + * keeps being honoured exactly as written). + */ +const NAIVE_DATE_TIME = + /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T ](\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?:\.(\d{1,3})\d*)?$/; + +/** + * Read an offset-free cell as the wall clock it is, or `undefined` when the + * shape does not match (caller falls through to `Date.parse`). Out-of-range + * components are rejected here rather than silently rolled over by `Date.UTC`. + */ +function parseNaiveWallClock(s: string): WallClockParts | undefined { + const m = NAIVE_DATE_TIME.exec(s); + if (!m) return undefined; + const parts: WallClockParts = { + year: Number(m[1]), + month: Number(m[2]), + day: Number(m[3]), + hour: Number(m[4]), + minute: Number(m[5]), + second: m[6] ? Number(m[6]) : 0, + millisecond: m[7] ? Number(m[7].padEnd(3, '0')) : 0, + }; + if (parts.month < 1 || parts.month > 12) return undefined; + if (parts.day < 1 || parts.day > 31) return undefined; + if ((parts.hour ?? 0) > 23) return undefined; + return parts; +} + /** * Coerce a cell into the string shape the engine accepts for a date-ish field: * - `date` → `YYYY-MM-DD` @@ -245,9 +286,48 @@ const TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/; * Unambiguous `YYYY-MM-DD` / `YYYY/MM/DD` inputs are normalised directly to * avoid timezone drift; everything else falls back to `Date.parse` (which * covers ISO datetimes and locale-default `MM/DD/YYYY`). + * + * ## Which clock an offset-free cell is read in (#8485) + * + * A spreadsheet cell like `2026-08-01 06:00:00` carries no offset, so it is a + * **wall clock**, not an instant — and `new Date(s)` resolves it against the + * **process** `TZ`. That made the stored instant a property of the deployment + * host: the same file, same tenant, same cell landed eight hours apart on two + * hosts, decided by a setting nobody authoring the spreadsheet can see. Since + * export renders `datetime` cells in the business timezone (#8373), the + * advertised export → edit → re-import round trip was lossless only where the + * host `TZ` happened to equal that zone. + * + * So a naive **datetime** cell is now read in `timezone` — the caller's + * `ExecutionContext.timezone`, the same value the export renders in — through + * `@objectstack/core`'s `zonedWallClockToUtcMs` (DST-safe via the platform tz + * database, and the primitive the date-bucket drill path already used in its + * date-only form). Three things deliberately do NOT change: + * + * - **an offset-bearing cell** (`…Z`, `…+08:00`) already names one instant and + * is honoured exactly as written — `NAIVE_DATE_TIME` cannot match it; + * - **the date-only fast path** stays UTC (ECMAScript reads a date-only form as + * UTC, and a `date` is a timezone-naive calendar day under ADR-0053 — moving + * it would re-time every date-only import to fix nothing); + * - **no resolved timezone ⇒ UTC**, never the process clock. That is the + * fallback the export cell path takes when no zone resolves, so the round + * trip stays exact for deployments that configure none — and a process-`TZ` + * fallback would preserve the defect for exactly the deployments that cannot + * see it. + * + * For a naive cell landing in a `date` or `time` field the typed components are + * taken verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`), which is + * both zone-free and host-`TZ`-free; previously those two branches also read the + * cell through the process clock and could report the wrong calendar day. */ -export function parseDateCell(raw: unknown, kind: 'date' | 'datetime' | 'time'): string | undefined { +export function parseDateCell( + raw: unknown, + kind: 'date' | 'datetime' | 'time', + timezone?: string, +): string | undefined { if (raw instanceof Date) { + // Already an instant (a JSON/programmatic caller's `Date`) — no wall clock + // to re-interpret, so no zone question to answer. if (Number.isNaN(raw.getTime())) return undefined; if (kind === 'datetime') return raw.toISOString(); if (kind === 'date') return `${raw.getUTCFullYear()}-${pad2(raw.getUTCMonth() + 1)}-${pad2(raw.getUTCDate())}`; @@ -256,9 +336,13 @@ export function parseDateCell(raw: unknown, kind: 'date' | 'datetime' | 'time'): const s = String(raw).trim(); if (s === '') return undefined; + const wall = parseNaiveWallClock(s); + if (kind === 'time') { if (TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s; - // A full datetime for a time field: take its clock component. + // A full datetime for a time field: take its clock component. Offset-free → + // the clock as typed; offset-bearing → the instant's UTC clock, as before. + if (wall) return `${pad2(wall.hour ?? 0)}:${pad2(wall.minute ?? 0)}:${pad2(wall.second ?? 0)}`; const t = new Date(s); if (!Number.isNaN(t.getTime())) return `${pad2(t.getUTCHours())}:${pad2(t.getUTCMinutes())}:${pad2(t.getUTCSeconds())}`; return undefined; @@ -275,6 +359,12 @@ export function parseDateCell(raw: unknown, kind: 'date' | 'datetime' | 'time'): return new Date(Date.UTC(y, mo - 1, d)).toISOString(); } + if (wall) { + if (kind === 'date') return `${wall.year}-${pad2(wall.month)}-${pad2(wall.day)}`; + const ms = zonedWallClockToUtcMs(wall, timezone); + return Number.isNaN(ms) ? undefined : new Date(ms).toISOString(); + } + const parsed = new Date(s); if (Number.isNaN(parsed.getTime())) return undefined; if (kind === 'date') { @@ -351,7 +441,8 @@ export async function coerceFieldValue( } if (t === 'date' || t === 'datetime' || t === 'time') { - const d = parseDateCell(raw, t); + // The business timezone an offset-free datetime cell is read in (#8485). + const d = parseDateCell(raw, t, ctx.timezone); if (d === undefined) { // One code, three sentences — a `time` cell is not "not a valid date". const key = t === 'datetime' ? 'import_invalid_datetime' : t === 'time' ? 'import_invalid_time' : 'import_invalid_date'; diff --git a/packages/rest/src/import-prepare.ts b/packages/rest/src/import-prepare.ts index 1d51aebb63..d336c25e5e 100644 --- a/packages/rest/src/import-prepare.ts +++ b/packages/rest/src/import-prepare.ts @@ -78,17 +78,40 @@ export function parseCsvToRows(csv: string, mapping: Record = {} return out; } +/** + * The wall clock an ExcelJS date cell shows in the sheet, as the offset-free + * `YYYY-MM-DD HH:mm:ss` a CSV export writes. + * + * [#8485] An xlsx serial date carries **no timezone** — it is a clock reading, + * and ExcelJS materialises it as a `Date` whose *UTC* components are that + * reading (measured: a cell showing `2026-08-01 06:00:00` round-trips to + * `2026-08-01T06:00:00.000Z` under any host `TZ`). Rendering it with + * `toISOString()` therefore stamped a `Z` the file never had, and that + * fabricated offset then took precedence over the caller's business timezone in + * `parseDateCell` — which honours a written offset by contract. Every real date + * cell in a user-authored workbook imported as UTC, whatever the tenant's zone. + */ +function xlsxDateToNaiveCell(d: Date): string { + const p2 = (n: number) => (n < 10 ? `0${n}` : String(n)); + return ( + `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())} ` + + `${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}:${p2(d.getUTCSeconds())}` + ); +} + /** * Flatten one ExcelJS cell value to the raw string the coercion layer expects. * ExcelJS hands back rich objects for formulas / hyperlinks / rich text / dates; * we reduce each to the human-visible text so a server-parsed xlsx yields the - * same cells a CSV export would (dates → ISO, so parseDateCell can re-read them). + * same cells a CSV export would (dates → the sheet's own wall clock, so + * `parseDateCell` re-reads them in the business timezone; see + * {@link xlsxDateToNaiveCell}). */ function xlsxCellToString(value: any): string { if (value === null || value === undefined) return ''; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); - if (value instanceof Date) return value.toISOString(); + if (value instanceof Date) return Number.isNaN(value.getTime()) ? '' : xlsxDateToNaiveCell(value); if (typeof value === 'object') { // Formula cell → prefer its computed result. if ('result' in value && value.result !== undefined && value.result !== null) return xlsxCellToString(value.result); diff --git a/packages/rest/src/import-runner.ts b/packages/rest/src/import-runner.ts index 5b5903dce8..39c5885eee 100644 --- a/packages/rest/src/import-runner.ts +++ b/packages/rest/src/import-runner.ts @@ -698,6 +698,13 @@ export function runImport(opts: RunImportOptions): Promise { // Cell-coercion failures land in the same row report as the engine's // validation errors, so they speak the same language (#3957). locale: context?.locale, translate: messageTranslator, + // [#8485] The clock an offset-free datetime cell is read in. Already + // on the resolved context beside `locale` (the localization cascade's + // `ExecutionContext.timezone`) — the SAME value the export renders + // cells in (#8373), which is what makes the round trip an inverse + // instead of a host-`TZ` lottery. Absent ⇒ UTC, as the export writes. + timezone: typeof context?.timezone === 'string' && context.timezone + ? String(context.timezone) : undefined, }); if (errors.length > 0) { const first = errors[0];