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
Conversation
…ear, 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>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5eda1f9dcbb3059f993386592e5cc903d2902e38 && git checkout 5eda1f9dcbb3059f993386592e5cc903d2902e38
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa && git checkout -B drift-repro 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b && git merge --no-ff 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa
node scripts/docs-audit/affected-docs.mjs --json 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b
|
…port-year-pad Brings in PR #20746 (core builds a UTC instant from parts with wallClockToUtcMs), so the export -> import round trip for datetime years 0001..0099 can be measured against a base that reads the padded year right. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…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>
Contract reviewServed-tier: Inputs: card #20602 (body and all seven comments: triage 5886127452, the predecessor claim 5894935136 and report 5895487340, the seat answer 5895522655, the engine pointer 5902858739, the takeover claim 5916503658 and its report 5917691304), PR #20688 (body, the three-file list, Check-runs on the head, read by this act (2026-09-30, after the dev's 18:54Z reading): 41 runs, 34 distinct names after collapsing to latest-per-name — 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in_progress. The seven required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core (and 1/6 to 6/6), Dogfood Regression Gate (and 1/3 to 3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The two the dev saw in_progress (Check Changeset, Part-of PR must not also close its card) have since completed success. The file list holds no governed path. ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20602
Clause-②: no
What changes
GET /api/v1/data/:object/exportwrote adateordatetimecell'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) andPOST /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'sdatebranch,utcWallClockandzonedWallClocknow take the day from one private helper,calendarDay.temporalStorageForm,daterule, imported from@objectstack/core(not mirrored). It pads 0001..0999 and leaves a year outside 0001..9999 unpadded.@objectstack/restalready depends on@objectstack/core; nopackages/core/**edit.zonedWallClockno longer reads the year fromIntl'syearpart, which is an ERA year (year 0, 1 BC, reads1): padding it would spell0001-01-01T03:00:00Zin New York as0001-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 withsetUTCFullYear, neverDate.UTC.packages/rest/src/rest-server.tsis untouched; the pins drive the real routes.This round: merge of
main, and what it changed for this PRThe seat held this PR behind #20599 (answer
5895522655, option B). #20599 has landed (PR #20746,a6866da0c), and this round mergedorigin/main9509ea106ainto the branch asc0c254921a(a merge commit; no rebase, no force-push;git merge-treewas clean).mainalso carries PR #20843 (05a7547c9f, #20280): adatetimenames a year from 1000 to 9999 at both engine doors, and adatekeeps 0001..9999. That moved this PR's own pin: atc0c254921athe route layer ofexport-date-year-pad.test.tswent red (80 passed | 63 skipped, every routebeforeAllfailed), because the create door now refuses the pin'sdatetimerows for 0500 and 0999 (400 VALIDATION_FAILED, fielddt, codeinvalid_date). So the dispatched step, "drop thedt: undefinedexclusion for 0001 and 0050", cannot be taken as written: those rows cannot be created at all. What81b61a6b18does instead:packages/rest/src/export-date-year-pad.test.ts): the route rows before year 1000 carry adateonly; 0099 joins the years (formatter census and routes); a boundary row pins the onedatetimecell the export's padding still reaches at the routes, the instant1000-01-01T02:00:00.000Z, which America/New_York reads on0999-12-31(exported0999-12-31 21:03:58, re-imported as the same instant). The module note says why.export-format.ts, one in the pin) no longer say the import "would take" the era-year spelling: after driver-sql on MySQL reads a year 0..99 back a century late — REST create storesplaced_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280 the import refuses it, as the write doors do. Comment-only; no behaviour inexport-format.tsmoved (its blob went8904e5c4b1b4tod318ab58eb98on that one comment).datetime0500-01-01 10:00:00re-imports: it names thedatecell and the zone-boundarydatetimecell that do, and says adatetimestored 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/exportandPOST /api/v1/data/:object/importhandlers of aRestServeroverObjectQLplusSqlDriver(better-sqlite3:memory:) and the real metadata protocol, driven in-process, business timezone from the resolvedExecutionContext, into a fresh stack for the import. A throwaway probe (deleted, not in the diff), atc0c254921a, CSV, xlsx and JSON, business timezone none / Asia/Shanghai / America/New_York, hostTZunset (UTC) andTZ=America/New_York./importdate0001, 0050, 0099, 0500 (-01-01)0001-01-01etc., padded, all zonesdate20262026-01-01datetime0001, 0050, 0099, 0500 at 10:00ZVALIDATION_FAILED,dtinvalid_date(72 of 72)datetime1000 at 10:00Z1000-01-01 10:00:00/18:05:43/05:03:58datetime2026 at 10:00Z2026-01-01 10:00:00/18:00:00/05:00:00datetime1000-01-01T02:00:00.000Z1000-01-01 02:00:00/1000-01-01 10:05:43/0999-12-31 21:03:58datetime0050, 0500 at 10:00Z written through the driver (a row stored before the floor)0050-01-01 10:00:00,0500-01-01 10:00:00(and zone clocks), paddeddtinvalid_date(36 of 36)Every row the create door takes round-trips exactly: 144 of 144 legs. The seat's concern for this landing order, a padded
datetime0001..0099 stored 1900 years late with no error, has no path left: the create door refuses such adatetime, 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.tsat9509ea106a(the merge'smainparent; blob5791dbaeb33bproven on disk, restored to HEAD8904e5c4b1b4withgit diff HEADempty): thedatecells export1-01-01,50-01-01,99-01-01,500-01-01, the New York boundary cell999-12-31 21:03:58, the pre-floor rows50-01-01 10:00:00; the import refuses every one asinvalid_date. Per format:ok 4, errors 6with no zone and in Asia/Shanghai,ok 3, errors 7in America/New_York; at the headok 8, errors 2everywhere (the two pre-floor rows).Ablations at
81b61a6b18, each throughnode scripts/ablation-replace.mjs(anchor hit once, blob moved, restore proven: blobd318ab58eb98equals HEAD andgit diff HEADempty); the subject resolves throughsrc/by relative import, so nodistleg:calendarDayreturns 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 whosedateis before 1000, the boundary row included.zonedWallClockspells the day'sgetUTCFullYear()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'sdatetimehalf biting on its own.Earlier readings by the predecessor round (head
e739a50fa0, base6981abfd26), still describingexport-format.tsas it is: H0 (the base exported500-01-01and500-01-01 10:00:00and the import refused the row) and the one-shot H1 census of 1050formatCellValuecells (years 1000, 2026, 9999 and +010000 byte-identical except 5 cells whose zone day is0999-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;dateanddatetime; zones none / UTC / Asia/Shanghai / America/New_York / unknown), the 2026 control's exact cells,Dateand 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, underscripts/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), hostTZunset and again underTZ=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 --listFilesOnlylists the pin file.@objectstack/restis byte-unchanged (calendarDayis private), so no downstream consumer owes a test.Gates
At
81b61a6b18(merge base9509ea106a):node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 60 commands, all exit 0.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3 (PREREQUISITE NOT MET, nodist) and are green afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2.--ran:60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(the whole tree, not narrowed): exit 0, 229 s.Acceptance notes
placed_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280's floor means adatetimein 0001..0999 is refused at the create door, so no such row exports. Every row the doors take round-trips exactly (table above).packages/rest/src/import-datetime-year-below-100.test.ts(PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746, read-only here): itspadExportYearstep 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-enddatetimecheck below 1000 in a zone. Not edited.datetimewhose 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.datetimebefore 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.packages/rest/src/import-prepare.tsxlsxDateToNaiveCellspells 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(claim5916503658); the branch's first two commits are the predecessor seat's (sessionsession_local_1d2a197c-c20e-4e90-9be8-413d4d432289).