Skip to content

Commit 67c1b11

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest): /export writes a date or datetime cell with a four-digit year, so an export of a year below 1000 re-imports (#20602) (#20688)
Fixes #20602 Clause-②: no ## What changes `GET /api/v1/data/:object/export` wrote a `date` or `datetime` cell's year unpadded, so a day in the years 0001 to 0999 left the export short (`500-01-01`, `999-12-31 21:03:58`) and `POST /api/v1/data/:object/import`, which reads a four-digit year only, refused the platform's own file. One source file changes, `packages/rest/src/export-format.ts`, on the three paths triage named: - `formatDate`'s `date` branch, `utcWallClock` and `zonedWallClock` now take the day from one private helper, `calendarDay`. - **The pad rule: core's `temporalStorageForm`, `date` rule, imported from `@objectstack/core`** (not mirrored). It pads 0001..0999 and leaves a year outside 0001..9999 unpadded. `@objectstack/rest` already depends on `@objectstack/core`; no `packages/core/**` edit. - `zonedWallClock` no longer reads the year from `Intl`'s `year` part, which is an ERA year (year 0, 1 BC, reads `1`): padding it would spell `0001-01-01T03:00:00Z` in New York as `0001-12-31`, a day a year later than the instant's. The zone's year is the instant's UTC year, plus one when the zone has reached January while UTC is in December, minus one the other way round. The day is built with `setUTCFullYear`, never `Date.UTC`. - `packages/rest/src/rest-server.ts` is untouched; the pins drive the real routes. ## This round: merge of `main`, and what it changed for this PR The seat held this PR behind #20599 (answer `5895522655`, option B). #20599 has landed (PR #20746, `a6866da0c`), and this round merged `origin/main` `9509ea106a` into the branch as `c0c254921a` (a merge commit; no rebase, no force-push; `git merge-tree` was clean). `main` also carries PR #20843 (`05a7547c9f`, #20280): **a `datetime` names a year from 1000 to 9999 at both engine doors**, and a `date` keeps 0001..9999. That moved this PR's own pin: at `c0c254921a` the route layer of `export-date-year-pad.test.ts` went red (`80 passed | 63 skipped`, every route `beforeAll` failed), because the create door now refuses the pin's `datetime` rows for 0500 and 0999 (`400 VALIDATION_FAILED`, field `dt`, code `invalid_date`). So the dispatched step, "drop the `dt: undefined` exclusion for 0001 and 0050", cannot be taken as written: those rows cannot be created at all. What `81b61a6b18` does instead: - **The pin** (`packages/rest/src/export-date-year-pad.test.ts`): the route rows before year 1000 carry a `date` only; 0099 joins the years (formatter census and routes); a boundary row pins the one `datetime` cell the export's padding still reaches at the routes, the instant `1000-01-01T02:00:00.000Z`, which America/New_York reads on `0999-12-31` (exported `0999-12-31 21:03:58`, re-imported as the same instant). The module note says why. - **Two comments** (one in `export-format.ts`, one in the pin) no longer say the import "would take" the era-year spelling: after #20280 the import refuses it, as the write doors do. Comment-only; no behaviour in `export-format.ts` moved (its blob went `8904e5c4b1b4` to `d318ab58eb98` on that one comment). - **The changeset** no longer says a `datetime` `0500-01-01 10:00:00` re-imports: it names the `date` cell and the zone-boundary `datetime` cell that do, and says a `datetime` stored before year 1000 exports padded and is refused by the import, as by the write doors. ## Measured, on the merged tree Harness: the real `POST /api/v1/data/:object`, `GET /api/v1/data/:object/export` and `POST /api/v1/data/:object/import` handlers of a `RestServer` over `ObjectQL` plus `SqlDriver` (better-sqlite3 `:memory:`) and the real metadata protocol, driven in-process, business timezone from the resolved `ExecutionContext`, into a fresh stack for the import. A throwaway probe (deleted, not in the diff), at `c0c254921a`, CSV, xlsx and JSON, business timezone none / Asia/Shanghai / America/New_York, host `TZ` unset (UTC) and `TZ=America/New_York`. | row | create door | exported cell (none / Shanghai / New York) | `/import` | stored back | |:--|:--|:--|:--|:--| | `date` 0001, 0050, 0099, 0500 (`-01-01`) | 201 | `0001-01-01` etc., padded, all zones | ok | identical, 72 of 72 legs | | `date` 2026 | 201 | `2026-01-01` | ok | identical | | `datetime` 0001, 0050, 0099, 0500 at 10:00Z | **400** `VALIDATION_FAILED`, `dt` `invalid_date` (72 of 72) | no row | none | none | | `datetime` 1000 at 10:00Z | 201 | `1000-01-01 10:00:00` / `18:05:43` / `05:03:58` | ok | identical | | `datetime` 2026 at 10:00Z | 201 | `2026-01-01 10:00:00` / `18:00:00` / `05:00:00` | ok | identical | | `datetime` `1000-01-01T02:00:00.000Z` | 201 | `1000-01-01 02:00:00` / `1000-01-01 10:05:43` / **`0999-12-31 21:03:58`** | ok | identical | | `datetime` 0050, 0500 at 10:00Z written through the driver (a row stored before the floor) | not the door | `0050-01-01 10:00:00`, `0500-01-01 10:00:00` (and zone clocks), padded | row refused, `dt` `invalid_date` (36 of 36) | nothing stored | Every row the create door takes round-trips exactly: 144 of 144 legs. The seat's concern for this landing order, a padded `datetime` 0001..0099 stored 1900 years late with no error, has no path left: the create door refuses such a `datetime`, and a row stored before the floor exports padded and is refused loudly by the import, never stored. **Control, the same probe with `export-format.ts` at `9509ea106a`** (the merge's `main` parent; blob `5791dbaeb33b` proven on disk, restored to HEAD `8904e5c4b1b4` with `git diff HEAD` empty): the `date` cells export `1-01-01`, `50-01-01`, `99-01-01`, `500-01-01`, the New York boundary cell `999-12-31 21:03:58`, the pre-floor rows `50-01-01 10:00:00`; the import refuses every one as `invalid_date`. Per format: `ok 4, errors 6` with no zone and in Asia/Shanghai, `ok 3, errors 7` in America/New_York; at the head `ok 8, errors 2` everywhere (the two pre-floor rows). **Ablations at `81b61a6b18`**, each through `node scripts/ablation-replace.mjs` (anchor hit once, blob moved, restore proven: blob `d318ab58eb98` equals HEAD and `git diff HEAD` empty); the subject resolves through `src/` by relative import, so no `dist` leg: - `calendarDay` returns the unpadded spelling: `111 failed | 61 passed (172)`; no failing test names 1000, 2026 or 9999; the failures are the below-1000 formatter cells and every route row whose `date` is before 1000, the boundary row included. - Only the zone path unpadded (`zonedWallClock` spells the day's `getUTCFullYear()` unpadded, the era-year correction kept): `14 failed | 158 passed (172)`: the ten zoned formatter cells before 1000 (Asia/Shanghai and America/New_York), the year-boundary pin, and exactly the boundary route row in America/New_York in CSV, xlsx and JSON. That is the new route row's `datetime` half biting on its own. Earlier readings by the predecessor round (head `e739a50fa0`, base `6981abfd26`), still describing `export-format.ts` as it is: H0 (the base exported `500-01-01` and `500-01-01 10:00:00` and the import refused the row) and the one-shot H1 census of 1050 `formatCellValue` cells (years 1000, 2026, 9999 and +010000 byte-identical except 5 cells whose zone day is `0999-12-31`, now padded; 0001..0999 padded; out-of-range zoned cells now spell the rule's year instead of the era year). ## Tests `packages/rest/src/export-date-year-pad.test.ts`, 172 tests: the formatter census (years 0001, 0050, 0099, 0500, 0999, 1000, 2026, 9999; `date` and `datetime`; zones none / UTC / Asia/Shanghai / America/New_York / unknown), the 2026 control's exact cells, `Date` and epoch-ms inputs, the year-boundary pin, and the route round trip per row, format and business timezone (the xlsx leg also asserts text cells). All at `81b61a6b18`, under `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/export-date-year-pad.test.ts src/import-datetime-year-below-100.test.ts`: `Tests 218 passed (218)`, host `TZ` unset and again under `TZ=America/New_York`. - `pnpm --filter @objectstack/rest run test`: `Test Files 239 passed (239)`, `Tests 4824 passed | 106 skipped (4930)`. - `pnpm --filter @objectstack/rest run test:repo`: `Tests 8 passed (8)`. - `pnpm --filter @objectstack/rest run typecheck`: exit 0, `check:test-typecheck: OK`; `tsc -p tsconfig.test.json --listFilesOnly` lists the pin file. - The public surface of `@objectstack/rest` is byte-unchanged (`calendarDay` is private), so no downstream consumer owes a test. ## Gates At `81b61a6b18` (merge base `9509ea106a`): - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 60 commands, all exit 0. `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`, no `dist`) and are green after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`. `--ran`: `60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN`. - NOT MEASURED locally, by the tool's own account: the six families whose argv takes a value from the workflow, and the eleven whole-root families; CI runs them. - `pnpm lint` (the whole tree, not narrowed): exit 0, 229 s. ## Acceptance notes 1. **The landing order the seat set is met, and the round trip it asked about is not a case any more.** #20599 has landed, and #20280's floor means a `datetime` in 0001..0999 is refused at the create door, so no such row exports. Every row the doors take round-trips exactly (table above). 2. **`packages/rest/src/import-datetime-year-below-100.test.ts`** (PR #20746, read-only here): its `padExportYear` step is now a byte-for-byte no-op on every cell this export writes for a day in 0001..9999, so on every cell its own round trip exports. Its round trip runs at 1000 and 2026 only (the floor), so it never carried a year below 1000 through the export; this PR's boundary row is the end-to-end `datetime` check below 1000 in a zone. Not edited. 3. **Out-of-range years in a business timezone.** A `datetime` whose zone day falls in year 0 or before spells the rule's year (`0-12-31`) instead of the era year (`1-12-31`). The import refuses both, and the write doors refuse such years. 4. **Observed, not filed:** the create door refuses a well-formed ISO `datetime` before year 1000 with the sentence "At must be a valid datetime (ISO-8601)", which names the spelling rather than the 1000..9999 range; the record validator chose one sentence per kind on purpose. No carrier. 5. **Not changed, dormant:** `packages/rest/src/import-prepare.ts` `xlsxDateToNaiveCell` spells an xlsx date cell's year unpadded. An xlsx date cell is an Excel serial (from 1900, or 1904), and the export writes text cells, so no workbook reaches it with a year below 1000. Taken over in this round by session `session_01VvcEokUG1tvVxkceYfR5XB` (claim `5916503658`); the branch's first two commits are the predecessor seat's (session `session_local_1d2a197c-c20e-4e90-9be8-413d4d432289`). --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent d78a0bd commit 67c1b11

3 files changed

Lines changed: 367 additions & 5 deletions

File tree

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

0 commit comments

Comments
 (0)