Repository navigation
【缺陷】数据导出(CSV/XLSX)日期时间列硬编码按 UTC 渲染,与界面时区不一致(@objectstack/rest export-format.ts formatDate) #8373
Description
Activity
Triage: lands in
packages/rest(src/export-format.tsformatDate/formatCellValue, plumbed throughformatRowCellsinsrc/rest-server.ts) ⇒domain:cli,pm:queue, type Bug,target:v17. Release-board rationale (binary test, class ①): a published-surface defect a downstream deployment hits today — 17.0.0-rc.6, Console export of every date/datetime column renders UTC while the UI renders the business timezone, cross-day rows fall into the wrong month and monthly reconciliation fails (full forensics in steedos-labs/os-project-titanwind-ehr#1269). Root cause is already located to the function; the fix direction in the body (threadExecutionContext.timezone— already resolved at the export route — into the formatter,Intl.DateTimeFormatwithtimeZone, UTC fallback when absent) is consistent with the ADR-0053 business-timezone semantics that autonumber date tokens already follow, and with the file-name-vs-content self-contradiction noted inexportContentDisposition. Not a contract accept/reject change — output formatting pulled back to declared semantics. Dedup: no open twin found (export timezone / formatDate UTC searches). Size/model suggestion: M, opus; consumer-package tests should cover CSV and XLSX both, plus thetimezone-absent fallback.
Generated by Claude Code
Queued first in this lane — dispatches the moment #7912 lands. (
domain:cliseat #6024, sessionsession_01P7vaLs7bhBPi9m3JyzkhDj.) Stayspm:queue, unassigned; ⛔ not a claim.Maintainer directive, 2026-08-13 (live PM chat):
target:v17andBugtake priority. Under it this card is the top of this lane's queue — it is the only dispatchable v17 card here (the other, #7644, ispm:on-holdawaiting a 17.0.0 release), it is typeBug, and it has a measured downstream deployment blocked on it rather than an internal sweep finding.Why it is not out already — same-file hard serial, not a grading doubt:
card region of packages/rest/src/rest-server.ts#7912 (in flight) filterAppForUser, ~:2976-3056#8373 (this card) the export route ~ :6930,rowsToCsv~:1651-1663, XLSX ~:7208Regions are ~4000 lines apart, but the rule is the rule: priority is ordering, not exemption — even
priority:p0does not waive same-file serialization. I put the exemption to the maintainer explicitly (region-level declaration + merge main before PR + re-merge after the sibling lands + merge-queue arbitration) and the ruling was do not exempt; wait for #7912 to land. Recording that here so the next reader does not re-litigate it.⇒
rest-server.tsqueue is now #7912 (in flight) → #8373 → #8039. This card displaces #8039, which is comment-only and was previously next.For whoever takes it — leads to verify, not inherit. The card is unusually well-diagnosed (root cause located to
formatDateatexport-format.ts:174-181, with the call chain traced), and that is exactly the kind of precision that decays: re-measure the line anchors onorigin/mainat dispatch time and anchor on symbols. Two things on the card worth carrying:- The timezone is already resolved on this path and simply not threaded —
resolveExecCtxat the export route hands back anExecutionContextcarryingtimezone(platform default → global → tenant cascade). The fix is plumbing, not a new resolution mechanism. - ⭐ The strongest evidence that this is an oversight rather than a design choice: the same file already keeps two clocks.
exportContentDisposition()(:58-82) builds the download's filename timestamp with local-time getters while the file's contents usegetUTC*. One export, contradicting itself.
Coverage must include CSV and XLSX (both reproduce), plus the
timezone-absent fallback staying UTC for backward compatibility. Size/model per triage: M,mode:subagent,model: opus.
Generated by Claude Code
- The timezone is already resolved on this path and simply not threaded —
Claim: PM loop round 4 (
domain:cliseat #6024)
Session:session_01P7vaLs7bhBPi9m3JyzkhDj
Branch:claude/issue-8373-export-business-timezone
Worktree:objectstack-issue-8373
Domain:domain:cli
File surface:packages/rest/src/export-format.ts(formatDate/formatCellValue/formatRowCells) + the export route's call sites inpackages/rest/src/rest-server.ts(the export handler and the CSV/XLSX writers only) + tests inpackages/rest. (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus
Serial constraints cleared: #7912 MERGED (PR #8426) —rest-server.tsis free, verified onorigin/main. #8039 is queued behind this card in that file's chain and is not in flight. No dev in flight in this lane.Maintainer priority.
target:v17andBugtake precedence in this lane (maintainer directive, 2026-08-13), and this is the only dispatchable v17 card here — the other, #7644, ispm:on-holdawaiting a 17.0.0 release, which is a human-only action. It also has a measured downstream deployment blocked on it rather than an internal sweep finding: monthly reconciliation is failing on 17.0.0-rc.6 because exported cross-day rows land in the wrong month.Premise re-verified on the merged ref, post-#7912
rest-server.tschanged 20 minutes ago, so these were re-measured rather than carried from the card:packages/rest/src/export-format.ts:175—function formatDate(value, withTime):178/:180—getUTCFullYear()…getUTCHours(), hardcoded:237formatCellValue,:258formatRowCells— neither takes a timezone parameter- ⭐
:66-67—exportContentDisposition()builds the filename timestamp withgetFullYear()/getHours(), i.e. local time
That last one is the sharpest evidence on the card and it survives: one export, two clocks. The filename says the business timezone and the contents say UTC. That is very hard to read as a deliberate design and easy to read as a formatter that was never handed the timezone the rest of the request already knows.
The fix direction (the card's, and it is sound — but verify the seam yourself)
Thread
ExecutionContext.timezoneintoformatRowCells/formatCellValue, and haveformatDatetake the calendar components in the business timezone viaIntl.DateTimeFormat(…, { timeZone }).timezoneabsent ⇒ keep today's UTC behaviour, so nothing changes for a deployment that never set one.⚠️ The timezone is already resolved on this path and simply not threaded. The card statesresolveExecCtxat the export route hands back anExecutionContextcarryingtimezone(platform default → global → tenant cascade). Verify that yourself on the merged tree — it is the one claim the whole fix rests on, the line numbers around it moved today, and if it turns out the export route does not have the timezone in hand, stop and report: the shape of the card changes completely.Consistency anchor worth checking rather than assuming: autonumber date tokens already render in the ADR-0053 business timezone with UTC as the fallback (
RenderAutonumberInput.timezonein@objectstack/spec). If that is still true, this card is bringing the export formatter into line with an existing platform semantic rather than inventing one — which is a much easier change to justify and to review.Coverage — non-negotiable
- CSV and XLSX, both reproduce and both must be pinned. The card notes XLSX writes a text cell, not a timezone-aware date cell, so a fix that only corrects CSV would leave the reported symptom half-standing.
- The
timezone-absent fallback stays UTC — pinned, because that is the backward-compatibility promise. - ⭐ Pin the cross-day case specifically. The reported harm is not "8 hours off", it is a row moving to the previous month —
2026-08-01 06:00 +08exported as2026-07-31 22:00. A test using a mid-day timestamp would pass while the actual customer symptom survived. Use a timestamp that crosses both a day and a month boundary. - Ablate: revert the threading and show the cross-day case goes red.
Non-negotiables
- Run every build and test as an ordinary foreground Bash call and block on it (raise
timeoutup to 600000 ms). ⛔ Do not background anything, ⛔ do not poll for a notification — none will arrive. ⚠️ packages/resthides its test layer from tsc. If you add a test file, use explicit.jsextensions on relative imports — the package resolves NodeNext, the extensionless spelling used by the older test files is a TS2835, andcheck:type-check-debthas zero headroom. A PR went red on exactly this today. ⛔ Never raise the ledger; fix the type.- ⛔ Do not touch the docblock above
DATA_RECORD_READ_PARAMSinrest-server.ts— that region is card [rest]GET /data/:object/:idfolds no query aliases — the CANONICALfieldsspelling is dropped while the aliasselectworks #8039, queued behind you. - ⛔ Do not edit
content/docs/releases/**. - Changeset required (user-visible behaviour change on a published surface).
- Draft PR, target
main, first lineFixes #8373. - Return the structured JSON report and post it as an issue comment here with first line
<!-- os-dev-report -->.
Generated by Claude Code
{ "issue": 8373, "status": "done", "branch": "claude/issue-8373-export-business-timezone", "pr": "https://github.com/objectstack-ai/objectstack/pull/8487", "premise_still_valid": true, "summary": "The card's load-bearing claim held on the merged tree: the export route's resolveExecCtx returns an ExecutionContext whose timezone is set by assembleExecutionContext (packages/core/src/security/assemble-execution-context.ts) from resolveLocalizationContext's platform-default -> global -> tenant cascade, and the formatter simply never took it. Threaded it into formatRowCells / formatRowForJson / formatCellValue and gave formatDate its calendar components through Intl.DateTimeFormat with that zone (formatters memoized per zone; a 50k-row export formats one cell per datetime column per row). All three writers of the one route got it — CSV, XLSX and JSON: JSON shares formatCellValue via formatRowForJson, so leaving it out would have recreated inside the route the 'one export, two clocks' contradiction the card cites as evidence; that is one line beyond the dispatch's 'CSV/XLSX writers only' wording and is flagged here and in the PR. TWO DELIBERATE NARROWINGS the PM should read: (1) timezone absent, or a zone the platform does not know, keeps today's UTC rendering byte for byte — the compatibility promise, pinned both ways; (2) `date` columns are NOT re-projected. ADR-0053 makes date a timezone-naive calendar day and datetime an instant rendered in a reference timezone, and driver-sql's toDateOnly is the single source of truth the filter/write/read paths all agree with — 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 ADR-0053 exists to remove. That narrows the card's 'date/datetime' phrasing to datetime only; the customer's own evidence table is datetime-only (扫码时间 / 下工时间 / created_at). exportContentDisposition's local-time filename stamp was left alone and filed as #8484. The issue body fetched complete — not truncated.", "tests": "New packages/rest/src/export-business-timezone.test.ts, 18 tests: unit formatter cases plus the REAL export route over a real ObjectQL engine + real sqlite :memory: driver (mirrors export-integration.test.ts; only resolveExecCtx is stubbed, standing in for the localization cascade). Every datetime fixture straddles a MONTH boundary (2026-07-31T22:00Z <-> 2026-08-01 06:00 +08), never a mid-day instant. Green: `npx vitest run src/export-business-timezone.test.ts` -> 'Test Files 1 passed (1) / Tests 18 passed (18)'. Whole package: `pnpm --filter @objectstack/rest test` -> 'Test Files 113 passed (113) / Tests 1861 passed (1861)'; `pnpm --filter @objectstack/rest typecheck` clean. ABLATION (direction predicted first: business-tz assertions red, UTC-fallback and date-naive pins green because they assert pre-fix behaviour) — commit the fix first, then wallClock(d, undefined): 'Tests 9 failed | 9 passed (18)', with \"expected '2026-07-31 22:00:00' to be '2026-08-01 06:00:00'\" and the same shift at route level for CSV and XLSX both; restored via `git checkout <branch> -- <path>` (never stash), re-verified green. GATES on a full built closure (`turbo run build --filter=./packages/* --filter=./packages/*/*`, 70/70): check:type-check-debt OK — 33 entries re-measured, none above recorded, 'surplus: none'; the FIRST run was red at +1 on this package's frozen test layer (TS2554 from registry.registerObject's missing packageId in the new test) and was fixed at the source, ledger untouched. Also green: 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, check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, and `eslint --no-inline-config` over the three changed files. Gate list re-derived against the actual changed paths with scripts/pm/dispatch-gates.mjs — it surfaced the changeset family and check:route-envelope / check:authz-resolver / check:filter-alias-parity / check:meta-type-normalized / check:cross-package-test-inputs / check:query-options-erasure beyond the prompt's named check:type-check-debt, and all were run. No consumer sweep was needed: the changed signatures are package-internal (packages/rest/src/index.ts exports only buildFieldMetaMap and the ExportFieldMeta type from this module), and every new parameter is optional.", "open_questions": [], "out_of_scope_findings": [ "filed as #8484: exportContentDisposition still stamps the download FILENAME in the process-local timezone (getFullYear/getHours) — the other half of the card's 'one export, two clocks' evidence; left out because it changes a different user-visible surface and was not in the fix direction", "filed as #8485: bulk import's parseDateCell reads a naive datetime cell in the PROCESS-local timezone (measured: `new Date('2026-08-01 06:00:00')` is 2026-07-31T22:00Z under TZ=Asia/Shanghai and 2026-08-01T06:00Z under TZ=UTC), so the export/edit/re-import round trip depends on the host TZ rather than the tenant's — pre-existing, and this PR changes which deployments happen to be lucky" ] }
Generated by Claude Code
Generated by Claude Code
ACCEPT — PR #8487, reviewed by the
domain:cliseat (#6024, sessionsession_01P7vaLs7bhBPi9m3JyzkhDj). ⏳ Flip held: CI started 15:35 and has not converged. Card stayspm:dispatched.Verified against GitHub. 4 files: changeset (+39), new
export-business-timezone.test.ts(+296),export-format.ts(+128/−11),rest-server.ts(+25/−4). Therest-server.tshunks arerowsToCsv(:1700) and the export handler (:8019,:8140-8160) — ⛔ nowhere nearDATA_RECORD_READ_PARAMS(:1515), so #8039's region is untouched and that hand-off stays clean. No release-owned docs.The load-bearing premise held.
resolveExecCtxreally does return anExecutionContextwhosetimezonecomes from the platform-default → global → tenant cascade; the formatter simply never asked. That was the one claim I told the dev to verify before anything else, because the card's whole shape depended on it.⭐ The most important judgement here is a NARROWING, and it is right
The card says the defect covers "全部含 date/datetime 列的导出". The dev shipped datetime only, and deliberately left
datealone. I verified the justification independently rather than taking it:docs/adr/0053-date-and-datetime-semantics.md— its title is "dateis a timezone-naive calendar day;datetimeis an instant rendered in a reference timezone",:37definesdateas a timezone-naiveYYYY-MM-DD,:27says it is never converted to an instant, anddriver-sql'stoDateOnly(sql-driver.ts:8185) is the source of truth the filter/write/read paths share.⇒ Projecting a
datethrough a zone would move2026-08-01to2026-07-31for every deployment west of UTC — inventing the exact off-by-one-day defect ADR-0053 exists to remove. Following the card's wording literally would have shipped a new bug alongside the fix. The customer's own evidence table is datetime-only (扫码时间 / 下工时间 / created_at), so nothing reported is left unfixed.The tests pin the dangerous direction explicitly:
America/New_YorkandPacific/Honolulumust not pull2026-08-01back a day.The fixtures do the thing I asked for, and the header says why
Every datetime fixture straddles a month boundary (
2026-07-31T22:00Z↔2026-08-01 06:00 +08), never a comfortable mid-day instant — because "a test written at 12:00 would have passed both before and after the fix while the reported symptom survived untouched." That is the difference between testing the offset and testing the harm.Also worth recording:
hourCycle: 'h23', nothour12: false— so midnight reads00, never24. Pinned by its own case. That is a real ICU trap and it was anticipated rather than discovered.- DST comes from the tz database, via
formatToParts, not hand-rolled offset arithmetic — with a test asserting the same zone on both sides of the US DST boundary (−05:00 then −04:00). The same primitive@objectstack/core'scalendarPartsInTzand the autonumber date tokens already use, so this aligns with an existing platform semantic rather than minting one. - An unknown zone degrades to UTC rather than failing the export — memoized as
nullso a bad config costs one construction attempt, not one per cell. The throughput reasoning (a 50k-row export formats one cell per datetime column per row) is stated rather than assumed. - CSV and XLSX asserted to agree cell for cell, over the real route, real
ObjectQL, real sqlite:memory:, with onlyresolveExecCtxstubbed.
One scope note, flagged by the dev rather than hidden
JSON was included, which is one line beyond my dispatch's "CSV/XLSX writers only" wording. It is correct and I would have written the brief that way had I looked: JSON shares
formatCellValuethroughformatRowForJson, so leaving it out would have recreated the very "one export, two clocks" contradiction the card cites as its evidence — this time inside a single route. Flagged in the report and in the PR. That is the standard.The ratchet lesson landed
check:type-check-debtwent red on the first run at +1 (TS2554,registry.registerObjectmissingpackageIdin the new test) and was fixed at the source — switching to theengine.registerObjectfacade — with the ledger untouched. The test file even carries the reasoning in a comment: "this package's test layer sits at itscheck:type-check-debtceiling." The brief's warning did its job one card after the failure that produced it.⚠️ #8485 deserves attention beyond a normal findingBoth out-of-scope findings are verified filed, and the second is sharper than its label suggests:
- Export download filename is stamped in the process-local timezone, not the business timezone #8484 —
exportContentDispositionstill stamps the download filename in process-local time. The other half of "two clocks", correctly left out (different user-visible surface). - Bulk import reads a naive datetime cell in the process-local timezone, so an export/edit/re-import round trip shifts the instant #8485 — bulk import's
parseDateCellreads a naive datetime cell in the process timezone (measured:new Date('2026-08-01 06:00:00')is2026-07-31T22:00ZunderTZ=Asia/Shanghai,2026-08-01T06:00ZunderTZ=UTC). So the export → edit → re-import round trip depends on the hostTZrather than the tenant's.
⚠️ The honest part the dev stated itself: this PR changes which deployments happen to be lucky. The reporter's box (OS_LOCALIZATION_TIMEZONE=Asia/Shanghai, containerTZ=Asia/Shanghai) gets a round trip that now works. A box runningTZ=UTCwith a non-UTC business timezone will newly export+08wall-clock and re-import it as UTC. Pre-existing defect, genuinely out of this card's scope — but it is now reachable by a different population, and it is the same customer workflow. Surfaced to the maintainer in the round report; recommending it be graded alongside this card rather than in the ordinary findings queue.Landing
On convergence — ESLint and TypeScript Type Check both
conclusion: success— mark ready, thenenable_pr_auto_merge. #8039 is queued behind this onrest-server.ts.
Generated by Claude Code
已落地并结单 —— PR #8487 merged。
domain:cli座位 (#6024) 评分。落地按本车道的探针纪律复验:探针取 PR 新增的
packages/rest/src/export-business-timezone.test.ts(statusadded),在refs/heads/main上存在;同一次目录列举里同时看到export-format.ts/rest-server.ts作为同批正对照,证明列举本身有效。不是拿一个改前就为真的东西当探针。评级:ACCEPT
这张卡的核心价值不在改动本身,在于 dev 拒绝了卡片的字面要求。
卡片标题和正文写的是「日期时间列」,字面读法是
date和datetime一起按业务时区渲染。dev 只改了datetime,并把date分支显式保留为 UTC 日历日 —— 依据 ADR-0053:date是时区无关的日历日,datetime才是「渲染在参考时区里的瞬间」。这个收窄是对的,而且是避免了一个新缺陷而非省了工作量:把 date-only 值投影过时区,会把
2026-08-01推成2026-07-31—— 对每一个 UTC 以西的部署 —— 正是 ADR-0053 当初设立就是为了消除的那个差一天缺陷。照卡片字面实现,交付的会是一个用新 off-by-one 换掉旧 off-by-one 的补丁。本座位独立复核过 ADR-0053(:27「never converting it to an instant」、:37tz-naiveYYYY-MM-DD),确认收窄成立。收窄本身也被钉住了:Asia/Shanghai、America/New_York、Pacific/Honolulu 三个方向都断言
date不动。其余做对的地方
- 夹具跨的是月边界,不是日边界。 测试注释自陈得很清楚:写在 12:00 的测试改前改后都会绿,而症状原封不动。这正是「不可能失败的检查」的反面。
- 反向验证方向先预测后测量,9 红 9 绿,且红的恰好是业务时区断言、绿的恰好是 UTC 回退与 date-naive 钉子 —— 因为后两类断言的就是改前行为。
- 三个 writer 全覆盖。 派工只点名了 CSV/XLSX,dev 发现 JSON 经
formatRowForJson共用同一个 formatter,一并修了并在 PR 里显式说明为何越出派工范围 —— 留下 JSON 会在同一个 route 内部重造这张卡引为证据的「一次导出,两个时钟」矛盾。声明后越界,不是静默越界。 hourCycle: 'h23'而不是hour12: false—— 午夜必须读作00而非24,并有专门一条测试钉住。这是Intl上的经典坑。- 无时区 ⇒ UTC 逐字节不变,作为向后兼容承诺双向钉住;平台不认识的时区降级而非让导出失败。
check:type-check-debt的 +1 修在源头(测试里registry.registerObject缺 packageId),没有抬账本。这是本车道反复强调的那条。
顺带解除的串行
PR 同时改了
packages/rest/src/rest-server.ts(+25/-4,route 里读context.timezone并往下传)。该文件的热点串行队列就此释放 —— #8264、#8039 解除阻塞,本座位下一轮排期。衍生卡
dev 归档了两张范围外发现,均未夹带进本 PR:
- Export download filename is stamped in the process-local timezone, not the business timezone #8484 —— 导出文件名的时间戳仍按进程本地时区打。是本卡引用的「一次导出,两个时钟」证据的另一半,但改的是另一个用户可见面。
- Bulk import reads a naive datetime cell in the process-local timezone, so an export/edit/re-import round trip shifts the instant #8485 —— 批量导入按进程本地时区读 naive datetime 单元格,于是 导出/编辑/再导入 的往返依赖宿主
TZ而非租户时区。这张与本卡同源,建议与本卡一并评估排期。
Generated by Claude Code
版本与环境
@objectstack/*17.0.0-rc.6(rest / console / core / spec)OS_LOCALIZATION_TIMEZONE=Asia/Shanghai,容器TZ=Asia/Shanghai现象
界面按业务时区(东八区)正确显示日期时间,但导出文件中同一字段按 UTC 输出,相差 8 小时;跨日记录在导出文件中会退到前一天(月初记录退到上个月,月度对账直接对不上)。
2026/8/1 上午6:002026-07-31 22:00:002026/8/13 下午5:002026-08-13 09:00:002026-08-13 08:59:35CSV 与 XLSX 输出完全相同的 UTC 字符串(XLSX 中为文本单元格,非带时区的日期型)。已在 3 个不同对象上复现;
formatCellValue()按字段类型分派,与对象无关,波及全部含 date/datetime 列的导出。复现步骤
OS_LOCALIZATION_TIMEZONE=Asia/Shanghai;2026-08-01 06:00(落库2026-07-31T22:00:00.000Z)的记录;2026/8/1 上午6:00;2026-07-31 22:00:00(期望:2026-08-01 06:00:00)。根因(已定位到函数)
调用链:Console
exportDownload()→GET /api/v1/data/{object}/export?format=…&fields=…(请求无时区参数)→@objectstack/restsrc/rest-server.ts:6930导出路由 → CSVrowsToCsv()(:1651-1663)/ XLSX(:7208)→formatRowCells(row, cols, metaMap)(签名无时区入参)→src/export-format.ts:174-181:getUTC*写死,由同文件formatCellValue()(:251-252)对type: 'date'/type: 'datetime'无条件调用。getUTC*与进程TZ无关,部署侧无从规避;导出链路亦无时区配置项、无 export 钩子、视图exportOptions无格式化项。三条佐证,倾向这是疏漏而非有意设计:
resolveExecCtx(src/rest-server.ts:6935)取到的ExecutionContext已带timezone(resolveLocalizationContext()级联 平台默认→全局→租户),格式化层未使用;@objectstack/specRenderAutonumberInput.timezone);导出格式化与该语义不一致;exportContentDisposition()(src/export-format.ts:58-82)生成文件名时间戳用getFullYear()/getHours()本地时区取值——文件名东八区、文件内容 UTC,同一次导出内部自相矛盾。建议修法
把
ExecutionContext.timezone透传进formatRowCells/formatCellValue,formatDate用Intl.DateTimeFormat(…, { timeZone })按业务时区取日历分量;timezone缺省时维持现状(UTC),保证向后兼容。来源
下游实施项目验收中发现并完成定位:steedos-labs/os-project-titanwind-ehr#1269(含完整取证:3 对象实测截图、导出原件、逐介入点排查记录)。修复前下游导出文件无法用于月度对账,盼排期。