Serve canonical ISO-8601 for the import-job DTO's four timestamps on Postgres/MySQL - #14076
Conversation
…estamps `importJobToProgress` rendered `created_at`, `started_at`, `completed_at` and `reverted_at` through `String(value)`. On Postgres and MySQL those columns are materialised as JS `Date`s, so `String` ran `Date.prototype.toString`: the REST contract served `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` where it promises `"2026-08-30T10:19:25.947Z"` — milliseconds dropped, the server's timezone baked in, no `Z`, not `Date.parse`-safe. Nothing upstream repaired it: `formatOutput`'s `AUDIT_TIMESTAMP_COLUMNS` pass and its `normalizeSqliteDatetimeOutput` pass over `datetimeFields` both sit inside the `if (this.isSqlite)` arm, so a declared `Field.datetime` is not protected on Postgres/MySQL. SQLite returns canonical ISO text, where `String()` was an identity — which is what kept every test green. The four sites now share the three-branch normaliser already landed in `@objectstack/metadata-protocol` (string passthrough -> `instanceof Date` -> `toISOString()` -> last-resort `String(v ?? '')`). Presence semantics are untouched. The new pin drives real `Date`s through the real routes under a forced non-UTC process zone, with a non-vacuity control and an idempotence control for the already-canonical shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
… repair #13997's `canonicalIsoInstant` landed in `@objectstack/metadata-protocol` while this branch was open. Cross-reference it beside `auditMetaItem`'s `occurredAt` form and record why the nullish arm differs here: these four sites are the DTO's last step, and the required `createdAt` field's absent-value spelling (`''`) is folded in rather than left to a caller's `?? <default>` chain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…r insertion `check:check-system-context-census` went red with 16 problems: the `canonicalIsoStamp` helper adds a net +56 lines near the top of `rest-server.ts`, so every `isSystem` anchor the census page cites in that file rotted by exactly that offset. Repaired with the gate's own documented remedy for pure line rot (`check-system-context-census.mjs --fix`): 10 anchors re-pointed, uniformly +56, no ledger row added or deleted and no prose touched. Measured as caused here rather than inherited — the census is exit 0 against the pre-fix `rest-server.ts` and exit 1 with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 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 — 13 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 304b9f6d47ae6e1b8731fdd21f95f69e2b2c350c && git checkout 304b9f6d47ae6e1b8731fdd21f95f69e2b2c350c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a7002ce5ac9acf6a4d556713c587a770f1848c26 c680e23b2299bb141e2639deb061008f13e1decc && git checkout -B drift-repro a7002ce5ac9acf6a4d556713c587a770f1848c26 && git merge --no-ff c680e23b2299bb141e2639deb061008f13e1decc
node scripts/docs-audit/affected-docs.mjs --json a7002ce5ac9acf6a4d556713c587a770f1848c26
|
Fixes #13994
Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L (durable attribution copy — a body edit rewrites the footer below)
importJobToProgress— the mapper behindGET /data/import/jobs/:jobId,/resultsand the history list — rendered its four timestamp fields throughString(value). On Postgres and MySQL those columns arrive as JSDates, soStringranDate.prototype.toStringand the REST contract servedMilliseconds dropped, the server's timezone baked into the value, no
Z, and notDate.parse-safe for a client doing strict ISO parsing.Route A as ruled: the repair is spelled at the mapper. No driver read-door change, no tolerant
??fallback, and the?presence-guards are untouched.What was measured before choosing (every line number re-derived)
The four sites are at
packages/rest/src/rest-server.ts:524, 538, 539, 540— the card cited:520/:533/:534/:536. The model form is atpackages/metadata-protocol/src/protocol.ts:7746-7750; the card said:7710-7715and triage measured:7623-7627, so both had rotted again.Neither timestamp repair runs on Postgres/MySQL — brace-depth evidence. In
SqlDriver#formatOutput(packages/drivers/driver-sql/src/sql-driver.ts:15861),if (this.isSqlite) {opens at:15879(depth 1 → 2) and closes at:15968(depth 2 → 1). Inside it: theAUDIT_TIMESTAMP_COLUMNSloop at:15946(depth_before=2) and thedatetimeFieldslookup at:15960(depth_before=2) with itsnormalizeSqliteDatetimeOutputloop at:15962-15964. So a declaredField.datetimeis not protected on Postgres/MySQL — re-verified on the current tree, as the lane's earlier finding said.A refinement to the card's classification. The card says
created_atis "a builtin audit column — not indatetimeFields". It is in fact both: it is inAUDIT_TIMESTAMP_COLUMNS(sql-driver.ts:269) and declaredField.datetimeon the object (packages/platform-objects/src/audit/sys-import-job.object.ts:124). The conclusion is unchanged and slightly strengthened — it is doubly unrepaired on PG/MySQL, since both passes sit in the sameisSqlitearm.This read path does reach
formatOutput— it is not one of the bypass doors.loadImportJob(rest-server.ts:7923) callsp.findData, which lands onSqlDriver#find→findRows, andfindrunsthis.formatOutput(object, row)over every row for every dialect (sql-driver.ts:5665-5670), unlikeexecute/aggregate/distinct. So the card's mechanism is right for the right reason:formatOutputruns, and the two repairs inside it are what the dialect gate withholds.The site is the only one. The card's re-run expression and a widened sweep for any
String(...)over a date-ish column acrosspackages/rest/src/both return exactly these four lines.Why the
String()was deleted-shaped but not deletedTriage's sharpest observation is recorded and correct:
JSON.stringifywould already have serialised a bareDatecorrectly throughtoJSON(), so the explicitString()is one layer too many, not one too few. Deleting it is nevertheless the wrong repair, because it changes the emitted value's static type fromstringtostring | Date— and that widens a declared contract which three independent declarations spell asstring:packages/spec/src/api/export.zod.ts:466-486—ImportJobProgressSchema(theoutputofImportJobApiContracts.getImportJobProgress)createdAt: z.string(), the other threez.string().optional(), each documented "(ISO 8601)"packages/client/src/index.ts:5485, 6236— the SDK'sgetImportJobProgressImportJobProgress, az.inputof the abovepackages/types/src/data.ts:1022-1050—ImportJobProgressInfostring, each doc-commented "ISO-8601"The declaration was right; the emitted value was wrong. So the declaration does not move, and no consumer is touched.
No consumer is currently tolerating the broken spelling. Sweeping objectui and the SDK for a lenient parse of these four fields found none — the
Date.parse/new Date(...)sites there belong to unrelatedcreatedAts (comments, notifications, conversations). On Postgres/MySQL a client now receives the same instant spelled correctly, with the milliseconds it previously lost; on SQLite nothing changes at all.The spelling
The four sites go through one module-private helper carrying the same three branches as the two landed normalisers —
auditMetaItem'soccurredAtinprotocol.tsandcanonicalIsoInstantinsys-metadata-repository.ts(#13997, which landed into this branch mid-flight). One spelling repo-wide, not a new variant. The only difference fromcanonicalIsoInstantis its nullish arm, and that difference is forced by the call sites: it returnsundefinedso each caller's own?? DEFAULTchain keeps its meaning, whereas these four sites are the DTO's last step and the requiredcreatedAtfield's absent-value spelling ('') is folded in, exactly as theString(row?.created_at ?? '')it replaces produced.Verification
A pin on ISO-text fixtures would prove nothing — that identity is exactly what kept this green while the defect was live, including in this package's own real-engine SQLite integration test.
packages/rest/src/import-job-dto-timestamp-canonical.test.tstherefore drives realDates through the real route handlers under a forced non-UTC process zone, and asserts:String(new Date(...))really is non-canonical under the forced zone, and the three swept zones really do produce three different broken spellings;Asia/Shanghai,America/New_YorkandUTC;startedAt/completedAt/revertedAtentirely, and an absentcreated_atis still'';safeParseof each response against the spec's ownImportJobProgressSchema/ImportJobSummarySchema. Because the judgement here is about a value, a green parse is the assertion (not merely the absence of unknown keys) — and this limb is what refuses theString()-deletion route.Both mappers are covered:
importJobToSummaryre-readsimportJobToProgress's output, and the list route is pinned too.Ablation
Predicted direction: red on the
Datecases, green on the controls. No rebuild leg applies — the pin imports./rest-serverrelatively, within the same package, so the subject does not cross a package wall throughdist.The mutant is the pre-fix file taken byte-exact from
origin/main(git checkout BASE -- ABSOLUTE_PATH), rather than hand-written mutant text. That choice was forced by a first attempt whose shell quoting silently ate a''and producedString(row?.created_at ?? ): the run went red on aPARSE_ERRORhaving collected zero tests, which would have read as a behavioural failure. That first reading is void and is not reported as evidence; the rerun carries an explicit guard that refuses any leg failing to collect.902319cd(=origin/main, ≠ HEAD)String(row.*_at)present, 0 ×canonicalIsoStampaab40fd3(= HEAD blob)git diff HEADempty,git statuscleanThe mutated leg failing 4 of 6 while the two control cases stay green is the discrimination claim, measured rather than asserted. Restore was pinned to
HEADwith absolute paths undertrap … EXIT INT TERM, and proved by blob equality plus an emptygit diff HEAD— not by an exit code.Gates
Change set re-derived after the final commit (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, 4 paths, no stale-tree warning); the derived family grew from 39 to 54 when the census repair added acontent/docs/**path, and the 15 added docs gates were run too. All exit codes captured before any pipe. Union re-run on final headc680e23b22:pnpm --filter @objectstack/rest exec vitest run— 165 files / 2772 tests passedpnpm --filter @objectstack/rest typecheck— clean; its own verdict line:check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json. The new pin is in that program (confirmed bytsc -p tsconfig.test.json --listFiles, 1 hit), so this is a real reading over it and not a green over source nothing read.pnpm lint(eslint . --no-inline-config, whole repo) — exit 0pnpm check:type-check-coverage— OK, counts unchanged (11 DEBT / 309 frozen)PREREQUISITE NOT MET, exit 3 — recorded as neither pass nor red):check:dual-build-cjs-loadsandcheck:type-check-debtboth need a full built closure, andcheck-test-completenessneeds a savedturbo run testlog its local invocation is not given. CI owns all three.One gate genuinely went red, and it was mine
check-system-context-censusreported 16 problems. Measured as caused here rather than inherited: withrest-server.tsreverted toorigin/mainthe census is exit 0, and with this change exit 1. The cause is pure line rot — the helper adds a net +56 lines near the top of the file (60 insertions − 4 deletions), and everyisSystemanchor the census page cites in that file moved by exactly that offset. Repaired with the gate's own documented remedy for line rot (--fix): 10 anchors re-pointed, uniformly +56, no ledger row added or deleted and no prose changed. The census is exit 0 again.Recorded finding, not folded in
The shared canonical spelling throws on an Invalid
Date. All three copies (auditMetaItem,canonicalIsoInstant, and this one) call.toISOString()on anyDate, and that raisesRangeError: Invalid time valuefor an invalid one — where the previousString(v)served"Invalid Date". So for that one input shape the change trades a wrong-looking string for a 500. No driver in the measured input domain produces it (the enumeration incanonicalVersionInstant's docblock:Date, canonical ISO string, epoch-ms number, nullish), and deviating unilaterally would have been the fourth variant the ruling forbids, so the spelling is unchanged here. Raised for the maintainer as a property of the shared spelling rather than of this site.A correction to an earlier revision of this body
An earlier revision claimed the
pnpm --filterdirection memo in the dispatch instructions was backwards. That claim was wrong and is withdrawn. Re-measured precisely in this worktree (pnpm 10.31.0):'@objectstack/rest^...'selects 35 packages including@objectstack/driver-sql(a dependency of rest) and excluding@objectstack/client(a dependent) ⇒ dependencies;'...^@objectstack/rest'selects 18 includingclientand excludingdriver-sql⇒ dependents. That confirms the memo rather than contradicting it. The one genuine wrinkle worth passing on: under this pnpm version both forms still include the named package itself, so^does not exclude it here — which is why building'PKG^...'also rebuildsPKG.Generated by Claude Code