Skip to content

Commit 9415820

Browse files
hotlongclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 6981abf commit 9415820

3 files changed

Lines changed: 348 additions & 5 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
A `date` of `0500-01-01` exported as `500-01-01`, and a `datetime` on that day
10+
as `500-01-01 10:00:00` (or that day's wall clock in the business timezone), in
11+
CSV, xlsx and JSON alike. `POST /api/v1/data/:object/import` reads a four-digit
12+
year only, so re-importing the platform's own file refused that row as
13+
`invalid_date`. The export now spells every `date` and `datetime` cell's day
14+
with the storage rule the write doors use (`temporalStorageForm` from
15+
`@objectstack/core`): `0500-01-01` and `0500-01-01 10:00:00`, which the import
16+
reads back as the same day and instant.
17+
18+
**What is not affected.** Every cell in the years 1000 to 9999 exports byte for
19+
byte as before, in every business timezone and with none. The clock of a
20+
`datetime` cell is unchanged. A year outside 0001 to 9999, which the write
21+
doors refuse, stays unpadded, and a `datetime` whose business-timezone day
22+
falls in such a year now spells that year as the storage rule does (`0-12-31`,
23+
not the era year `1-12-31`); the import refuses both spellings, as before.
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20602] `GET /api/v1/data/:object/export` spells a `date` or `datetime`
5+
* cell's year with four digits, so the platform's own export re-imports
6+
* through `POST /api/v1/data/:object/import`.
7+
*
8+
* The export read the year as `getUTCFullYear()` or as `Intl`'s `year` part,
9+
* both unpadded numbers, so a `date` `0500-01-01` left as `500-01-01` and a
10+
* `datetime` `0500-01-01T10:00:00.000Z` as `500-01-01 10:00:00`. The import
11+
* reader takes a four-digit year only (#20534), so re-importing the file
12+
* refused the row as `invalid_date`. Every export `date` and `datetime` cell
13+
* now takes its day from core's `temporalStorageForm` `date` rule, the storage
14+
* form the doors write, which pads 0001..0999 and leaves a year outside
15+
* 0001..9999 unpadded.
16+
*
17+
* Two layers:
18+
*
19+
* - **The formatter** (`formatCellValue`, the one path CSV, xlsx and JSON
20+
* share): a census over the years 0001, 0050, 0500, 0999, 1000, 2026 and
21+
* 9999, `date` and `datetime`, with and without a business timezone. The
22+
* 1000, 2026 and 9999 cells are the pre-#20602 output, byte for byte.
23+
* - **The routes**: rows written through the create door, exported as CSV,
24+
* xlsx and JSON, and re-imported into a fresh stack through the import door,
25+
* store the same `date` and `datetime` values, under no business timezone,
26+
* Asia/Shanghai and America/New_York.
27+
*
28+
* Not pinned here: the `datetime` round trip for years 0001..0099. The export
29+
* spells those padded (the census below), and the import reads them back a
30+
* century late, through `Date.UTC`'s two-digit-year remap in core's
31+
* `zonedWallClockToUtcMs`. That is #20599's, a different defect.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
38+
import { RestServer } from './rest-server.js';
39+
import { formatCellValue } from './export-format.js';
40+
import type { ExportFieldMeta } from './export-format.js';
41+
import { loadXlsxWorkbook } from './xlsx-test-loader.js';
42+
43+
const DATE: ExportFieldMeta = { name: 'd', type: 'date' };
44+
const DATETIME: ExportFieldMeta = { name: 'dt', type: 'datetime' };
45+
46+
const YEARS = ['0001', '0050', '0500', '0999', '1000', '2026', '9999'] as const;
47+
48+
const ZONES = [undefined, 'UTC', 'Asia/Shanghai', 'America/New_York', 'Not/AZone'] as const;
49+
50+
// ---------------------------------------------------------------------------
51+
// The formatter — the one cell path CSV, xlsx and JSON share.
52+
// ---------------------------------------------------------------------------
53+
54+
describe('[#20602] formatCellValue spells a four-digit year for every date and datetime', () => {
55+
describe.each(ZONES)('business timezone %s', (zone) => {
56+
it.each(YEARS)('date %s-01-01 exports as itself', (year) => {
57+
expect(formatCellValue(`${year}-01-01`, DATE, zone)).toBe(`${year}-01-01`);
58+
});
59+
60+
it.each(YEARS)('datetime %s-01-01T10:00:00.000Z exports on its own day', (year) => {
61+
const cell = formatCellValue(`${year}-01-01T10:00:00.000Z`, DATETIME, zone);
62+
expect(cell).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
63+
expect(String(cell).slice(0, 11)).toBe(`${year}-01-01 `);
64+
});
65+
});
66+
67+
it.each(YEARS)('datetime %s-01-01T10:00:00.000Z reads 10:00:00 with no zone, UTC or an unknown zone', (year) => {
68+
for (const zone of [undefined, 'UTC', 'Not/AZone']) {
69+
expect(formatCellValue(`${year}-01-01T10:00:00.000Z`, DATETIME, zone)).toBe(`${year}-01-01 10:00:00`);
70+
}
71+
});
72+
73+
it('keeps the 2026 control exactly as it was, in every zone', () => {
74+
expect(formatCellValue('2026-07-15', DATE, 'Asia/Shanghai')).toBe('2026-07-15');
75+
expect(formatCellValue('2026-07-15T10:00:00.000Z', DATETIME)).toBe('2026-07-15 10:00:00');
76+
expect(formatCellValue('2026-07-15T10:00:00.000Z', DATETIME, 'Asia/Shanghai')).toBe('2026-07-15 18:00:00');
77+
expect(formatCellValue('2026-07-15T10:00:00.000Z', DATETIME, 'America/New_York')).toBe('2026-07-15 06:00:00');
78+
});
79+
80+
it('pads a Date and an epoch-ms value the same way', () => {
81+
const instant = Date.parse('0500-01-01T10:00:00.000Z');
82+
expect(formatCellValue(new Date(instant), DATE)).toBe('0500-01-01');
83+
expect(formatCellValue(instant, DATE)).toBe('0500-01-01');
84+
expect(formatCellValue(new Date(instant), DATETIME)).toBe('0500-01-01 10:00:00');
85+
expect(formatCellValue(instant, DATETIME)).toBe('0500-01-01 10:00:00');
86+
});
87+
88+
// A zone's calendar day can sit in the year either side of the UTC one. Its
89+
// year is the instant's, never `Intl`'s `year` part: that is an ERA year, and
90+
// year 0 (1 BC) reads `1` there.
91+
it('takes the year the zone has reached across a year boundary', () => {
92+
expect(String(formatCellValue('0999-12-31T23:30:00.000Z', DATETIME, 'Asia/Shanghai')).slice(0, 11)).toBe('1000-01-01 ');
93+
expect(String(formatCellValue('1000-01-01T02:00:00.000Z', DATETIME, 'America/New_York')).slice(0, 11)).toBe('0999-12-31 ');
94+
// Year 1 in UTC, still year 0 in New York: year 0 has no four-digit form,
95+
// so it is spelled unpadded as the storage rule spells it and the import
96+
// refuses it. Padding `Intl`'s era year would spell the last day of year 1,
97+
// a date a year later that the import would take.
98+
const cell = String(formatCellValue('0001-01-01T03:00:00.000Z', DATETIME, 'America/New_York'));
99+
expect(cell.startsWith('0-12-31 ')).toBe(true);
100+
expect(cell.startsWith('0001-')).toBe(false);
101+
});
102+
});
103+
104+
// ---------------------------------------------------------------------------
105+
// The routes — create, export, then import into a fresh stack.
106+
// ---------------------------------------------------------------------------
107+
108+
const OBJECT = 'export_year_pad_20602';
109+
110+
const LEDGER = {
111+
name: OBJECT, label: 'Ledger 20602', systemFields: false,
112+
fields: {
113+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
114+
d: { name: 'd', type: 'date' as const, label: 'Day' },
115+
dt: { name: 'dt', type: 'datetime' as const, label: 'At' },
116+
},
117+
};
118+
119+
const MAPPING = { ID: 'id', Day: 'd', At: 'dt' };
120+
121+
/**
122+
* One row per year: the day `Y-01-01` and the instant at 10:00 UTC on it. The
123+
* rows for 0001 and 0050 carry no `datetime`: its import is #20599's (the
124+
* module note).
125+
*/
126+
const ROWS = YEARS.map((year) => ({
127+
year,
128+
id: `y${year}`,
129+
d: `${year}-01-01`,
130+
dt: year === '0001' || year === '0050' ? undefined : `${year}-01-01T10:00:00.000Z`,
131+
}));
132+
133+
function createMockServer() {
134+
const noop = () => {};
135+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
136+
}
137+
138+
function makeRes() {
139+
const chunks: Buffer[] = [];
140+
const res: any = {
141+
write: (c: unknown) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))); return true; },
142+
end: () => {},
143+
header: () => res,
144+
status: (code: number) => { res._status = code; return res; },
145+
json: (body: any) => { res._json = body; return res; },
146+
};
147+
res._buffer = () => Buffer.concat(chunks);
148+
return res;
149+
}
150+
151+
async function boot(timezone: string | undefined, engines: ObjectQL[]) {
152+
const engine = new ObjectQL();
153+
engines.push(engine);
154+
engine.registerDriver(new SqlDriver({
155+
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
156+
}), true);
157+
await engine.init();
158+
engine.registerObject(LEDGER as any);
159+
await engine.syncSchemas();
160+
const protocol = new ObjectStackProtocolImplementation(engine as any);
161+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
162+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user', ...(timezone ? { timezone } : {}) });
163+
rest.registerRoutes();
164+
const route = (method: string, path: string) => {
165+
const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path) as any;
166+
expect(found, `${method} ${path}`).toBeDefined();
167+
return found;
168+
};
169+
const createRoute = route('POST', '/api/v1/data/:object');
170+
const exportRoute = route('GET', '/api/v1/data/:object/export');
171+
const importRoute = route('POST', '/api/v1/data/:object/import');
172+
const send = async (r: any, req: Record<string, unknown>) => {
173+
const res = makeRes();
174+
await r.handler({ params: { object: OBJECT }, ...req } as any, res);
175+
return res;
176+
};
177+
return {
178+
engine,
179+
create: (body: Record<string, unknown>) => send(createRoute, { body }),
180+
exportAs: (format: 'csv' | 'xlsx' | 'json') => send(exportRoute, { query: { format } }),
181+
importBody: (body: Record<string, unknown>) => send(importRoute, { body }),
182+
};
183+
}
184+
185+
/** Each row's exported `date` and `datetime` cells, by row id. */
186+
async function exportedCells(format: 'csv' | 'xlsx' | 'json', buffer: Buffer): Promise<Map<string, { d: string; dt: string }>> {
187+
const cells = new Map<string, { d: string; dt: string }>();
188+
if (format === 'json') {
189+
for (const row of JSON.parse(buffer.toString('utf8'))) {
190+
cells.set(String(row.id), { d: String(row.d ?? ''), dt: String(row.dt ?? '') });
191+
}
192+
return cells;
193+
}
194+
if (format === 'csv') {
195+
const [header, ...lines] = buffer.toString('utf8').split('\r\n').filter((l) => l.length > 0);
196+
expect(header).toBe('ID,Day,At');
197+
for (const line of lines) {
198+
const [id, d, dt] = line.split(',');
199+
cells.set(id, { d, dt: dt ?? '' });
200+
}
201+
return cells;
202+
}
203+
const ws = (await loadXlsxWorkbook(buffer)).worksheets[0];
204+
expect((ws.getRow(1).values as unknown[]).slice(1)).toEqual(['ID', 'Day', 'At']);
205+
for (let r = 2; r <= ws.rowCount; r++) {
206+
const [id, d, dt] = (ws.getRow(r).values as unknown[]).slice(1);
207+
// A text cell, never a Date: the import reads the export's own spelling.
208+
expect(typeof d).toBe('string');
209+
cells.set(String(id), { d: String(d), dt: dt == null ? '' : String(dt) });
210+
}
211+
return cells;
212+
}
213+
214+
function importBodyFor(format: 'csv' | 'xlsx' | 'json', buffer: Buffer): Record<string, unknown> {
215+
if (format === 'json') return { format, rows: JSON.parse(buffer.toString('utf8')), writeMode: 'insert' };
216+
if (format === 'csv') return { format, csv: buffer.toString('utf8'), mapping: MAPPING, writeMode: 'insert' };
217+
return { format, xlsxBase64: buffer.toString('base64'), mapping: MAPPING, writeMode: 'insert' };
218+
}
219+
220+
/** The 2026 control's `datetime` cell, as it exported before #20602. */
221+
const CONTROL_DATETIME_CELL: Record<string, string> = {
222+
none: '2026-01-01 10:00:00',
223+
'Asia/Shanghai': '2026-01-01 18:00:00',
224+
'America/New_York': '2026-01-01 05:00:00',
225+
};
226+
227+
const BUSINESS_ZONES = [undefined, 'Asia/Shanghai', 'America/New_York'] as const;
228+
const FORMATS = ['csv', 'xlsx', 'json'] as const;
229+
230+
describe.each(BUSINESS_ZONES)('[#20602] GET /export then POST /import, business timezone %s', (zone) => {
231+
describe.each(FORMATS)('format %s', (format) => {
232+
const engines: ObjectQL[] = [];
233+
let source: Awaited<ReturnType<typeof boot>>;
234+
let target: Awaited<ReturnType<typeof boot>>;
235+
let cells: Map<string, { d: string; dt: string }>;
236+
let imported: any;
237+
238+
beforeAll(async () => {
239+
source = await boot(zone, engines);
240+
for (const row of ROWS) {
241+
const created = await source.create({ id: row.id, d: row.d, ...(row.dt ? { dt: row.dt } : {}) });
242+
expect(created._status ?? 201, `create ${row.id}`).toBe(201);
243+
}
244+
const exported = await source.exportAs(format);
245+
expect(exported._status ?? 200).toBe(200);
246+
cells = await exportedCells(format, exported._buffer());
247+
expect([...cells.keys()].sort()).toEqual(ROWS.map((r) => r.id).sort());
248+
249+
target = await boot(zone, engines);
250+
imported = (await target.importBody(importBodyFor(format, exported._buffer())))._json;
251+
expect(imported).toMatchObject({ total: ROWS.length });
252+
});
253+
254+
afterAll(async () => {
255+
while (engines.length) {
256+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
257+
}
258+
});
259+
260+
it.each(ROWS)('exports the $year row with a four-digit year and re-imports it unchanged', async (row) => {
261+
const cell = cells.get(row.id)!;
262+
expect(cell.d).toBe(row.d);
263+
if (row.dt) {
264+
expect(cell.dt.slice(0, 11)).toBe(`${row.year}-01-01 `);
265+
if (row.year === '2026') expect(cell.dt).toBe(CONTROL_DATETIME_CELL[zone ?? 'none']);
266+
} else {
267+
expect(cell.dt).toBe('');
268+
}
269+
270+
// The import's per-row result sits at the row's position in the file.
271+
const position = [...cells.keys()].indexOf(row.id) + 1;
272+
const refusal = imported.results?.find((r: any) => r.row === position && r.ok === false);
273+
expect(refusal, `the import refused the ${row.year} row`).toBeUndefined();
274+
const back = await target.engine.findOne(OBJECT, { where: { id: row.id } });
275+
const src = await source.engine.findOne(OBJECT, { where: { id: row.id } });
276+
expect(src?.d).toBe(row.d);
277+
expect({ d: back?.d, dt: back?.dt ?? null }).toEqual({ d: src?.d, dt: src?.dt ?? null });
278+
if (row.dt) expect(back?.dt).toBe(row.dt);
279+
});
280+
});
281+
});

‎packages/rest/src/export-format.ts‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@
2424
* is the PROCESS-LOCAL clock, not UTC — see {@link exportContentDisposition}
2525
* for why the two contracts deliberately differ, and {@link zonedWallClock}
2626
* for where that choice is left to each caller.
27+
*
28+
* Fourth contract, on the year (#20602): every `date` and `datetime` cell takes
29+
* its day from core's `temporalStorageForm` `date` rule — see
30+
* {@link calendarDay} — so the year keeps four digits and the export re-imports.
2731
*/
2832

33+
import { temporalStorageForm } from '@objectstack/core';
34+
2935
export interface ExportFieldMeta {
3036
name: string;
3137
type?: string;
@@ -228,10 +234,29 @@ function zonedFormatter(timezone: string): Intl.DateTimeFormat | null {
228234
return fmt;
229235
}
230236

237+
/**
238+
* [#20602] The `YYYY-MM-DD` of `day`'s UTC calendar day, spelled by core's
239+
* `temporalStorageForm` `date` rule, imported rather than mirrored: the
240+
* storage form the write doors keep and the one `/import`'s reader
241+
* (`parseDateCell`) spells an instant's day with. Every export `date` and
242+
* `datetime` cell takes its day from here.
243+
*
244+
* The rule pads the year to four digits. `getUTCFullYear()` and `Intl`'s
245+
* `year` part are unpadded numbers, so a `date` `0500-01-01` used to export as
246+
* `500-01-01` and a `datetime` on that day as `500-01-01 10:00:00`, and
247+
* `/import`, which reads a four-digit year only, refused the file's row as
248+
* `invalid_date`. A year outside 0001..9999 has no `YYYY-MM-DD` form: the rule
249+
* leaves it unpadded (`0-12-31`, `10000-01-01`), as the export always spelled
250+
* it, and the import refuses it.
251+
*/
252+
function calendarDay(day: Date): string {
253+
return String(temporalStorageForm(day, 'date'));
254+
}
255+
231256
/** The UTC wall clock of an instant — `YYYY-MM-DD` + `HH:mm:ss`. */
232257
function utcWallClock(d: Date): { ymd: string; hms: string } {
233258
return {
234-
ymd: `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`,
259+
ymd: calendarDay(d),
235260
hms: `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`,
236261
};
237262
}
@@ -272,7 +297,20 @@ function zonedWallClock(d: Date, timezone?: string): { ymd: string; hms: string
272297
const mi = get('minute');
273298
const s = get('second');
274299
if (!(y && mo && da && h && mi && s)) return null;
275-
return { ymd: `${y}-${mo}-${da}`, hms: `${h}:${mi}:${s}` };
300+
// [#20602] The zone's calendar day, spelled by the same rule as every other
301+
// cell ({@link calendarDay}). Its year is NOT `Intl`'s `year` part, which is
302+
// an ERA year: year 0 (1 BC) reads `1` there, so padding it would spell
303+
// `0001-01-01T03:00Z` in New York as the last day of year 1, a date a year
304+
// later that `/import` would take. An offset is under a day, so the zone's
305+
// year is the instant's UTC year, one more when the zone has reached January
306+
// and UTC is still in December, one less the other way round.
307+
const month = Number(mo);
308+
const utcMonth = d.getUTCMonth() + 1;
309+
const year = d.getUTCFullYear() + (month === 1 && utcMonth === 12 ? 1 : month === 12 && utcMonth === 1 ? -1 : 0);
310+
const day = new Date(0);
311+
// `setUTCFullYear`, never `Date.UTC`, which reads a year 0..99 as 1900..1999.
312+
day.setUTCFullYear(year, month - 1, Number(da));
313+
return { ymd: calendarDay(day), hms: `${h}:${mi}:${s}` };
276314
}
277315

278316
/**
@@ -319,13 +357,14 @@ function toDate(value: unknown): Date | null {
319357
*
320358
* `timezone` absent (or unknown to the platform) ⇒ UTC, i.e. exactly the
321359
* pre-#8373 output.
360+
*
361+
* Both branches spell the day through {@link calendarDay}, so the year keeps
362+
* four digits (#20602).
322363
*/
323364
function formatDate(value: unknown, withTime: boolean, timezone?: string): unknown {
324365
const d = toDate(value);
325366
if (!d) return value;
326-
if (!withTime) {
327-
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
328-
}
367+
if (!withTime) return calendarDay(d);
329368
const { ymd, hms } = wallClock(d, timezone);
330369
return `${ymd} ${hms}`;
331370
}

0 commit comments

Comments
 (0)