Skip to content

Commit 81b61a6

Browse files
committed
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 <noreply@anthropic.com>
1 parent c0c2549 commit 81b61a6

3 files changed

Lines changed: 52 additions & 33 deletions

File tree

‎.changeset/20602-export-year-four-digits.md‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@ fix(rest): `GET /api/v1/data/:object/export` writes a `date` or `datetime` cell
66

77
Clause-②: no
88

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
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
1213
year only, so re-importing the platform's own file refused that row as
1314
`invalid_date`. The export now spells every `date` and `datetime` cell's day
1415
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.
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.
1718

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.
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.

‎packages/rest/src/export-date-year-pad.test.ts‎

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,19 @@
1717
* Two layers:
1818
*
1919
* - **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.
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.
2323
* - **The routes**: rows written through the create door, exported as CSV,
2424
* xlsx and JSON, and re-imported into a fresh stack through the import door,
2525
* store the same `date` and `datetime` values, under no business timezone,
2626
* Asia/Shanghai and America/New_York.
2727
*
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.
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.
3233
*/
3334

3435
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -43,7 +44,7 @@ import { loadXlsxWorkbook } from './xlsx-test-loader.js';
4344
const DATE: ExportFieldMeta = { name: 'd', type: 'date' };
4445
const DATETIME: ExportFieldMeta = { name: 'dt', type: 'datetime' };
4546

46-
const YEARS = ['0001', '0050', '0500', '0999', '1000', '2026', '9999'] as const;
47+
const YEARS = ['0001', '0050', '0099', '0500', '0999', '1000', '2026', '9999'] as const;
4748

4849
const ZONES = [undefined, 'UTC', 'Asia/Shanghai', 'America/New_York', 'Not/AZone'] as const;
4950

@@ -94,7 +95,7 @@ describe('[#20602] formatCellValue spells a four-digit year for every date and d
9495
// Year 1 in UTC, still year 0 in New York: year 0 has no four-digit form,
9596
// so it is spelled unpadded as the storage rule spells it and the import
9697
// 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+
// a day a year later than the instant's.
9899
const cell = String(formatCellValue('0001-01-01T03:00:00.000Z', DATETIME, 'America/New_York'));
99100
expect(cell.startsWith('0-12-31 ')).toBe(true);
100101
expect(cell.startsWith('0001-')).toBe(false);
@@ -118,17 +119,32 @@ const LEDGER = {
118119

119120
const MAPPING = { ID: 'id', Day: 'd', At: 'dt' };
120121

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+
121127
/**
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).
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.
125131
*/
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+
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+
];
132148

133149
function createMockServer() {
134150
const noop = () => {};
@@ -257,11 +273,11 @@ describe.each(BUSINESS_ZONES)('[#20602] GET /export then POST /import, business
257273
}
258274
});
259275

260-
it.each(ROWS)('exports the $year row with a four-digit year and re-imports it unchanged', async (row) => {
276+
it.each(ROWS)('exports the $id row with a four-digit year and re-imports it unchanged', async (row) => {
261277
const cell = cells.get(row.id)!;
262278
expect(cell.d).toBe(row.d);
263279
if (row.dt) {
264-
expect(cell.dt.slice(0, 11)).toBe(`${row.year}-01-01 `);
280+
expect(cell.dt.slice(0, 11)).toBe(`${row.dtDay(zone)} `);
265281
if (row.year === '2026') expect(cell.dt).toBe(CONTROL_DATETIME_CELL[zone ?? 'none']);
266282
} else {
267283
expect(cell.dt).toBe('');

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ function zonedWallClock(d: Date, timezone?: string): { ymd: string; hms: string
300300
// [#20602] The zone's calendar day, spelled by the same rule as every other
301301
// cell ({@link calendarDay}). Its year is NOT `Intl`'s `year` part, which is
302302
// 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
303+
// `0001-01-01T03:00Z` in New York as the last day of year 1, a day a year
304+
// later than the instant's. An offset is under a day, so the zone's
305305
// year is the instant's UTC year, one more when the zone has reached January
306306
// and UTC is still in December, one less the other way round.
307307
const month = Number(mo);

0 commit comments

Comments
 (0)