From 9415820c8f32afca078944dfa6758db46a70cd4a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:19:19 +0800 Subject: [PATCH 1/2] fix(rest): /export writes a date or datetime cell with a four-digit year, so a year below 1000 re-imports Every export date and datetime cell now takes its day from core's temporalStorageForm date rule (imported), which pads 0001..0999 and leaves a year outside 0001..9999 unpadded. The business-timezone path reads the zone's year from the instant, never from Intl's era year. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .changeset/20602-export-year-four-digits.md | 23 ++ .../rest/src/export-date-year-pad.test.ts | 281 ++++++++++++++++++ packages/rest/src/export-format.ts | 49 ++- 3 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 .changeset/20602-export-year-four-digits.md create mode 100644 packages/rest/src/export-date-year-pad.test.ts diff --git a/.changeset/20602-export-year-four-digits.md b/.changeset/20602-export-year-four-digits.md new file mode 100644 index 00000000000..5ac960fd6c7 --- /dev/null +++ b/.changeset/20602-export-year-four-digits.md @@ -0,0 +1,23 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): `GET /api/v1/data/:object/export` writes a `date` or `datetime` cell with a four-digit year, so an export of a year from 0001 to 0999 re-imports (#20602) + +Clause-②: no + +A `date` of `0500-01-01` exported as `500-01-01`, and a `datetime` on that day +as `500-01-01 10:00:00` (or that day's wall clock in the business timezone), in +CSV, xlsx and JSON alike. `POST /api/v1/data/:object/import` reads a four-digit +year only, so re-importing the platform's own file refused that row as +`invalid_date`. The export now spells every `date` and `datetime` cell's day +with the storage rule the write doors use (`temporalStorageForm` from +`@objectstack/core`): `0500-01-01` and `0500-01-01 10:00:00`, which the import +reads back as the same day and instant. + +**What is not affected.** Every cell in the years 1000 to 9999 exports byte for +byte as before, in every business timezone and with none. The clock of a +`datetime` cell is unchanged. A year outside 0001 to 9999, which the write +doors refuse, stays unpadded, and a `datetime` whose business-timezone day +falls in such a year now spells that year as the storage rule does (`0-12-31`, +not the era year `1-12-31`); the import refuses both spellings, as before. diff --git a/packages/rest/src/export-date-year-pad.test.ts b/packages/rest/src/export-date-year-pad.test.ts new file mode 100644 index 00000000000..08df76de692 --- /dev/null +++ b/packages/rest/src/export-date-year-pad.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20602] `GET /api/v1/data/:object/export` spells a `date` or `datetime` + * cell's year with four digits, so the platform's own export re-imports + * through `POST /api/v1/data/:object/import`. + * + * The export read the year as `getUTCFullYear()` or as `Intl`'s `year` part, + * both unpadded numbers, so a `date` `0500-01-01` left as `500-01-01` and a + * `datetime` `0500-01-01T10:00:00.000Z` as `500-01-01 10:00:00`. The import + * reader takes a four-digit year only (#20534), so re-importing the file + * refused the row as `invalid_date`. Every export `date` and `datetime` cell + * now takes its day from core's `temporalStorageForm` `date` rule, the storage + * form the doors write, which pads 0001..0999 and leaves a year outside + * 0001..9999 unpadded. + * + * Two layers: + * + * - **The formatter** (`formatCellValue`, the one path CSV, xlsx and JSON + * share): a census over the years 0001, 0050, 0500, 0999, 1000, 2026 and + * 9999, `date` and `datetime`, with and without a business timezone. The + * 1000, 2026 and 9999 cells are the pre-#20602 output, byte for byte. + * - **The routes**: rows written through the create door, exported as CSV, + * xlsx and JSON, and re-imported into a fresh stack through the import door, + * store the same `date` and `datetime` values, under no business timezone, + * Asia/Shanghai and America/New_York. + * + * Not pinned here: the `datetime` round trip for years 0001..0099. The export + * spells those padded (the census below), and the import reads them back a + * century late, through `Date.UTC`'s two-digit-year remap in core's + * `zonedWallClockToUtcMs`. That is #20599's, a different defect. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +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 } from './export-format.js'; +import type { ExportFieldMeta } from './export-format.js'; +import { loadXlsxWorkbook } from './xlsx-test-loader.js'; + +const DATE: ExportFieldMeta = { name: 'd', type: 'date' }; +const DATETIME: ExportFieldMeta = { name: 'dt', type: 'datetime' }; + +const YEARS = ['0001', '0050', '0500', '0999', '1000', '2026', '9999'] as const; + +const ZONES = [undefined, 'UTC', 'Asia/Shanghai', 'America/New_York', 'Not/AZone'] as const; + +// --------------------------------------------------------------------------- +// The formatter — the one cell path CSV, xlsx and JSON share. +// --------------------------------------------------------------------------- + +describe('[#20602] formatCellValue spells a four-digit year for every date and datetime', () => { + describe.each(ZONES)('business timezone %s', (zone) => { + it.each(YEARS)('date %s-01-01 exports as itself', (year) => { + expect(formatCellValue(`${year}-01-01`, DATE, zone)).toBe(`${year}-01-01`); + }); + + it.each(YEARS)('datetime %s-01-01T10:00:00.000Z exports on its own day', (year) => { + const cell = formatCellValue(`${year}-01-01T10:00:00.000Z`, DATETIME, zone); + expect(cell).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + expect(String(cell).slice(0, 11)).toBe(`${year}-01-01 `); + }); + }); + + it.each(YEARS)('datetime %s-01-01T10:00:00.000Z reads 10:00:00 with no zone, UTC or an unknown zone', (year) => { + for (const zone of [undefined, 'UTC', 'Not/AZone']) { + expect(formatCellValue(`${year}-01-01T10:00:00.000Z`, DATETIME, zone)).toBe(`${year}-01-01 10:00:00`); + } + }); + + it('keeps the 2026 control exactly as it was, in every zone', () => { + expect(formatCellValue('2026-07-15', DATE, 'Asia/Shanghai')).toBe('2026-07-15'); + expect(formatCellValue('2026-07-15T10:00:00.000Z', DATETIME)).toBe('2026-07-15 10:00:00'); + expect(formatCellValue('2026-07-15T10:00:00.000Z', DATETIME, 'Asia/Shanghai')).toBe('2026-07-15 18:00:00'); + expect(formatCellValue('2026-07-15T10:00:00.000Z', DATETIME, 'America/New_York')).toBe('2026-07-15 06:00:00'); + }); + + it('pads a Date and an epoch-ms value the same way', () => { + const instant = Date.parse('0500-01-01T10:00:00.000Z'); + expect(formatCellValue(new Date(instant), DATE)).toBe('0500-01-01'); + expect(formatCellValue(instant, DATE)).toBe('0500-01-01'); + expect(formatCellValue(new Date(instant), DATETIME)).toBe('0500-01-01 10:00:00'); + expect(formatCellValue(instant, DATETIME)).toBe('0500-01-01 10:00:00'); + }); + + // A zone's calendar day can sit in the year either side of the UTC one. Its + // year is the instant's, never `Intl`'s `year` part: that is an ERA year, and + // year 0 (1 BC) reads `1` there. + it('takes the year the zone has reached across a year boundary', () => { + expect(String(formatCellValue('0999-12-31T23:30:00.000Z', DATETIME, 'Asia/Shanghai')).slice(0, 11)).toBe('1000-01-01 '); + expect(String(formatCellValue('1000-01-01T02:00:00.000Z', DATETIME, 'America/New_York')).slice(0, 11)).toBe('0999-12-31 '); + // Year 1 in UTC, still year 0 in New York: year 0 has no four-digit form, + // so it is spelled unpadded as the storage rule spells it and the import + // refuses it. Padding `Intl`'s era year would spell the last day of year 1, + // a date a year later that the import would take. + const cell = String(formatCellValue('0001-01-01T03:00:00.000Z', DATETIME, 'America/New_York')); + expect(cell.startsWith('0-12-31 ')).toBe(true); + expect(cell.startsWith('0001-')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// The routes — create, export, then import into a fresh stack. +// --------------------------------------------------------------------------- + +const OBJECT = 'export_year_pad_20602'; + +const LEDGER = { + name: OBJECT, label: 'Ledger 20602', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + d: { name: 'd', type: 'date' as const, label: 'Day' }, + dt: { name: 'dt', type: 'datetime' as const, label: 'At' }, + }, +}; + +const MAPPING = { ID: 'id', Day: 'd', At: 'dt' }; + +/** + * One row per year: the day `Y-01-01` and the instant at 10:00 UTC on it. The + * rows for 0001 and 0050 carry no `datetime`: its import is #20599's (the + * module note). + */ +const ROWS = YEARS.map((year) => ({ + year, + id: `y${year}`, + d: `${year}-01-01`, + dt: year === '0001' || year === '0050' ? undefined : `${year}-01-01T10:00:00.000Z`, +})); + +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: Buffer[] = []; + const res: any = { + write: (c: unknown) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))); return true; }, + end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + res._buffer = () => Buffer.concat(chunks); + return res; +} + +async function boot(timezone: string | undefined, engines: ObjectQL[]) { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, + }), true); + await engine.init(); + engine.registerObject(LEDGER as any); + await engine.syncSchemas(); + 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 = (method: string, path: string) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path) as any; + expect(found, `${method} ${path}`).toBeDefined(); + return found; + }; + const createRoute = route('POST', '/api/v1/data/:object'); + const exportRoute = route('GET', '/api/v1/data/:object/export'); + const importRoute = route('POST', '/api/v1/data/:object/import'); + const send = async (r: any, req: Record) => { + const res = makeRes(); + await r.handler({ params: { object: OBJECT }, ...req } as any, res); + return res; + }; + return { + engine, + create: (body: Record) => send(createRoute, { body }), + exportAs: (format: 'csv' | 'xlsx' | 'json') => send(exportRoute, { query: { format } }), + importBody: (body: Record) => send(importRoute, { body }), + }; +} + +/** Each row's exported `date` and `datetime` cells, by row id. */ +async function exportedCells(format: 'csv' | 'xlsx' | 'json', buffer: Buffer): Promise> { + const cells = new Map(); + if (format === 'json') { + for (const row of JSON.parse(buffer.toString('utf8'))) { + cells.set(String(row.id), { d: String(row.d ?? ''), dt: String(row.dt ?? '') }); + } + return cells; + } + if (format === 'csv') { + const [header, ...lines] = buffer.toString('utf8').split('\r\n').filter((l) => l.length > 0); + expect(header).toBe('ID,Day,At'); + for (const line of lines) { + const [id, d, dt] = line.split(','); + cells.set(id, { d, dt: dt ?? '' }); + } + return cells; + } + const ws = (await loadXlsxWorkbook(buffer)).worksheets[0]; + expect((ws.getRow(1).values as unknown[]).slice(1)).toEqual(['ID', 'Day', 'At']); + for (let r = 2; r <= ws.rowCount; r++) { + const [id, d, dt] = (ws.getRow(r).values as unknown[]).slice(1); + // A text cell, never a Date: the import reads the export's own spelling. + expect(typeof d).toBe('string'); + cells.set(String(id), { d: String(d), dt: dt == null ? '' : String(dt) }); + } + return cells; +} + +function importBodyFor(format: 'csv' | 'xlsx' | 'json', buffer: Buffer): Record { + if (format === 'json') return { format, rows: JSON.parse(buffer.toString('utf8')), writeMode: 'insert' }; + if (format === 'csv') return { format, csv: buffer.toString('utf8'), mapping: MAPPING, writeMode: 'insert' }; + return { format, xlsxBase64: buffer.toString('base64'), mapping: MAPPING, writeMode: 'insert' }; +} + +/** The 2026 control's `datetime` cell, as it exported before #20602. */ +const CONTROL_DATETIME_CELL: Record = { + none: '2026-01-01 10:00:00', + 'Asia/Shanghai': '2026-01-01 18:00:00', + 'America/New_York': '2026-01-01 05:00:00', +}; + +const BUSINESS_ZONES = [undefined, 'Asia/Shanghai', 'America/New_York'] as const; +const FORMATS = ['csv', 'xlsx', 'json'] as const; + +describe.each(BUSINESS_ZONES)('[#20602] GET /export then POST /import, business timezone %s', (zone) => { + describe.each(FORMATS)('format %s', (format) => { + const engines: ObjectQL[] = []; + let source: Awaited>; + let target: Awaited>; + let cells: Map; + let imported: any; + + beforeAll(async () => { + source = await boot(zone, engines); + for (const row of ROWS) { + const created = await source.create({ id: row.id, d: row.d, ...(row.dt ? { dt: row.dt } : {}) }); + expect(created._status ?? 201, `create ${row.id}`).toBe(201); + } + const exported = await source.exportAs(format); + expect(exported._status ?? 200).toBe(200); + cells = await exportedCells(format, exported._buffer()); + expect([...cells.keys()].sort()).toEqual(ROWS.map((r) => r.id).sort()); + + target = await boot(zone, engines); + imported = (await target.importBody(importBodyFor(format, exported._buffer())))._json; + expect(imported).toMatchObject({ total: ROWS.length }); + }); + + afterAll(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } + }); + + it.each(ROWS)('exports the $year row with a four-digit year and re-imports it unchanged', async (row) => { + const cell = cells.get(row.id)!; + expect(cell.d).toBe(row.d); + if (row.dt) { + expect(cell.dt.slice(0, 11)).toBe(`${row.year}-01-01 `); + if (row.year === '2026') expect(cell.dt).toBe(CONTROL_DATETIME_CELL[zone ?? 'none']); + } else { + expect(cell.dt).toBe(''); + } + + // The import's per-row result sits at the row's position in the file. + const position = [...cells.keys()].indexOf(row.id) + 1; + const refusal = imported.results?.find((r: any) => r.row === position && r.ok === false); + expect(refusal, `the import refused the ${row.year} row`).toBeUndefined(); + const back = await target.engine.findOne(OBJECT, { where: { id: row.id } }); + const src = await source.engine.findOne(OBJECT, { where: { id: row.id } }); + expect(src?.d).toBe(row.d); + expect({ d: back?.d, dt: back?.dt ?? null }).toEqual({ d: src?.d, dt: src?.dt ?? null }); + if (row.dt) expect(back?.dt).toBe(row.dt); + }); + }); +}); diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index 5791dbaeb33..8904e5c4b1b 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -24,8 +24,14 @@ * is the PROCESS-LOCAL clock, not UTC — see {@link exportContentDisposition} * for why the two contracts deliberately differ, and {@link zonedWallClock} * for where that choice is left to each caller. + * + * Fourth contract, on the year (#20602): every `date` and `datetime` cell takes + * its day from core's `temporalStorageForm` `date` rule — see + * {@link calendarDay} — so the year keeps four digits and the export re-imports. */ +import { temporalStorageForm } from '@objectstack/core'; + export interface ExportFieldMeta { name: string; type?: string; @@ -228,10 +234,29 @@ function zonedFormatter(timezone: string): Intl.DateTimeFormat | null { return fmt; } +/** + * [#20602] The `YYYY-MM-DD` of `day`'s UTC calendar day, spelled by core's + * `temporalStorageForm` `date` rule, imported rather than mirrored: the + * storage form the write doors keep and the one `/import`'s reader + * (`parseDateCell`) spells an instant's day with. Every export `date` and + * `datetime` cell takes its day from here. + * + * The rule pads the year to four digits. `getUTCFullYear()` and `Intl`'s + * `year` part are unpadded numbers, so a `date` `0500-01-01` used to export as + * `500-01-01` and a `datetime` on that day as `500-01-01 10:00:00`, and + * `/import`, which reads a four-digit year only, refused the file's row as + * `invalid_date`. A year outside 0001..9999 has no `YYYY-MM-DD` form: the rule + * leaves it unpadded (`0-12-31`, `10000-01-01`), as the export always spelled + * it, and the import refuses it. + */ +function calendarDay(day: Date): string { + return String(temporalStorageForm(day, 'date')); +} + /** The UTC wall clock of an instant — `YYYY-MM-DD` + `HH:mm:ss`. */ function utcWallClock(d: Date): { ymd: string; hms: string } { return { - ymd: `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`, + ymd: calendarDay(d), hms: `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`, }; } @@ -272,7 +297,20 @@ function zonedWallClock(d: Date, timezone?: string): { ymd: string; hms: string const mi = get('minute'); const s = get('second'); if (!(y && mo && da && h && mi && s)) return null; - return { ymd: `${y}-${mo}-${da}`, hms: `${h}:${mi}:${s}` }; + // [#20602] The zone's calendar day, spelled by the same rule as every other + // cell ({@link calendarDay}). Its year is NOT `Intl`'s `year` part, which is + // an ERA year: year 0 (1 BC) reads `1` there, so padding it would spell + // `0001-01-01T03:00Z` in New York as the last day of year 1, a date a year + // later that `/import` would take. An offset is under a day, so the zone's + // year is the instant's UTC year, one more when the zone has reached January + // and UTC is still in December, one less the other way round. + const month = Number(mo); + const utcMonth = d.getUTCMonth() + 1; + const year = d.getUTCFullYear() + (month === 1 && utcMonth === 12 ? 1 : month === 12 && utcMonth === 1 ? -1 : 0); + const day = new Date(0); + // `setUTCFullYear`, never `Date.UTC`, which reads a year 0..99 as 1900..1999. + day.setUTCFullYear(year, month - 1, Number(da)); + return { ymd: calendarDay(day), hms: `${h}:${mi}:${s}` }; } /** @@ -319,13 +357,14 @@ function toDate(value: unknown): Date | null { * * `timezone` absent (or unknown to the platform) ⇒ UTC, i.e. exactly the * pre-#8373 output. + * + * Both branches spell the day through {@link calendarDay}, so the year keeps + * four digits (#20602). */ function formatDate(value: unknown, withTime: boolean, timezone?: string): unknown { const d = toDate(value); if (!d) return value; - if (!withTime) { - return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; - } + if (!withTime) return calendarDay(d); const { ymd, hms } = wallClock(d, timezone); return `${ymd} ${hms}`; } From 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:58:52 +0000 Subject: [PATCH 2/2] test(rest): the export round-trip pin follows the datetime year-1000 floor After merging main, the create door refuses a datetime before year 1000 (the floor landed with PR #20843), so the pin's route rows for 0500 and 0999 could no longer be created and the whole route layer went red. The route rows before 1000 now carry a date only, 0099 joins the years, and a boundary row pins the one datetime cell the export's padding still reaches at the routes: 1000-01-01T02:00:00.000Z, which America/New_York reads on 0999-12-31. Two comments and the changeset no longer say the import would take a padded datetime before year 1000: it refuses one now, as the write doors do. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20602-export-year-four-digits.md | 25 +++++---- .../rest/src/export-date-year-pad.test.ts | 56 ++++++++++++------- packages/rest/src/export-format.ts | 4 +- 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/.changeset/20602-export-year-four-digits.md b/.changeset/20602-export-year-four-digits.md index 5ac960fd6c7..d0d2f7541bb 100644 --- a/.changeset/20602-export-year-four-digits.md +++ b/.changeset/20602-export-year-four-digits.md @@ -6,18 +6,21 @@ fix(rest): `GET /api/v1/data/:object/export` writes a `date` or `datetime` cell Clause-②: no -A `date` of `0500-01-01` exported as `500-01-01`, and a `datetime` on that day -as `500-01-01 10:00:00` (or that day's wall clock in the business timezone), in -CSV, xlsx and JSON alike. `POST /api/v1/data/:object/import` reads a four-digit +A `date` of `0500-01-01` exported as `500-01-01`, in CSV, xlsx and JSON alike, +and so did the day of a `datetime` cell whose business-timezone day fell before +year 1000: the instant `1000-01-01T02:00:00.000Z` exported in America/New_York +as `999-12-31 21:03:58`. `POST /api/v1/data/:object/import` reads a four-digit year only, so re-importing the platform's own file refused that row as `invalid_date`. The export now spells every `date` and `datetime` cell's day with the storage rule the write doors use (`temporalStorageForm` from -`@objectstack/core`): `0500-01-01` and `0500-01-01 10:00:00`, which the import -reads back as the same day and instant. +`@objectstack/core`): `0500-01-01` and `0999-12-31 21:03:58`, which the import +reads back as the same day and the same instant. -**What is not affected.** Every cell in the years 1000 to 9999 exports byte for -byte as before, in every business timezone and with none. The clock of a -`datetime` cell is unchanged. A year outside 0001 to 9999, which the write -doors refuse, stays unpadded, and a `datetime` whose business-timezone day -falls in such a year now spells that year as the storage rule does (`0-12-31`, -not the era year `1-12-31`); the import refuses both spellings, as before. +**What is not affected.** Every cell whose day falls in the years 1000 to 9999 +exports byte for byte as before, in every business timezone and with none. The +clock of a `datetime` cell is unchanged. A `datetime` stored before year 1000, +which the write doors now refuse, exports with a padded year as well, and the +import refuses it as `invalid_date`, as the write doors do. A year outside 0001 +to 9999 stays unpadded, and a `datetime` whose business-timezone day falls in +such a year now spells that year as the storage rule does (`0-12-31`, not the +era year `1-12-31`); the import refuses both spellings, as before. diff --git a/packages/rest/src/export-date-year-pad.test.ts b/packages/rest/src/export-date-year-pad.test.ts index 08df76de692..7e8d9035868 100644 --- a/packages/rest/src/export-date-year-pad.test.ts +++ b/packages/rest/src/export-date-year-pad.test.ts @@ -17,18 +17,19 @@ * Two layers: * * - **The formatter** (`formatCellValue`, the one path CSV, xlsx and JSON - * share): a census over the years 0001, 0050, 0500, 0999, 1000, 2026 and - * 9999, `date` and `datetime`, with and without a business timezone. The - * 1000, 2026 and 9999 cells are the pre-#20602 output, byte for byte. + * share): a census over the years 0001, 0050, 0099, 0500, 0999, 1000, 2026 + * and 9999, `date` and `datetime`, with and without a business timezone. + * The 1000, 2026 and 9999 cells are the pre-#20602 output, byte for byte. * - **The routes**: rows written through the create door, exported as CSV, * xlsx and JSON, and re-imported into a fresh stack through the import door, * store the same `date` and `datetime` values, under no business timezone, * Asia/Shanghai and America/New_York. * - * Not pinned here: the `datetime` round trip for years 0001..0099. The export - * spells those padded (the census below), and the import reads them back a - * century late, through `Date.UTC`'s two-digit-year remap in core's - * `zonedWallClockToUtcMs`. That is #20599's, a different defect. + * A `datetime` names a year from 1000 (#20280): the create door refuses an + * earlier one, so the route rows before 1000 carry a `date` only. The one + * `datetime` cell the padding still reaches at the routes is an instant from + * 1000 on whose business-timezone day is in 0999: the boundary row, at + * `1000-01-01T02:00:00.000Z`, which America/New_York reads on 0999-12-31. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -43,7 +44,7 @@ import { loadXlsxWorkbook } from './xlsx-test-loader.js'; const DATE: ExportFieldMeta = { name: 'd', type: 'date' }; const DATETIME: ExportFieldMeta = { name: 'dt', type: 'datetime' }; -const YEARS = ['0001', '0050', '0500', '0999', '1000', '2026', '9999'] as const; +const YEARS = ['0001', '0050', '0099', '0500', '0999', '1000', '2026', '9999'] as const; const ZONES = [undefined, 'UTC', 'Asia/Shanghai', 'America/New_York', 'Not/AZone'] as const; @@ -94,7 +95,7 @@ describe('[#20602] formatCellValue spells a four-digit year for every date and d // Year 1 in UTC, still year 0 in New York: year 0 has no four-digit form, // so it is spelled unpadded as the storage rule spells it and the import // refuses it. Padding `Intl`'s era year would spell the last day of year 1, - // a date a year later that the import would take. + // a day a year later than the instant's. const cell = String(formatCellValue('0001-01-01T03:00:00.000Z', DATETIME, 'America/New_York')); expect(cell.startsWith('0-12-31 ')).toBe(true); expect(cell.startsWith('0001-')).toBe(false); @@ -118,17 +119,32 @@ const LEDGER = { const MAPPING = { ID: 'id', Day: 'd', At: 'dt' }; +/** [#20280] The first year the create door takes for a `datetime`. */ +const DATETIME_FIRST_YEAR = 1000; + +type Row = { year: string; id: string; d: string; dt?: string; dtDay: (zone: string | undefined) => string }; + /** - * One row per year: the day `Y-01-01` and the instant at 10:00 UTC on it. The - * rows for 0001 and 0050 carry no `datetime`: its import is #20599's (the - * module note). + * One row per year: the day `Y-01-01` and, from 1000 on, the instant at 10:00 + * UTC on it (the module note). Then the boundary row: the instant 02:00 UTC on + * 1000-01-01, which America/New_York reads on 0999-12-31. */ -const ROWS = YEARS.map((year) => ({ - year, - id: `y${year}`, - d: `${year}-01-01`, - dt: year === '0001' || year === '0050' ? undefined : `${year}-01-01T10:00:00.000Z`, -})); +const ROWS: Row[] = [ + ...YEARS.map((year) => ({ + year, + id: `y${year}`, + d: `${year}-01-01`, + dt: Number(year) >= DATETIME_FIRST_YEAR ? `${year}-01-01T10:00:00.000Z` : undefined, + dtDay: () => `${year}-01-01`, + })), + { + year: '0999', + id: 'y0999-boundary', + d: '0999-12-31', + dt: '1000-01-01T02:00:00.000Z', + dtDay: (zone) => (zone === 'America/New_York' ? '0999-12-31' : '1000-01-01'), + }, +]; function createMockServer() { const noop = () => {}; @@ -257,11 +273,11 @@ describe.each(BUSINESS_ZONES)('[#20602] GET /export then POST /import, business } }); - it.each(ROWS)('exports the $year row with a four-digit year and re-imports it unchanged', async (row) => { + it.each(ROWS)('exports the $id row with a four-digit year and re-imports it unchanged', async (row) => { const cell = cells.get(row.id)!; expect(cell.d).toBe(row.d); if (row.dt) { - expect(cell.dt.slice(0, 11)).toBe(`${row.year}-01-01 `); + expect(cell.dt.slice(0, 11)).toBe(`${row.dtDay(zone)} `); if (row.year === '2026') expect(cell.dt).toBe(CONTROL_DATETIME_CELL[zone ?? 'none']); } else { expect(cell.dt).toBe(''); diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index 8904e5c4b1b..d318ab58eb9 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -300,8 +300,8 @@ function zonedWallClock(d: Date, timezone?: string): { ymd: string; hms: string // [#20602] The zone's calendar day, spelled by the same rule as every other // cell ({@link calendarDay}). Its year is NOT `Intl`'s `year` part, which is // an ERA year: year 0 (1 BC) reads `1` there, so padding it would spell - // `0001-01-01T03:00Z` in New York as the last day of year 1, a date a year - // later that `/import` would take. An offset is under a day, so the zone's + // `0001-01-01T03:00Z` in New York as the last day of year 1, a day a year + // later than the instant's. An offset is under a day, so the zone's // year is the instant's UTC year, one more when the zone has reached January // and UTC is still in December, one less the other way round. const month = Number(mo);