From 3f9964a10ca4fbd5e76ab7ded406e833d4e88031 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:11:58 +0000 Subject: [PATCH 1/2] fix(rest): render exported datetime cells in the business timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../rest/src/export-business-timezone.test.ts | 293 ++++++++++++++++++ packages/rest/src/export-format.ts | 139 ++++++++- packages/rest/src/rest-server.ts | 29 +- 3 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 packages/rest/src/export-business-timezone.test.ts diff --git a/packages/rest/src/export-business-timezone.test.ts b/packages/rest/src/export-business-timezone.test.ts new file mode 100644 index 0000000000..c7bf6223ac --- /dev/null +++ b/packages/rest/src/export-business-timezone.test.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8373] The exported file must show the same clock as the screen. + * + * `formatDate` read `getUTC*` unconditionally, so every `datetime` column of a + * CSV / XLSX / JSON export streamed UTC while the UI rendered the business + * timezone. The harm is not "8 hours off": a record at `2026-08-01 06:00 +08` + * exported as `2026-07-31 22:00`, i.e. it left AUGUST — a downstream monthly + * reconciliation stopped balancing on exactly that. `getUTC*` ignores the + * process `TZ`, so there was no deployment-side workaround. + * + * Every datetime fixture below therefore straddles a **month** boundary, not a + * comfortable mid-day instant: a test written at 12:00 would have passed both + * before and after the fix while the reported symptom survived untouched. + * + * Three contracts are pinned here: + * + * 1. `datetime` renders in `ExecutionContext.timezone` — CSV **and** XLSX + * (both reproduce; the XLSX path writes its own cells through + * `formatRowCells`, so a CSV-only fix would leave the symptom half-standing), + * plus JSON, which shares `formatCellValue` through `formatRowForJson`. + * 2. **No timezone ⇒ UTC**, byte-identical to the pre-#8373 output. That is the + * backward-compatibility promise for every deployment that never set one, + * and it also covers a zone the platform does not know. + * 3. `date` is a **timezone-naive calendar day** (ADR-0053) and is NOT + * re-projected. `driver-sql`'s `toDateOnly` is the source of truth for what + * a `date` is; projecting one through a zone would move `2026-08-01` to + * `2026-07-31` for every deployment west of UTC — the off-by-one-day defect + * ADR-0053 exists to remove. + */ + +import { describe, it, expect, beforeEach, afterEach } 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 { formatCellValue, formatRowCells, formatRowForJson } from './export-format.js'; +import type { ExportFieldMeta } from './export-format.js'; + +// The instant at the heart of the report: 2026-08-01 06:00 in +08 is +// 2026-07-31 22:00 in UTC — a different day, month and quarter-of-year. +const CROSS_MONTH_UTC = '2026-07-31T22:00:00.000Z'; +const IN_SHANGHAI = '2026-08-01 06:00:00'; +const IN_UTC = '2026-07-31 22:00:00'; +const SHANGHAI = 'Asia/Shanghai'; + +// --------------------------------------------------------------------------- +// Unit level — the formatter itself. +// --------------------------------------------------------------------------- + +const DATETIME_META: ExportFieldMeta = { name: 'scanned_at', type: 'datetime' }; +const DATE_META: ExportFieldMeta = { name: 'due', type: 'date' }; + +describe('formatCellValue — datetime renders in the business timezone', () => { + it('moves a cross-month instant back into the month the UI shows', () => { + expect(formatCellValue(CROSS_MONTH_UTC, DATETIME_META, SHANGHAI)).toBe(IN_SHANGHAI); + }); + + it('falls back to UTC when no timezone is resolved (pre-#8373 behaviour)', () => { + expect(formatCellValue(CROSS_MONTH_UTC, DATETIME_META)).toBe(IN_UTC); + expect(formatCellValue(CROSS_MONTH_UTC, DATETIME_META, undefined)).toBe(IN_UTC); + expect(formatCellValue(CROSS_MONTH_UTC, DATETIME_META, 'UTC')).toBe(IN_UTC); + }); + + it('falls back to UTC for a zone this platform does not know', () => { + expect(formatCellValue(CROSS_MONTH_UTC, DATETIME_META, 'Mars/Olympus_Mons')).toBe(IN_UTC); + expect(formatCellValue(CROSS_MONTH_UTC, DATETIME_META, '')).toBe(IN_UTC); + }); + + it('renders midnight as 00, never 24 (hourCycle h23)', () => { + // 2026-08-01T16:00Z is 2026-08-02 00:00 in +08. + expect(formatCellValue('2026-08-01T16:00:00.000Z', DATETIME_META, SHANGHAI)) + .toBe('2026-08-02 00:00:00'); + }); + + it('crosses BACK a day for a zone west of UTC', () => { + // 2026-08-01T02:30Z is 2026-07-31 22:30 in New York (-04:00, DST). + expect(formatCellValue('2026-08-01T02:30:00.000Z', DATETIME_META, 'America/New_York')) + .toBe('2026-07-31 22:30:00'); + }); + + it('reads the tz database for DST rather than a fixed offset', () => { + // Same zone, opposite sides of the US DST boundary: -05:00 then -04:00. + expect(formatCellValue('2026-01-15T12:00:00.000Z', DATETIME_META, 'America/New_York')) + .toBe('2026-01-15 07:00:00'); + expect(formatCellValue('2026-07-15T12:00:00.000Z', DATETIME_META, 'America/New_York')) + .toBe('2026-07-15 08:00:00'); + }); + + it('leaves an unparseable value untouched', () => { + expect(formatCellValue('not a date', DATETIME_META, SHANGHAI)).toBe('not a date'); + }); +}); + +describe('formatCellValue — date stays a timezone-naive calendar day (ADR-0053)', () => { + it('does not re-project a date-only value into the business timezone', () => { + expect(formatCellValue('2026-08-01', DATE_META, SHANGHAI)).toBe('2026-08-01'); + // The direction that would have broken: a zone west of UTC must not pull + // the calendar day back to 2026-07-31. + expect(formatCellValue('2026-08-01', DATE_META, 'America/New_York')).toBe('2026-08-01'); + expect(formatCellValue('2026-08-01', DATE_META, 'Pacific/Honolulu')).toBe('2026-08-01'); + }); + + it('is identical with and without a timezone', () => { + expect(formatCellValue('2026-08-01', DATE_META, SHANGHAI)) + .toBe(formatCellValue('2026-08-01', DATE_META)); + }); +}); + +describe('row helpers thread the timezone through', () => { + const metaMap = new Map([ + ['scanned_at', DATETIME_META], + ['due', DATE_META], + ]); + const row = { scanned_at: CROSS_MONTH_UTC, due: '2026-08-01' }; + + it('formatRowCells (the CSV / XLSX column path)', () => { + expect(formatRowCells(row, ['scanned_at', 'due'], metaMap, SHANGHAI)) + .toEqual([IN_SHANGHAI, '2026-08-01']); + expect(formatRowCells(row, ['scanned_at', 'due'], metaMap)) + .toEqual([IN_UTC, '2026-08-01']); + }); + + it('formatRowForJson', () => { + expect(formatRowForJson(row, metaMap, SHANGHAI)) + .toMatchObject({ scanned_at: IN_SHANGHAI, due: '2026-08-01' }); + expect(formatRowForJson(row, metaMap)) + .toMatchObject({ scanned_at: IN_UTC, due: '2026-08-01' }); + }); +}); + +// --------------------------------------------------------------------------- +// Route level — the REAL export route over a REAL engine, sqlite `:memory:` +// and the real metadata accessor, mirroring `export-integration.test.ts`. The +// only stub is `resolveExecCtx`, which stands in for the identity + localization +// cascade (`resolveLocalizationContext` → `ExecutionContext.timezone`). +// --------------------------------------------------------------------------- + +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 makeRes() { + const chunks: string[] = []; + const headers: Record = {}; + const res: any = { + write: (s: string) => { chunks.push(typeof s === 'string' ? s : String(s)); return true; }, + end: () => {}, + header: (n: string, v: string) => { headers[n] = v; return res; }, + status: () => res, + json: () => res, + }; + return { res, chunks, headers }; +} + +function makeBinRes() { + const chunks: Buffer[] = []; + const res: any = { + write: (c: any) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); return true; }, + end: () => {}, + header: () => res, + status: () => res, + json: () => res, + }; + return { res, getBuffer: () => Buffer.concat(chunks) }; +} + +/** Boot the real stack; `timezone` is what the resolved ExecutionContext carries. */ +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.registry.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 route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + ); + return route as any; +} + +async function csvRow(timezone?: string): Promise { + const route = await boot(timezone); + const { res, chunks } = makeRes(); + await route.handler({ params: { object: 'shift' }, query: { format: 'csv' } } as any, res); + const lines = chunks.join('').split('\r\n').filter((l) => l.length > 0); + return lines[1].split(','); +} + +async function xlsxRow(timezone?: string): Promise { + const route = await boot(timezone); + const { res, getBuffer } = makeBinRes(); + await route.handler({ params: { object: 'shift' }, query: { format: 'xlsx' } } as any, res); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(getBuffer() as any); + return (wb.worksheets[0].getRow(2).values as any[]).slice(1).map((v) => String(v)); +} + +describe('GET /data/:object/export — the file agrees with the screen', () => { + let sanity: any; + beforeEach(async () => { + sanity = await boot(SHANGHAI); + expect(sanity).toBeDefined(); + }); + + it('CSV: the cross-month row stays in August under Asia/Shanghai', async () => { + const cells = await csvRow(SHANGHAI); + expect(cells).toEqual(['1', IN_SHANGHAI, '2026-08-01']); + // The whole point, stated as the customer would: the row is in the month + // they see on screen, not the previous one. + expect(cells[1].startsWith('2026-08')).toBe(true); + }); + + it('XLSX: the same row, the same clock — not just the CSV path', async () => { + const cells = await xlsxRow(SHANGHAI); + expect(cells).toEqual(['1', IN_SHANGHAI, '2026-08-01']); + expect(cells[1].startsWith('2026-08')).toBe(true); + }); + + it('JSON: shares the formatter, so it shares the clock', async () => { + const route = await boot(SHANGHAI); + const { res, chunks } = makeRes(); + await route.handler({ params: { object: 'shift' }, query: { format: 'json' } } as any, res); + const arr = JSON.parse(chunks.join('')); + expect(arr[0]).toMatchObject({ scanned_at: IN_SHANGHAI, due: '2026-08-01' }); + }); + + it('CSV and XLSX agree cell for cell', async () => { + expect(await csvRow(SHANGHAI)).toEqual(await xlsxRow(SHANGHAI)); + }); + + it('no timezone on the context ⇒ UTC, exactly as before #8373', async () => { + expect(await csvRow()).toEqual(['1', IN_UTC, '2026-08-01']); + expect(await xlsxRow()).toEqual(['1', IN_UTC, '2026-08-01']); + }); + + it('an unknown zone degrades to UTC rather than failing the export', async () => { + expect(await csvRow('Not/AZone')).toEqual(['1', IN_UTC, '2026-08-01']); + }); + + it('the date column is the same calendar day in every zone', async () => { + const shanghai = await csvRow(SHANGHAI); + const newYork = await csvRow('America/New_York'); + const utc = await csvRow(); + expect(shanghai[2]).toBe('2026-08-01'); + expect(newYork[2]).toBe('2026-08-01'); + expect(utc[2]).toBe('2026-08-01'); + }); +}); diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index bed892424b..609f14d99b 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -11,6 +11,12 @@ * Contract: when no field metadata is available (schema lookup failed or carried * no fields) every helper is a pass-through, so the export stays byte-for-byte * identical to the un-formatted behaviour. + * + * Second contract, on the clock (#8373): a `datetime` cell renders in the + * request's business timezone — the `timezone` the route's already-resolved + * `ExecutionContext` carries — and falls back to UTC when there is none, which + * is byte-identical to the pre-#8373 output. A `date` cell is a timezone-naive + * calendar day and never reads it (ADR-0053). See {@link formatDate}. */ export interface ExportFieldMeta { @@ -162,6 +168,74 @@ function pad2(n: number): string { return n < 10 ? `0${n}` : String(n); } +/** + * `Intl.DateTimeFormat` instances keyed by IANA zone, with `null` memoizing a + * zone the platform rejected. A 50k-row export formats one cell per datetime + * column per row, so constructing a formatter per cell is the difference + * between a stream and a stall; the key set is bounded by the deployment's + * configured zones. + */ +const ZONED_FORMATTERS = new Map(); + +function zonedFormatter(timezone: string): Intl.DateTimeFormat | null { + const cached = ZONED_FORMATTERS.get(timezone); + if (cached !== undefined) return cached; + let fmt: Intl.DateTimeFormat | null = null; + try { + fmt = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + // `h23` (not `hour12: false`) — midnight must read `00`, never `24`. + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + fmt = null; // not a valid IANA zone → callers fall back to UTC + } + ZONED_FORMATTERS.set(timezone, fmt); + return fmt; +} + +/** + * The wall clock an instant shows in `timezone` — `YYYY-MM-DD` + `HH:mm:ss`, + * split so callers can use either half. + * + * Reads the calendar components from `Intl.DateTimeFormat().formatToParts()` + * so DST transitions come from the platform's tz database rather than + * hand-rolled offset arithmetic (the same primitive `@objectstack/core`'s + * `calendarPartsInTz` and `@objectstack/spec`'s autonumber date tokens use). + * + * Falls back to the UTC wall clock whenever `timezone` is absent, `'UTC'`, or + * not a zone this platform knows — the pre-#8373 behaviour, kept as the + * backward-compatibility contract for deployments that never set one. + */ +function wallClock(d: Date, timezone?: string): { ymd: string; hms: string } { + if (timezone && timezone !== 'UTC') { + const fmt = zonedFormatter(timezone); + if (fmt) { + const parts = fmt.formatToParts(d); + const get = (t: string) => parts.find((p) => p.type === t)?.value; + const y = get('year'); + const mo = get('month'); + const da = get('day'); + const h = get('hour'); + const mi = get('minute'); + const s = get('second'); + if (y && mo && da && h && mi && s) { + return { ymd: `${y}-${mo}-${da}`, hms: `${h}:${mi}:${s}` }; + } + } + } + return { + ymd: `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`, + hms: `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`, + }; +} + function toDate(value: unknown): Date | null { if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; if (typeof value === 'number' || typeof value === 'string') { @@ -171,13 +245,39 @@ function toDate(value: unknown): Date | null { return null; } -/** `YYYY-MM-DD` (date) or `YYYY-MM-DD HH:mm:ss` (datetime), in UTC. */ -function formatDate(value: unknown, withTime: boolean): unknown { +/** + * `YYYY-MM-DD` (date) or `YYYY-MM-DD HH:mm:ss` (datetime). + * + * The two branches read DIFFERENT clocks, because ADR-0053 gives the two field + * types different meanings: + * + * - **`datetime` is an instant**, rendered in a reference timezone — so it is + * rendered here in the caller's business timezone (`ExecutionContext.timezone`, + * the platform-default → global → tenant cascade), matching what the UI shows. + * Before #8373 this was hardcoded to UTC while the UI rendered the business + * zone, so an export of `2026-08-01 06:00 +08` read `2026-07-31 22:00` — a + * row that crossed a day boundary crossed a MONTH boundary with it, and a + * downstream monthly reconciliation stopped balancing. `getUTC*` ignores the + * process `TZ`, so there was no deployment-side workaround either. + * - **`date` is a timezone-naive calendar day** — never re-projected into a + * zone. `@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/read paths all agree with it. Passing a + * date-only value through a zone would shift `2026-08-01` to `2026-07-31` for + * every deployment west of UTC — inventing the very off-by-one-day defect + * ADR-0053 removed. So this branch is deliberately unchanged. + * + * `timezone` absent (or unknown to the platform) ⇒ UTC, i.e. exactly the + * pre-#8373 output. + */ +function formatDate(value: unknown, withTime: boolean, timezone?: string): unknown { const d = toDate(value); if (!d) return value; - const ymd = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; - if (!withTime) return ymd; - return `${ymd} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; + if (!withTime) { + return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; + } + const { ymd, hms } = wallClock(d, timezone); + return `${ymd} ${hms}`; } function optionLabel(value: unknown, options?: Array<{ label?: string; value?: unknown }>): unknown { @@ -233,8 +333,18 @@ function formatReference(value: unknown, displayField?: string): unknown { return one(value); } -/** Format one storage value into a display value using its field metadata. */ -export function formatCellValue(value: unknown, meta?: ExportFieldMeta): unknown { +/** + * Format one storage value into a display value using its field metadata. + * + * `timezone` is the request's business timezone (`ExecutionContext.timezone`). + * It reaches only the `datetime` branch; absent, the cell renders in UTC — + * see {@link formatDate} for why `date` never reads it at all. + */ +export function formatCellValue( + value: unknown, + meta?: ExportFieldMeta, + timezone?: string, +): unknown { if (value === null || value === undefined) return value; if (!meta || !meta.type) return value; const t = meta.type; @@ -249,18 +359,24 @@ export function formatCellValue(value: unknown, meta?: ExportFieldMeta): unknown return arr.map((v) => optionLabel(v, meta.options)).join(', '); } if (t === 'date') return formatDate(value, false); - if (t === 'datetime') return formatDate(value, true); + if (t === 'datetime') return formatDate(value, true, timezone); if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField); return value; } -/** Ordered display cells for one row — the CSV / XLSX column path. */ +/** + * Ordered display cells for one row — the CSV / XLSX column path. + * + * `timezone` is threaded straight through to {@link formatCellValue}; the + * export route reads it off the `ExecutionContext` it already resolved. + */ export function formatRowCells( row: Record, fields: string[], metaMap: Map, + timezone?: string, ): unknown[] { - return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f))); + return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone)); } /** @@ -271,13 +387,14 @@ export function formatRowCells( export function formatRowForJson( row: Record, metaMap: Map, + timezone?: string, ): Record { if (metaMap.size === 0 || !row || typeof row !== 'object') return row; let copy: Record | null = null; for (const key of Object.keys(row)) { const meta = metaMap.get(key); if (!meta) continue; - const formatted = formatCellValue(row[key], meta); + const formatted = formatCellValue(row[key], meta, timezone); if (formatted !== row[key]) { if (!copy) copy = { ...row }; copy[key] = formatted; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 119ff7b216..afef725643 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1700,17 +1700,21 @@ function formatCsvCell(value: any): string { * the header row uses field labels and cell values are formatted to readable * display values (lookup names, select labels, 是/否, formatted dates). With an * empty map the output is byte-identical to the raw, un-formatted behaviour. + * + * `timezone` (#8373) is the caller's business timezone, used to render + * `datetime` cells in the same clock the UI shows; absent, they render in UTC. */ function rowsToCsv( fields: string[], rows: Array>, includeHeader: boolean, metaMap: Map, + timezone?: string, ): string { const lines: string[] = []; if (includeHeader) lines.push(fields.map(f => formatCsvCell(headerLabel(f, metaMap))).join(',')); for (const row of rows) { - lines.push(formatRowCells(row, fields, metaMap).map(formatCsvCell).join(',')); + lines.push(formatRowCells(row, fields, metaMap, timezone).map(formatCsvCell).join(',')); } return lines.join('\r\n') + (lines.length > 0 ? '\r\n' : ''); } @@ -7819,6 +7823,11 @@ export class RestServer { // their option label, booleans to 是/否, dates to YYYY-MM-DD. When the // schema is unavailable the raw stored values stream through unchanged. // + // [#8373] `datetime` cells render in the caller's BUSINESS timezone + // (`ExecutionContext.timezone`), so the file agrees with the screen; + // with no timezone resolved they render in UTC, as they always did. + // `date` stays a timezone-naive calendar day (ADR-0053). + // // A zero-row result still emits the header row when the column set is // authoritative (the security service's readable projection, or an explicit // `fields=`), so an empty export doubles as an import template. Without a @@ -8010,6 +8019,18 @@ export class RestServer { // (and thus a name). Batched $in inside findData — no N+1. const expandFields = referenceFieldNames(metaMap); + // [#8373] The clock every `datetime` cell below renders in. + // The business timezone is ALREADY on the context resolved + // at the top of this handler (`resolveLocalizationContext`'s + // platform-default → global → tenant cascade, assembled onto + // `ExecutionContext.timezone`) — the export formatter simply + // never asked for it, so every date/datetime column streamed + // UTC while the UI rendered the business zone. `undefined` + // keeps the historical UTC rendering, byte for byte. + const timezone = typeof (context as any)?.timezone === 'string' && (context as any).timezone + ? String((context as any).timezone) + : undefined; + // [#3547] Column projection ≡ list's field-level security — the // LONG-TERM correct path. Ask the security service which fields the // caller may READ under this context (the SAME field mask the read @@ -8119,7 +8140,7 @@ export class RestServer { } if (format === 'csv') { - const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap); + const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap, timezone); res.write(text); } else if (format === 'xlsx') { if (firstChunk && includeHeader) { @@ -8127,7 +8148,7 @@ export class RestServer { } const cols = fields ?? []; for (const row of rows) { - const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap)); + const r = xlsx!.ws.addRow(formatRowCells(row, cols, metaMap, timezone)); if (styled) { cols.forEach((f, i) => { const argb = cellFontColor(row?.[f], metaMap.get(f)); @@ -8139,7 +8160,7 @@ export class RestServer { } else { for (let i = 0; i < rows.length; i++) { const prefix = (firstChunk && i === 0) ? '' : ','; - res.write(prefix + JSON.stringify(formatRowForJson(rows[i], metaMap))); + res.write(prefix + JSON.stringify(formatRowForJson(rows[i], metaMap, timezone))); } } firstChunk = false; From 8e20781fa76f828da24e6e0fe9707d51f07c35d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:32:18 +0000 Subject: [PATCH 2/2] test(rest): type-clean object registration; changeset for the export timezone fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../export-datetime-business-timezone.md | 39 +++++++++++++++++++ .../rest/src/export-business-timezone.test.ts | 5 ++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/export-datetime-business-timezone.md diff --git a/.changeset/export-datetime-business-timezone.md b/.changeset/export-datetime-business-timezone.md new file mode 100644 index 0000000000..62d45c3869 --- /dev/null +++ b/.changeset/export-datetime-business-timezone.md @@ -0,0 +1,39 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): exported `datetime` cells render in the business timezone, not UTC (#8373) + +`GET /api/v1/data/{object}/export` formatted every `datetime` column with +`getUTC*`, while the UI rendered the same field in the business timezone. The +whole file was therefore off by the tenant's offset, on **every** export format +— and the harm was not "a few hours out". A record the screen showed at +`2026/8/1 06:00` (+08) landed in the file as `2026-07-31 22:00:00`: the row left +August. A downstream deployment's monthly reconciliation stopped balancing on +exactly that, and because `getUTC*` ignores the process `TZ`, no deployment-side +setting could work around it. + +The timezone was already resolved on this path and simply never threaded. The +export route opens with `resolveExecCtx`, whose `ExecutionContext` carries +`timezone` from the platform-default → global → tenant localization cascade; the +formatting layer just never asked for it. It now does, reading the calendar +components through `Intl.DateTimeFormat(…, { timeZone })` so DST comes from the +platform tz database rather than hand-rolled offset arithmetic. This brings the +export formatter into line with the ADR-0053 business-timezone semantics that +autonumber date tokens already follow. + +Fixed on all three output formats — CSV, XLSX and JSON — which share one +formatter; the reported symptom reproduced on CSV and XLSX alike. + +**Nothing changes without a resolved timezone.** No `timezone` on the context (or +one this platform does not recognise) keeps the previous UTC rendering, byte for +byte, so a deployment that never configured one sees the same files as before. + +**`date` columns are deliberately untouched.** Under ADR-0053 a `date` is a +timezone-naive calendar day — `@objectstack/driver-sql`'s `toDateOnly` is the +source of truth 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. Only `datetime`, which ADR-0053 +defines as an instant rendered in a reference timezone, follows the business +zone. diff --git a/packages/rest/src/export-business-timezone.test.ts b/packages/rest/src/export-business-timezone.test.ts index c7bf6223ac..c0a7be43c3 100644 --- a/packages/rest/src/export-business-timezone.test.ts +++ b/packages/rest/src/export-business-timezone.test.ts @@ -202,7 +202,10 @@ async function boot(timezone?: string) { true, ); await engine.init(); - engine.registry.registerObject(SHIFT as any); + // The engine facade (not `registry.registerObject`, which demands a + // packageId): one argument, sensible defaults, and it type-checks — this + // package's test layer sits at its `check:type-check-debt` ceiling. + engine.registerObject(SHIFT as any); await engine.syncSchemas(); await engine.insert('shift', { id: '1', scanned_at: CROSS_MONTH_UTC, due: '2026-08-01' });