Skip to content

fix(rest): render exported datetime cells in the business timezone, not UTC - #8487

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-8373-export-business-timezone
Aug 13, 2026
Merged

fix(rest): render exported datetime cells in the business timezone, not UTC#8487
os-zhuang merged 3 commits into
mainfrom
claude/issue-8373-export-business-timezone

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8373

Export files must show the same clock as the screen. They did not: formatDate in packages/rest/src/export-format.ts read getUTC* unconditionally, and formatCellValue called it for every date / datetime cell, 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 at 2026/8/1 06:00 (+08) landed in the file as 2026-07-31 22:00:00, i.e. it left August, and a downstream deployment's monthly reconciliation stopped balancing on exactly that. Because getUTC* ignores the process TZ, 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:

  • resolveExecCtx at the top of the export handler returns an ExecutionContext whose timezone is set by assembleExecutionContext (packages/core/src/security/assemble-execution-context.ts) from resolveLocalizationContext's platform-default to global to tenant cascade.
  • The formatting layer simply never asked for it: formatCellValue and formatRowCells took 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 (calendarParts in packages/spec/src/data/autonumber-format.ts), so this brings the export formatter onto an existing platform semantic rather than minting one.

What changed

  • formatDate takes its calendar components through Intl.DateTimeFormat(..., { timeZone }) so DST comes from the platform tz database rather than hand-rolled offset arithmetic — the same primitive @objectstack/core's calendarPartsInTz uses. Formatters are memoized per zone; a 50k-row export formats one cell per datetime column per row.
  • timezone is threaded through formatRowCells / formatRowForJson / formatCellValue, and the export route reads it off the context it already resolved.
  • All three writers of the one route get it: CSV, XLSX and JSON. CSV and XLSX both reproduce the defect and both are pinned; JSON shares formatCellValue through formatRowForJson, 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.

date columns are untouched. Under ADR-0053 a date is a timezone-naive calendar day and datetime is an instant rendered in a reference timezone — two different meanings, so two different clocks. @objectstack/driver-sql's toDateOnly is the single source of truth for what a date is (YYYY-MM-DD; a Date collapsed 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 move 2026-08-01 to 2026-07-31 for 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 existing export-integration.test.ts harness — the only stub is resolveExecCtx, 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:

Tests 9 failed | 9 passed (18)
AssertionError: expected '2026-07-31 22:00:00' to be '2026-08-01 06:00:00'
AssertionError: expected [ '1', '2026-07-31 22:00:00', ... ] to deeply equal [ '1', '2026-08-01 06:00:00', ... ] (CSV, route level)
AssertionError: expected [ '1', '2026-07-31 22:00:00', ... ] to deeply equal [ '1', '2026-08-01 06:00:00', ... ] (XLSX, route level)

Restored, the whole package is green: pnpm --filter @objectstack/rest test — 113 files, 1861 tests passed; pnpm --filter @objectstack/rest typecheck clean.

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, from registry.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


Generated by Claude Code

`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.
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 3:35pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest)
  • content/docs/api/index.mdx(via @objectstack/rest)
  • content/docs/permissions/authentication.mdx(via @objectstack/rest)
  • content/docs/permissions/system-context.mdx(via packages/rest)
  • content/docs/plugins/index.mdx(via @objectstack/rest)
  • content/docs/plugins/packages.mdx(via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/rest)
  • content/docs/releases/v12.mdx(via @objectstack/rest)
  • content/docs/releases/v17.mdx(via @objectstack/rest)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

【缺陷】数据导出(CSV/XLSX)日期时间列硬编码按 UTC 渲染,与界面时区不一致(@objectstack/rest export-format.ts formatDate)

2 participants

@os-zhuang@claude