Uh oh!
There was an error while loading. Please reload this page.
fix(rest): read an offset-free import cell in the business timezone - #8784
Conversation
…8485) `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 25 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also reference the affected code. These are read-only:
|
qq9340100
commented
Aug 15, 2026
ACCEPT — PM review, |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8485
A spreadsheet cell like
2026-08-01 06:00:00carries no offset, so it is a wall clock, not an instant — andparseDateCellended innew Date(s), which resolves that form against the processTZ. The stored instant was therefore a property of the deployment host: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).Since the export renders
datetimecells in that business timezone (#8373), the advertised export → edit → re-import round trip was lossless only where the hostTZhappened to equal the business zone.import-coerce.tsopens by calling itself "the inverse ofexport-format.ts"; it now is one.What changed
@objectstack/coregainszonedWallClockToUtcMs— the general wall clock → instant direction.zonedDateStartToUtcMsbecomes its midnight special case, so one DST-safe implementation (Intloffsets from the platform tz database, never hand-rolled) serves both. Generalising the existing primitive rather than hand-rolling a second one inrestis what keeps the two halves of this seam from drifting apart again.parseDateCelltakes the caller's timezone and reads a naive datetime cell in it;coerceFieldValue/coerceRowthread it throughCoerceContext.timezone, andimport-runner.tsreads it off the same resolvedcontextthe export route reads its timezone from.dateortimefield takes the typed components verbatim. Those branches also read the process clock, so a host east of the cell stored the previous calendar day for adatecolumn.toISOString()stamping aZthe file never had. That fabricated offset previously outranked the business timezone by the honoured-offset carve-out below, so every real date cell in a user-authored workbook imported as UTC whatever the tenant's zone.The three rulings, honoured
…Zand…+08:00already name one instant; theNAIVE_DATE_TIMEpattern is anchored at both ends so it cannot match them. This change affects naive cells only.YYYY-MM-DDis UTC per ECMAScript and adateis a timezone-naive calendar day (ADR-0053). Not swept into the zoned handling.wallClock=zonedWallClock(...) ?? utcWallClock(...)), so the round trip stays exact for deployments that configure no zone. A process-TZfallback would have preserved the defect for exactly the deployments that cannot see it. An explicitly resolved'UTC'is a resolved zone, not a missing one.One behaviour change for existing deployments follows from ruling 3: a host with a non-UTC
TZand no resolved business timezone previously read naive cells in the host clock and now reads them as UTC.Degenerate DST readings
Two wall clocks are not a bijection with instants. Both degenerate cases resolve to the earlier candidate instant — measured, and pinned including the local clock each lands on:
'compatible'disambiguation, which pushes forward;A cell naming a clock its zone never had is ambiguous by construction; what an import needs is an answer that is deterministic and host-independent, which both are.
Tests
Verified at HEAD
1f0ee1791.The acceptance criterion is inverse-ness of the pair, so it is asserted on the pair, and every fixture runs under a host
TZdeliberately different from the business timezone — the configuration where the old code is wrong, and the only one where these can fail. Every datetime fixture straddles a month boundary, because a mid-day instant survives most wrong implementations untouched.parseDateCell: the reported cell lands in the tenant's month on all four hosts; a companion assertion measures that the process-clock reading really does produce four different instants, so the host-independence claim is not vacuous.export → importidentity across every zone × every host × six instants, including both DST edges, plus a guard that the export cell really is offset-free (otherwise the round trip would pass through the honoured-offset branch and stop covering this).:memory:; onlyresolveExecCtxis stubbed.storedInstantrefuses an offset-free stored value rather than lettingnew Date()re-read the host clock.Dateand a raw Excel serial +numFmt— what a user-authored file actually stores — the latter added here so the fixture does not depend on ExcelJS's encode and decode being wrong symmetrically.Reverse verification (predicted red, observed red): ablating the naive-datetime branch turns 15 tests red, including the route-level round trip and the raw-serial xlsx case; restored from the commit afterwards.
@objectstack/coretest@objectstack/resttest@objectstack/service-analyticstest@objectstack/resttypecheckservice-analyticsis the one out-of-package consumer ofzonedDateStartToUtcMs, run because that function is widely consumed and the merge queue runs the full suite.zonedDateStartToUtcMskeeps its exact contract — still date-only (a datetime argument is stillNaN, pinned), and its equivalence to the generalised form is pinned across seven zone/date pairs.Gates re-derived at final HEAD with
scripts/pm/dispatch-gates.mjsagainst the actual changed paths and all green:check:nul-bytes,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:error-code-casing,check:kernel-hook-pairs,check:objectui-changeset,check:test-source-alias,check:query-options-erasure,check:type-check-coverage,check:type-check-debt --re-measure, pluscheck-adr-0087-registration,check-changeset-no-major,check-cross-package-test-inputs,check-empty-changeset. Nothing refused to run.Generated by Claude Code