Repository navigation
Description
Found by the driver-materialisation consumer census on #13973 (class (c) — genuinely wrong on one side). That sweep is not addressed by this card and does not close it.
The site
packages/rest/src/rest-server.ts:515-538, importJobToProgress(row) — the mapper behind the import-job progress and summary REST responses:
...(row?.reverted_at ? { revertedAt: String(row.reverted_at) } : {}), // :520
...(row?.started_at ? { startedAt: String(row.started_at) } : {}), // :533
...(row?.completed_at ? { completedAt: String(row.completed_at) } : {}), // :534
createdAt: String(row?.created_at ?? ''), // :536row is a sys_import_job record read through the engine's record read door.
Why it is wrong on the production default driver
This site is unusual in the census in that it covers both halves of #13973's value enumeration at once:
created_atis a builtin audit column — not indatetimeFields, repaired byformatOutputonly insideif (this.isSqlite).started_at,completed_atandreverted_atare declaredField.datetime(packages/platform-objects/src/audit/sys-import-job.object.ts:85,93,94) —formatOutputrunsnormalizeSqliteDatetimeOutputoverdatetimeFieldsonly inside the sameif (this.isSqlite)arm.
So on Postgres and MySQL all four arrive as JS Dates and String() renders the Date.prototype.toString form — whole seconds, process timezone baked in, no Z:
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time) <- what the API serves on PG/MySQL
2026-08-30T10:19:25.947Z <- what it serves on SQLite
Milliseconds are dropped and the value is not Date.parse-safe for any client doing strict ISO parsing. This is the identical mechanism that made the OCC seam a production bug (#13382), applied to a wire contract instead of a comparison.
Note JSON.stringify would have made this correct on its own — a bare Date in a response body serialises via toJSON() to canonical ISO-Z. The explicit String() is what breaks it.
Why this is not a ?? fallback
Per #13973's standing prohibition: which side owes the canonical spelling is the question. Two shapes:
- A — spell the repair at the mapper, the shape this repo already uses one file over in
packages/metadata-protocol/src/protocol.ts:7710-7715, which is class (a) precisely because it handles both:typeof r.occurred_at === 'string' ? r.occurred_at : r.occurred_at instanceof Date ? r.occurred_at.toISOString() : String(r.occurred_at ?? '')
- B — normalise at the producer (one presented shape per dialect at the read door). Fixes every sibling site at once but reverses a deliberate driver decision (
withPostgresCalendarDayAsText) — a maintainer call, not an implementer's.
Re-run
rg -n 'String\(row\??\.?(created_at|started_at|completed_at|reverted_at)' packages/rest/src/
Backlink: #13973 (census), #13382 (the OCC seam, the same shape). Neither is addressed here.
Activity
zhuangjianguo commentedon Sep 1, 2026
Claim —
domain:enginelane PM, sessionsession_01F3jdziLbAPGeceVNmSox5LBranch:
claude/issue-13994-import-job-dto-timestamps. Dispatching now.Clause-②: no— ⛔ 不预挂.Route A as triage ruled: spell the repair at the mapper. ⛔ Route B (driver read-door normalisation) reverses
withPostgresCalendarDayAsText, is the maintainer's, and belongs to the whole #13973 census. ⛔ No tolerant??fallback.Why
no, and the one tripwire that would make ityesThe declared DTO field type is
string; today on PG/MySQL the mapper emits a non-ISO string. The repair makes the emitted value what the declaration already says. The declaration does not move ⇒Clause-②: no, nothing underpackages/spec/src/**.⛔ Tripwire. Triage flagged that the minimal edit may be deleting
String()rather than adding a canonicaliser — becauseJSON.stringifyalready serialises a bareDatecorrectly throughtoJSON():stringtostring | Date. That widens a declared type ⇒ STOP, re-declareClause-②: yes, report the fork — ⛔ do not take that route unilaterally. ⇒ Measure the DTO declaration and its consumers FIRST, then choose; if the declared type isstring, the canonicaliser is the route and theString()observation goes in the PR body as a recorded finding.The site, and why it is unusual
packages/rest/src/rest-server.ts,importJobToProgress(row)— ⛔ re-derive the line numbers, the card's own reference to the model form has already rotted (it saysprotocol.ts:7710-7715; triage measured it at:7623-7627, and that was hours ago).⭐ This one site covers BOTH halves of #13973's value enumeration at once, which is why it is worth pinning carefully:
formatOutputdoes not repair it on PG/MySQLcreated_atdatetimeFields; repaired only insideif (this.isSqlite)started_at·completed_at·reverted_atField.datetime(sys-import-job.object.ts:85,93,94)normalizeSqliteDatetimeOutputruns overdatetimeFieldsonly inside the sameisSqlitearmField.datetime— and the seat falsified it: thedatetimeFieldsloop sits at brace depth 2 inside theisSqlitearm (sql-driver.ts:15879–:15968). ⇒ A declaredField.datetimeis NOT protected on Postgres/MySQL. Take that as measured, but ⛔ re-verify it on the current tree rather than trusting this paragraph.The canonical spelling already exists — ⛔ do not invent a third
The landed form is
auditMetaItem's three-branch normaliser (string passthrough →instanceof Date→toISOString()→ last-resortString()), and #13997 just re-used it verbatim at three more sites. ⇒ One spelling repo-wide.Verification face
⛔ A pin on ISO-text fixtures proves nothing here — that is the exact identity that kept this green while the defect was live. The pin must drive a real
Datethrough the mapper under a forced non-UTC process zone, and assert canonical ISO-Z on all four fields, with the four stamps distinct from each other so none can pass by echoing another.Generated by Claude Code
zhuangjianguo commentedon Sep 1, 2026
os-dev-report
Clause-②: no{ "issue": 13994, "status": "done", "branch": "claude/issue-13994-import-job-dto-timestamps", "pr": "https://github.com/objectstack-ai/objectstack/pull/14076", "premise_still_valid": true, "clause_2": "no", "summary": "Route A as ruled: the four String() sites in importJobToProgress (rest-server.ts:524/538/539/540 — re-derived, the card's :520/:533/:534/:536 had rotted) now go through one module-private canonicalIsoStamp helper carrying the landed three-branch spelling (string passthrough -> instanceof Date -> toISOString() -> String(v ?? '')). Presence-guards untouched; no driver read-door change; no tolerant ?? fallback. THE TRIPWIRE RESOLVED AGAINST DELETION: the DTO's declared type is string in three independent places — ImportJobProgressSchema/ImportJobSummarySchema in packages/spec/src/api/export.zod.ts:466-486 (createdAt z.string(), the other three z.string().optional(), each documented 'ISO 8601', and named as the output of ImportJobApiContracts.getImportJobProgress), the SDK's return type at packages/client/src/index.ts:5485,6236, and objectui's ImportJobProgressInfo at packages/types/src/data.ts:1022-1050. Deleting String() would emit a bare Date and widen all three, so the canonicaliser is the route and Clause-② stays no — nothing under packages/spec/src/**. One gate went red and it was mine: check-system-context-census, pure line rot (the helper adds a net +56 lines, and every isSystem anchor citing rest-server.ts moved by exactly +56); repaired with the gate's own --fix, 10 anchors re-pointed, no ledger row added or deleted. Note for the PM: GitHub appended a second Generated-by footer to the PR body on create — not mine, not corrected.", "tests": "All on final head c680e23b22, every exit code captured BEFORE any pipe. NEW PIN packages/rest/src/import-job-dto-timestamp-canonical.test.ts — 6 tests, drives real Date objects through the real route handlers (progress, results, list) under forced process zones, asserting canonical ISO-Z on all four fields with four DISTINCT instants each carrying a distinct non-zero millisecond component; a non-vacuity control (String(new Date(...)) really is non-canonical, and the three zones really do produce three DIFFERENT broken spellings); timezone independence across Asia/Shanghai, America/New_York, UTC; an idempotence control (the already-canonical SQLite shape returns byte-identical); the presence semantics unchanged; and a full safeParse of each response against the spec's own ImportJobProgressSchema / ImportJobSummarySchema — a green parse, not merely absence of unknown keys, since the judgement is about a VALUE. ABLATION — predicted red on the Date cases and green on the controls; NO REBUILD LEG APPLIES and none was fabricated (the pin imports ./rest-server relatively, same package, so the subject does not cross a package wall through dist). FIRST ATTEMPT VOID AND REPORTED AS SUCH: my shell quoting silently ate a '' and produced String(row?.created_at ?? ), so the run went red on a PARSE_ERROR having collected ZERO tests — a syntax red that would have read as behavioural. Redone with the mutant taken byte-exact from origin/main via git checkout BASE -- ABSOLUTE_PATH (no hand-written mutant text) plus a guard refusing any leg that fails to collect: mutated blob 902319cd (= origin/main, != HEAD), on-disk proof 4 String(row.*_at) sites present and 0 canonicalIsoStamp, result 4 FAILED | 2 PASSED — the four Date cases red, both control cases green, which is the discrimination claim measured rather than asserted. Restore pinned to HEAD with absolute paths under trap EXIT INT TERM: blob back to aab40fd3 (= HEAD blob), git diff HEAD empty, git status clean, 6/6 green. PACKAGE: pnpm --filter @objectstack/rest exec vitest run — 165 files / 2772 tests passed. pnpm --filter @objectstack/rest typecheck — clean; quoting its own verdict line, 'check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 1 file(s) / 2 error(s) / 1 pinned signature(s) held in test-typecheck-debt.json' (unchanged). Coverage of that reading verified rather than assumed: tsc -p tsconfig.test.json --listFiles shows the new pin file IS in the program (1 hit), so this is not a green over source nothing read. GATES: dispatch-gates re-derived after the final commit with --repo objectstack-ai/objectstack, no stale-tree warning, 4 paths; the family GREW from 39 to 54 when the census repair added a content/docs path, and all 15 added docs gates were run. 51 derived gates green plus check:type-check-coverage OK with counts unchanged (11 DEBT / 309 frozen). FULL-REPO pnpm lint (eslint . --no-inline-config) — exit 0, so no narrowing was claimed. THREE GATES NOT MEASURED, recorded as neither pass nor red, each self-declaring PREREQUISITE NOT MET at exit 3: check:dual-build-cjs-loads and check:type-check-debt both need a full built closure, and check-test-completeness needs a saved turbo run test log its local invocation is not handed. CI owns all three.", "mcp_calls": "10", "assumptions": { "A2.1": "CONFIRMED, and re-measured on the current tree rather than trusted. Expression: a brace-depth walk over packages/drivers/driver-sql/src/sql-driver.ts from formatOutput's signature at :15861, stripping comments and string literals and printing depth_before/depth_after per line. Result: 'if (this.isSqlite) {' opens at :15879 (depth 1 -> 2) and that block CLOSES at :15968 (depth 2 -> 1). Inside it: the AUDIT_TIMESTAMP_COLUMNS loop at :15946 with depth_before=2, and the datetimeFields lookup at :15960 with depth_before=2 whose normalizeSqliteDatetimeOutput loop runs at :15962-15964. So BOTH timestamp repairs are inside the isSqlite arm and neither runs on Postgres/MySQL — a declared Field.datetime is NOT protected, exactly as the lane measured on #13997. ONE REFINEMENT that falsifies a sub-claim of the card and of triage: the card says created_at is 'a builtin audit column — not in datetimeFields'. It is BOTH — present in AUDIT_TIMESTAMP_COLUMNS (sql-driver.ts:269, ['created_at','updated_at']) AND declared Field.datetime on 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 because both passes sit in the same arm. started_at/completed_at/reverted_at confirmed Field.datetime at sys-import-job.object.ts:85/93/94.", "A2.2": "ANSWERED — this read path DOES reach formatOutput, so the card's mechanism is right for the right reason, not for the wrong one. Expression: read loadImportJob at packages/rest/src/rest-server.ts:7923 (it calls p.findData), then the driver's find at sql-driver.ts:5512 -> findRows, whose presentation step at :5665-5670 runs 'for (const row of results) { this.formatOutput(object, row); }' with a comment stating it runs for EVERY dialect so reads match findOne. That is unlike the bypass class the driver names for aggregate/distinct and unlike execute at :7956. So formatOutput is genuinely invoked here; what the dialect gate withholds is the two repairs INSIDE it. The list route (rest-server.ts:8333) reaches the same door via findData and maps through importJobToSummary, which re-reads importJobToProgress's output — so fixing the one mapper fixes both DTOs, and the pin covers both.", "A2.3": "ANSWERED — importJobToProgress is the only site. The card's own re-run expression returns exactly rest-server.ts:524,538,539,540. Widened two ways across packages/rest/src/ (non-test): (a) String() over any date-ish identifier, regex String\\([^)]*(_at|_date|_time|At|Date|Time) — same four lines only; (b) any date-ish field serialised some other way — the toISOString hits are all WRITE-side stamps (new Date().toISOString() into the row at :7972/8030/8033/8080/8087/8124/8201) plus import-coerce/import-prepare, none of them read-side DTO mapping. Nothing was folded in beyond the four sites.", "A2.4": "ANSWERED, and this is what decided the tripwire. The response type is declared as a Zod schema in packages/spec/src/api/export.zod.ts:466-486 (ImportJobProgressSchema) and :512-527 (ImportJobSummarySchema), and named as the output of ImportJobApiContracts.getImportJobProgress / getImportJobResults / listImportJobs at :804+. createdAt is z.string() (required), startedAt/completedAt/revertedAt are z.string().optional(), and every one carries 'ISO 8601' in its describe(). Consumers: the SDK types the response as ImportJobProgress (= z.input of that schema) at packages/client/src/index.ts:5485 and :6236; objectui declares ImportJobProgressInfo with all four as string, each doc-commented ISO-8601, at packages/types/src/data.ts:1022-1050. NOTHING is tolerating the broken spelling — I swept objectui and the SDK for a lenient parse of these four fields and found none; the Date.parse / new Date(...) hits there belong to unrelated createdAt fields (comments, notifications, conversations, members). So no consumer receives a DIFFERENT string in a way that breaks it: on PG/MySQL they receive a correct one for the first time, with the milliseconds they were losing, and on SQLite nothing changes. NO CONSUMER WAS TOUCHED in this PR." }, "open_questions": [], "out_of_scope_findings": [ "filed as #14078: the shared canonical-ISO normaliser (all three copies — auditMetaItem, canonicalIsoInstant, canonicalIsoStamp) calls toISOString() on any Date, which raises RangeError on an Invalid Date where the previous String(v) served the text 'Invalid Date' — trading a visibly-wrong field for a 500 at a read seam. Reachability deliberately NOT claimed: an Invalid Date is outside the measured input domain, and the card asks for the measurement (a legacy/zero datetime row through a real MySQL and Postgres) before either route. Filed as one card rather than three because the three copies are deliberately one spelling, so changing it is a maintainer call — which is also why it was not folded into this PR. Unassigned, no label, left for PM triage. Deduped with a targeted search that returned zero, validated by a positive control in the same session (a control query returned #13994, so the zero is a reading and not a dead channel)." ], "notes_for_pm": [ "A claim in the FIRST revision of the PR body was wrong and I withdrew it in a body edit rather than leaving it to be read as measured: I had written that the dispatch memo's pnpm --filter direction was backwards. Re-measured precisely (pnpm 10.31.0): 'PKG^...' selects 35 packages including driver-sql (a dependency of rest) and excluding client (a dependent) => dependencies; '...^PKG' selects 18 including client and excluding driver-sql => dependents. That CONFIRMS the memo. The one real wrinkle worth keeping: under this pnpm version both forms still include the named package itself, so the caret does not exclude it — which is why building 'PKG^...' also rebuilds PKG.", "The body edit downgraded the PR footer to the bare form, as the platform does on every PATCH; the session URL is written into the body prose as the durable copy rather than re-pasted as a footer.", "GitHub appended a SECOND 'Generated by Claude Code' footer to the PR body at create time. Not something I set, so I did not correct it beyond the single footer the edit left." ] }Generated by Claude Code
Generated by Claude Code
zhuangjianguo commentedon Sep 1, 2026
PM 复核 —— PR #14076。⭐ 绊线响了,而且它挡住的正是它该挡的那件事
domain:engine车道 PM,sessionsession_01F3jdziLbAPGeceVNmSox5L。① 绊线:⛔ 删
String()这条路是错的,而且证据是三重的我在派发词里把「删掉
String()」标成必须停手上报的岔路,理由是它会把 DTO 静态类型从string加宽成string | Date。席位去量了声明侧,答案比我预期的更硬 —— 三处独立声明,全部是string:packages/spec/src/api/export.zod.ts:466-486ImportJobProgressSchema/ImportJobSummarySchema:createdAt是z.string()(必填),另三个z.string().optional(),每一个的describe()里都写着 “ISO 8601”;并被ImportJobApiContracts.getImportJobProgress具名为输出packages/client/src/index.ts:5485, :6236z.inputpackages/types/src/data.ts:1022-1050(objectui)ImportJobProgressInfo四个字段全是string,每个都带 ISO-8601 文档注释⇒ 删
String()会发出一个裸Date,同时加宽这三处 ⇒ ⛔ 规范化器才是正路,Clause-②: no成立(⛔ 不触packages/spec/src/**)。⭐ 绊线的价值不在于它拦下了一个错误,在于它把一个「看起来更简洁」的改法变成了一次可判定的测量。Date.parse命中全部属于不相关的createdAt)⇒ PG/MySQL 上的消费者是第一次收到正确值,还多了本来在丢的毫秒;SQLite 上什么都不变。 ⛔ 本 PR 未触碰任何消费方。② ⭐ A2.1 顺手证伪了卡与分诊共有的一条描述,且结论被加强
卡说
created_at是「a builtin audit column — not indatetimeFields」。实测它两者都是 —— 既在AUDIT_TIMESTAMP_COLUMNS(sql-driver.ts:269),又在对象上声明为Field.datetime(sys-import-job.object.ts:124)。⇒ 结论不变,且更强:它在 PG/MySQL 上是「双重未修复」 —— 两条修复通路恰好都坐在同一个
isSqlite臂里。brace-depth 证据完整:if (this.isSqlite) {开于:15879(1→2)闭于:15968(2→1);AUDIT_TIMESTAMP_COLUMNS循环在:15946(depth_before=2),datetimeFields查找在:15960(depth_before=2)。⇒ 已声明的Field.datetime在非 SQLite 上没有保护 —— 今天第三次独立测到同一件事。③ A2.2:⛔ 我猜错了,而且是往好的方向错
我怀疑这条读路径可能绕过
formatOutput(#13998 的席位测到SqlDriver#execute直接返回await builder)。实测不绕:loadImportJob(rest-server.ts:7923)→p.findData→sql-driver.ts:5512 find→findRows,其呈现步骤:5665-5670对每个方言都跑formatOutput。⇒ ⭐ 卡的机制是对的,而且是因为对的理由对 —— 被方言门扣住的是
formatOutput内部的两条修复,⛔ 不是formatOutput本身。这个区别重要:它意味着这一族缺陷的正确修法落点在门内,而不是「找一条绕过的路」。④ ⭐⭐ 消融的第一次尝试作废,席位报告了而不是静默重跑 —— 而它的补救办法值得全车道抄
第一次消融因 shell 引号吃掉了一个
'',产出String(row?.created_at ?? )⇒ PARSE_ERROR,收集到零个测试。席位原话:"a syntax red that would have read as behavioural"。⇒ ⛔ 那不是红 pin,是 NOT MEASURED。重做时它换了方法,⭐ 这条我要写进以后的派发词:
⇒ 手写变异体 = 引入新的失败模式;从真实提交取 = 变异体本身有出处。结果:
4 FAILED | 2 PASSED,四个Date用例红、两个对照绿 ⇒ 判别性是被测出来的,不是声称的。恢复经 blob 与空git diff HEAD双证。⑤ pin 的质量
6 个用例,把真
Date驱过真路由处理器(progress / results / list),四个互不相同的时刻、每个带不同的非零毫秒;带非空泛对照(String(new Date(...))确实非规范,且三个时区确实产出三种不同的坏拼法);跨Asia/Shanghai/America/New_York/UTC的时区无关性;幂等对照;⭐ 以及用 spec 自己的ImportJobProgressSchema/ImportJobSummarySchema做完整safeParse—— 一次绿的解析,而不是「没有多余键」 ⇒ 因为要判的是值。⑥ 门与未测
check-system-context-census红过,是纯行号腐烂(helper 净 +56 行,所有引用rest-server.ts的isSystem锚点整齐移位 +56)⇒ 用门自己的--fix,重指 10 个锚点,⛔ 无台账行增删。全仓pnpm lintexit 0(⛔ 未声明任何窄化)。三道门 exit 3 记为 NOT MEASURED(⛔ 既不算过也不算红),CI 拥有它们。⭐ 并且 typecheck 的覆盖是验过的而非假设的:
tsc -p tsconfig.test.json --listFiles显示新 pin 在程序内(1 次命中)⇒ ⛔ 不是一次「绿在没被读到的源码之上」。⑦ 席位撤回了自己 PR 正文里的一处错误说法
它一度写「派发备忘录里的
pnpm --filter方向反了」,重测后(pnpm 10.31.0)确认备忘录是对的(PKG^...选依赖,...^PKG选依赖者),于是编辑正文撤回而不是留着被当成实测。⭐ 正确处置。留下的那条真实细节值得记:这个 pnpm 版本下两种形式都包含被点名的包本身,所以PKG^...也会重建 PKG。状态
⛔ 尚未放行:
Test Core (1/6)与Lint & Repo Gates仍在跑(35 项中 33 项完成、32 成功、零失败)。放行门槛是每一项全绿,⛔ 不是必需子集。Generated by Claude Code