Uh oh!
There was an error while loading. Please reload this page.
fix(rest): render exported datetime cells in the business timezone, not UTC - #8487
Conversation
`formatDate` read `getUTC*` unconditionally, so every date/datetime column of a CSV / XLSX / JSON export streamed UTC while the UI rendered the business timezone. A record at 2026-08-01 06:00 +08 exported as 2026-07-31 22:00 — the row left August, and a downstream monthly reconciliation stopped balancing. `getUTC*` ignores the process TZ, so there was no deployment-side workaround. The timezone was already resolved on this path and simply never threaded: `resolveExecCtx` hands the export route an ExecutionContext carrying `timezone` (platform default -> global -> tenant). Thread it into formatRowCells / formatRowForJson / formatCellValue and read the calendar components through Intl.DateTimeFormat with that zone, matching the ADR-0053 business-timezone semantics autonumber date tokens already follow. No timezone (or one the platform does not know) keeps today's UTC rendering, byte for byte. `date` stays a timezone-naive calendar day per ADR-0053 and is never re-projected — doing so would move 2026-08-01 to 2026-07-31 for every deployment west of UTC.
…timezone fix `registry.registerObject` requires a packageId, so the one-argument call the sibling export test uses is a TS2554 in the hidden test layer — and `check:type-check-debt` has zero headroom (every ledger entry sits exactly at its measurement). The engine facade takes the same one argument and type-checks.
…ort-business-timezone
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8373
Export files must show the same clock as the screen. They did not:
formatDateinpackages/rest/src/export-format.tsreadgetUTC*unconditionally, andformatCellValuecalled it for everydate/datetimecell, so a Console export streamed UTC while the UI rendered the business timezone. The reported harm is not "a few hours out" — a row the screen showed at2026/8/1 06:00(+08) landed in the file as2026-07-31 22:00:00, i.e. it left August, and a downstream deployment's monthly reconciliation stopped balancing on exactly that. BecausegetUTC*ignores the processTZ, no deployment-side setting could work around it.The premise held: the timezone was already there
The card's load-bearing claim re-verified on the merged tree:
resolveExecCtxat the top of the export handler returns anExecutionContextwhosetimezoneis set byassembleExecutionContext(packages/core/src/security/assemble-execution-context.ts) fromresolveLocalizationContext's platform-default to global to tenant cascade.formatCellValueandformatRowCellstook no timezone parameter.So this is plumbing, not a new resolution mechanism. The consistency anchor held too: autonumber date tokens already render in the ADR-0053 business timezone with UTC as the fallback (
calendarPartsinpackages/spec/src/data/autonumber-format.ts), so this brings the export formatter onto an existing platform semantic rather than minting one.What changed
formatDatetakes its calendar components throughIntl.DateTimeFormat(..., { timeZone })so DST comes from the platform tz database rather than hand-rolled offset arithmetic — the same primitive@objectstack/core'scalendarPartsInTzuses. Formatters are memoized per zone; a 50k-row export formats one cell per datetime column per row.timezoneis threaded throughformatRowCells/formatRowForJson/formatCellValue, and the export route reads it off the context it already resolved.formatCellValuethroughformatRowForJson, and leaving it behind would have recreated inside the route the very "one export, two clocks" contradiction this card cites as evidence. That JSON call site is one line inside the same handler loop as the XLSX writer — called out here because the dispatch named the CSV/XLSX writers specifically.The two things that deliberately did not change
No timezone means UTC, byte for byte. That is the backward-compatibility promise for every deployment that never configured one, and it covers a zone the platform does not recognise as well (degrade, never fail the export). Pinned in both directions.
datecolumns are untouched. Under ADR-0053 adateis a timezone-naive calendar day anddatetimeis an instant rendered in a reference timezone — two different meanings, so two different clocks.@objectstack/driver-sql'stoDateOnlyis the single source of truth for what adateis (YYYY-MM-DD; aDatecollapsed on its UTC calendar day) and the filter, write and read paths all agree with it. Projecting a date-only value through a zone would move2026-08-01to2026-07-31for every deployment west of UTC — inventing the off-by-one-day defect that ADR decision exists to remove. This narrows the card's stated fix direction ("date/datetime"), and the narrowing is pinned by tests in Asia/Shanghai, America/New_York and Pacific/Honolulu.Tests
New
packages/rest/src/export-business-timezone.test.ts(18 tests): unit-level formatter cases plus the real export route over a real ObjectQL engine and a real sqlite:memory:driver, mirroring the existingexport-integration.test.tsharness — the only stub isresolveExecCtx, standing in for the localization cascade.Every datetime fixture straddles a month boundary rather than sitting comfortably mid-day: a test written at 12:00 would have passed both before and after the fix while the reported symptom survived untouched.
Reverse verification, direction predicted before running it — reverting only the threading (
wallClock(d, undefined)) should redden every business-timezone assertion and leave the UTC-fallback and date-naive pins green, because those assert pre-fix behaviour:Restored, the whole package is green:
pnpm --filter @objectstack/rest test— 113 files, 1861 tests passed;pnpm --filter @objectstack/rest typecheckclean.Gates run locally on a full built closure:
check:type-check-debt(OK, 33 entries re-measured, none above its recorded number — the first attempt was +1 on this package's frozen test layer, fromregistry.registerObject's missing packageId in the new test; fixed at the source rather than raising the ledger),check:type-check-coverage,check:nul-bytes,check:route-envelope,check:authz-resolver,check:query-options-erasure,check:cross-package-test-inputs,check:filter-alias-parity,check:meta-type-normalized, the changeset family (check:changeset-gate-self-tests,check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check:objectui-changeset), and eslint over the changed files.Out of scope, filed
TZrather than the tenant's.Generated by Claude Code