Repository navigation
fix(rest): /import reads a time cell by core's one time rule, so an exported 10:00:00.250 re-imports (#20722) - #20829
Conversation
…xported 10:00:00.250 re-imports
The import's time reader was a private pattern with no fractional part, so
the 10:00:00.250 that /export writes for a time with milliseconds failed its
row as invalid_date on every backend. It now asks the rule the write door
asks of a written time: isUninterpretableTemporalComparand('time', ...) for
the verdict and temporalStorageForm(..., 'time') for the stored value. The
year-first date-time stays the import's own reading. A refused time cell
reports the write door's code for the same value, invalid_time.
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…n memory, SQLite and PostgreSQL Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…port-time-fraction
…tcher error vocabulary import-coerce.ts now refuses a time cell with the write door's field code, invalid_time, through coerceError; the table carries the same foreign-vocabulary row its invalid_date twin has (ADR-0114 field catalog, never error.code). Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 33 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 fc3977195dc7bc1cb5b479a37a43f9e369086446 && git checkout fc3977195dc7bc1cb5b479a37a43f9e369086446
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 73155fedcacc215565c4eef6d9899977e0707010 99eb702d917ffbf0c2a58e91d60e8aa59436b86d && git checkout -B drift-repro 73155fedcacc215565c4eef6d9899977e0707010 && git merge --no-ff 99eb702d917ffbf0c2a58e91d60e8aa59436b86d
node scripts/docs-audit/affected-docs.mjs --json 73155fedcacc215565c4eef6d9899977e0707010
|
… — minor, Clause-② no (narrowing), BREAKING banner and ADR-0087 disposition The reader now refuses a time cell that is an ISO instant with no four-digit UTC year, as the write door does, and a refused time cell's row code moves from invalid_date to invalid_time. The changeset states both, ships as minor under the launch-window convention, and records that the import keeps a non-zero fraction where #20671's entry said HH:MM:SS. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #20722 (body; triage ① Derived judgments(a) The import's
(b) The row code (c) The changeset (d) The export to import round trip — right. Other derived facts: ② Semver level
③ Boundary flagsReport Report
Report
Report Report Report
Report Check-runs on Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20722
Clause-②: no (narrowing)
POST /api/v1/data/:object/importnow reads atimecell by@objectstack/core's onetimerule. That is the rule the write door has asked of a writtentimesince #20671. So the10:00:00.250that/exportwrites for atimewith milliseconds re-imports as itself. A cell that@objectstack/core'stimerule reads is now admitted exactly when the write door admits it. The one reading the import keeps and the write door refuses is the year-first date-time (#20534), named below. A fraction is admitted on both. AZ/ offset suffix on a time of day is refused on both, with the same field code,invalid_time.Base
96e724475(claim) → merged withorigin/mainatd2b188fb5→0e624ca2b→ patch round 199eb702d9(changeset only).Reproduced first, then after
Measured through the routes:
POST /api/v1/data/:objectfor the write door,POST .../importfor the import,GET .../exportfor the round trip. The process ran inTZ=America/New_York. Backends wereInMemoryDriver,SqlDriverover SQLite, andSqlDriverover a private PostgreSQL 16.13 atAsia/Shanghai. All three answered alike on every row.timevalue/importat base/importnow10:00:00.250,23:59:59.999(the card)invalid_date,Clock: "10:00:00.250" is not a valid time10:00:00.5,10:00:00.00010:00:00.500,10:00:00invalid_date2026-07-15T10:00:00.250Z,2026-07-15 10:00:00.25010:00:00.25010:00:00, fraction silently dropped10:00:00.2509999-12-31T23:00:00-02:00(UTC year 10000)invalid_time01:00:00invalid_time10:00Z,10:00+08:00,10:00:00.250Z,+010000-01-01T10:00:00Z,25:00,9:00,07/15/2026 10:00invalid_timeinvalid_dateinvalid_time10:00:00,10:00,2026-07-15T18:00:00+08:0010:00:0010:00:0010:00:00.250,23:59:59.99910:00:00controlBase readings: the new pin run on the unfixed tree gave 48 failed / 30 passed on SQLite plus live PostgreSQL. On memory, an uncommitted copy of the pin with
InMemoryDrivergave 31 failed / 26 passed at base. For the memory base leg, the baseimport-coerce.tswas restored under a trap. The restore was proved bygit hash-objectequal to the HEAD blob944f05589and an emptygit diff HEAD. At head: 78 / 78 on SQLite plus PostgreSQL, and 57 / 57 on memory.The change
packages/rest/src/import-coerce.tsTIME_OF_DAYpattern is deleted. It had no fractional part.readTimeOfDayCell(s)does two things. Its verdict isisUninterpretableTemporalComparand('time', s). Its stored value istemporalStorageForm(s, 'time'). A{placeholder}is refused byclassifyFilterToken, the samereadablegate the write door applies. ⛔ There is no second regex.temporalStorageFormalone is not a verdict, measured on core'sdist. It is total and never throws. For junk it hands the value back unchanged. But it also returns a clock for values the verdict refuses:07/15/2026 10:00gives10:00:00throughDate.parsein the host zone, and2026-02-30T10:00:00Zgives10:00:00after rolling the date over.timecell that core's rule refuses is read in the year-first form alone (2026/7/15 9:00gives09:00:00). That is the one reading the import has beyond the write door, from the maintainer ruling on /import: parseDateCell emits adatecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534. It is pinned as such. The ISO reader (readIsoTemporalCell) no longer answers for atimecell. Core's rule is the whole verdict there.timecell now reportsinvalid_time, the write door's code for the same value, where it reportedinvalid_date.date/datetimekeepinvalid_date. The row sentence (import_invalid_time) is unchanged.packages/runtime/src/dispatcher-error-vocabulary.ts: oneforeign-vocabularyrow for the newinvalid_timestamp atimport-coerce.ts. It is the twin of that file's existinginvalid_daterow.check:dispatcher-error-vocabularywas red without it. The table is not in@objectstack/runtime's published output. On a fresh build, its text has 0 hits inpackages/runtime/dist, against a positive control of 105 hits forHttpDispatcher. So runtime takes no changeset.packages/rest/src/import-date-cell-iso-real-day.test.ts(the /import: parseDateCell emits adatecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534 pin): its onetrow (07/15/2026 10:00) now expectsinvalid_time. That is the code the write door gives the same value.danddtrows keepinvalid_date.packages/rest/src/import-time-cell-fraction.test.ts:OS_TEST_POSTGRES_URLis set (a named skip otherwise);10:00:00.250,23:59:59.999and the10:00:00control..changeset/20722-import-time-fraction.md:@objectstack/rest: minor,Clause-②: no (narrowing), a**BREAKING**banner and an ADR-0087not-required (no-migration-prescription)disposition (patch round 1,99eb702d9).Clause-② reading:
no (narrowing)timecell that is an ISO instant whose UTC year has no four-digit spelling. For example,9999-12-31T23:00:00-02:00was stored as01:00:00. The write door has refused that value since record validator: atimefield written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671. This is the half the claim's Clause-② reading asked to hear about. The other half widens toward what the write door already admits: fractions, and a zone-naive2026-07-15 24:00into atime, which is now00:00:00on both doors.timecell moves frominvalid_datetoinvalid_time. Triage 5899707588 asked for this: "refused with the same code the write door gives". No code is minted.invalid_timeis already inFieldErrorCode. ADR-0114 records that nothing branches on a field code: objectui readscodeonly as a last-resort text.The seat took option B: body line 2 and the changeset both read
Clause-②: no (narrowing). The options are in theos-dev-reporton #20722.PM hypotheses
96e724475import-coerce.ts:273was the pattern the card names, and:506was its only use. The round trip failed as described, on all three backends.canonicalTimeOfDayis a private function inpackages/core/src/utils/temporal-storage-form.ts. It is exported nowhere. The public doors aretemporalStorageForm(value, kind)andisUninterpretableTemporalComparand(kind, value). Both come from@objectstack/core's root:index.tsre-exportstemporal-storage-form.jsandtemporal-comparand.js.packages/restalready depends on@objectstack/coreand already importedtemporalStorageForm. No core edit was needed.temporalStorageFormnever throws and hands back unchanged what it cannot read, but it is not a verdict (see above).Asia/Shanghai, started and stopped by this run. No CI job provisions PostgreSQL for@objectstack/rest. TheTemporal Conformance (live PG + MySQL)job runs driver-sql, metadata-protocol and one runtime file. So the PostgreSQL cell of this pin is a named skip in CI, as it is for the existingdata-temporal-write-real-day-iso.test.ts.dist:ClockTimeValueSchema(c9c182ed) admits10:00:00.250and refuses10:00Z,10:00+08:00and10:00:00.250Z;timefield written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671) does the same;2026-07-15T10:00:00Z) is admitted by the write door and the import, which fold it to its UTC time of day (ADR-0053 D-C1).ClockTimeValueSchemarefuses it.Tests
On head
0e624ca2bunless stated:pnpm --filter @objectstack/rest exec vitest run --project local, with live PostgreSQL, on the merged tree992b08a49: 232 files, 4588 passed / 30 skipped.0e624ca2bdiffers from it only in the runtime table.pnpm --filter @objectstack/rest typecheck: exit 0.check:test-typecheckOK, 0 debt.pnpm --filter @objectstack/runtime exec vitest run --project repo src/error-envelope.conformance.test.ts: 53 / 53. This file is the only importer of the edited table (git grep).pnpm --filter @objectstack/runtime typecheck: exit 0.require('@objectstack/rest')andrequire('@objectstack/runtime')from the freshly built CJSdistload.dist/index.cjsof rest carries the new reader.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranon0e624ca2bgives 62 derived. 61 exited 0. One is NOT MEASURED:check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET. It needs every package'sdist, and this run built the rest and runtime closures only. The targetedrequirereading above covers the two packages this diff touches.pnpm lint(the whole repo,eslint . --no-inline-config) exited 0 on0e624ca2b.The derivation's last read printed a stale-tree note:
origin/mainhas since moved pastd2b188fb5by.github/**/half-state-patrol*and four docs / objectql commits.Acceptance notes
Noted, not filed:
Dateobject reachingparseDateCell(…, 'time')still takesutcClock, which drops milliseconds, where core's rule keeps them. No public door hands the reader aDate: JSON carries none, and an xlsx date cell is rendered to text first. Carrier: none.timecell on/importgets the import's own sentence (Clock: "10:00Z" is not a valid time), not the write door's prescription (drop the Z or offset, or use a datetime field). Reusing that sentence needs a zone test the write door keeps private inobjectql. Carrier: none./exportwrites atimecell as the stored string, and the import reads a string cell as written.timefield written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671's pending changeset says the import "turns atimecell intoHH:MM:SS". After this change it isHH:MM:SS, plus.fffwhen non-zero.Generated by Claude Code