fix(metadata-protocol): render a composite externalId in seed diagnostics instead of its NUL-joined key - #16830
Conversation
… on BYTES (#16488) WIP: the regression pins land first so the red/green measurement is taken from a committed tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…tics instead of pasting the NUL-joined key (#16488) `externalIdKey` joins a composite key's parts with U+0000 on purpose — the byte cannot occur in a natural-key value, so ('a','b') and ('a\\0b','') never collide. That separator is untouched. What changes is that the KEY is no longer interpolated into human-readable diagnostics: one raw NUL makes grep classify the whole server log as binary, so every later grep -n over it silently returns nothing. New private `externalIdDisplay` renders a composite key as a JSON array of its parts and returns a single-field key byte-identical. Applied at the six message interpolations (buildWriteError's parenthetical and pass 2's five `on record '…'` sites); the structured `attemptedValue` and the loggers' structured context keep the real key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…he new pin file (#16488) check:engine-double-contract retained the new test file's two engine doubles (delete/findOne/update) as unrecorded; regenerated with --write, 3 rows added, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 1 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 — 10 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 c93bab4e994db0e2e6c6e4e67148d03ad942a5ee && git checkout c93bab4e994db0e2e6c6e4e67148d03ad942a5ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7f96e1417e01d011884272b28278b6b400521415 9996cb7638279bd3486249ee68a8bc78466b301a && git checkout -B drift-repro 7f96e1417e01d011884272b28278b6b400521415 && git merge --no-ff 9996cb7638279bd3486249ee68a8bc78466b301a
node scripts/docs-audit/affected-docs.mjs --json 7f96e1417e01d011884272b28278b6b400521415
|
落地前检:② 已满足,③ 尚未 —— 保持 draft
① 不适用 —— ② ✅ exit 0: ⭐ 两个阴性对照都发火,各自 exit 4:控制 A 只删掉 claim 评论里的 ③ ⛔ 未满足:10 个 workflow run 里, ⇒ 「入队资格要求全部 check 全绿」,而一个 ⇒ 本席下一轮巡检重读这一条; Generated by Claude Code |
Fixes #16488
A composite
externalId's NUL joiner no longer reaches a human-readable seed diagnostic. The map key is untouched.Source reading: objectstack-ai/ats#20 — measured there, filed here.
1. Call-site classification (the first deliverable)
Counts re-derived on this branch's base (
origin/maind4401f7), and the two circulating counts measure different things — so I say which I counted:externalIdKeyinseed-loader.tsexternalIdKey(The 11 is the 10 calls plus the
private externalIdKey(definition on line 2642; the 13 adds two doc-comment mentions ({@link SeedLoaderService.externalIdKey}and a prose sentence onDeferredUpdate.recordExternalId). Triage's "11 call sites" and the dispatching seat's "13 occurrences" are therefore both right about their own subject, and neither is the call-site count. I counted the 10 calls.const extIdOf = (rec) => this.externalIdKey(rec, externalId)existing.has(k)/existing.get(key)at 827, 852, 868recordExternalId:on aDeferredUpdateinsertedRecords.get(...)fallback lookup at 1760, and five messages that name the record by itconst externalIdValue(self-ref write, success)insertedRecords.setconst externalIdValue(self-ref write, catch)existingRecords.get+insertedRecords.setconst externalIdValue(batched path)insertedRecords.setx2,pendingInserts.push({ externalIdValue })->insertedRecords.setat 886const externalIdValue(dry run)insertedRecords.setwriteRecordexistingRecords.get(...)decideWriteActionexistingRecords.get(...)buildWriteErrorkeyValueattemptedValue(structured) and the(label=value)parentheticalattemptedValuekeeps the key; the message rendersloadExistingRecordsmap.set(key, record)Eight of the ten are pure map keys and not one character of them moved. The four sites triage flagged as
externalIdValue(1368 / 1379 / 1400 / 1449) are in that eight: the variable name does say "value" while it holds a key, but every one of them is consumed only as aMapkey, so renaming — not re-rendering — is what they would want. That is recorded in the acceptance notes, not done here.Two sites are dual, and the split runs between structured datum and prose:
errors[].attemptedValue(the loader's own header calls it "the record's EXTERNAL key ('which row')"), and the twologger.errorcontext objects that carryrecordExternalId. A machine reads these, and both serialisers a logger uses (JSON.stringify,util.inspect) escape a control character rather than emitting the byte.buildWriteError's parenthetical (the line the card quotes), pass 2's back-fill-FAILED line, pass 2's DROPPED logger line (two interpolations), pass 2's DROPPED payload message, and pass 2's UNRESOLVED-after-pass-2 line.2. The joiner is a fossil — read first, kept, quoted
The standing instruction applies squarely here, so the guard's own comment was read before anything moved. It is a ruling and it gives its reason:
(The source spells that separator as a JavaScript unicode escape; it is written here as
U+0000on purpose, so that no channel between the source file and this page can turn the quotation itself into the raw byte this card is about.)The fossil says the current KEY behaviour is deliberate, so the key is exactly as it was. What the fossil does not say — and never claimed — is that the same string belongs in a message. That is the only thing this PR changes. The quoted sentence is reproduced verbatim in the new renderer's own doc comment and in the regression file's header, so the next reader meets it before the same temptation.
3. Rendering chosen: a JSON array of the parts
New
private externalIdDisplay(key):JSON.stringify(key.split(U+0000)), e.g.(employer+user=["ats_employer-1788753956811-1","usr_ats_quillstone_admin"]).Why JSON and not
a + b. Triage's steer was to matchexternalIdLabel's+unless a value can itself contain+, in which case take JSON. A key part isString(record[field])for an arbitrary declared field — a compositeexternalIdis not restricted to resolved foreign keys, and a natural-key text value such asSales + Marketingis ordinary. So the joined form is ambiguous exactly where a composite key is interesting, and the condition triage named for JSON holds. JSON buys a second property the+form does not have:JSON.stringifyescapes control characters rather than passing them through, so a value that carries a control byte of its own cannot reintroduce the defect through this path — the rendering is NUL-free by construction, not by removal.Guarding on the bytes (
key.includes(U+0000)) rather than onArray.isArray(externalId)is deliberate for the same reason: it is the byte that hurts, so the byte is the discriminator.4. The assertion reads bytes, and it was measured red then green
packages/metadata-protocol/src/seed-loader-composite-key-diagnostic.test.tscounts occurrences of U+0000 and demands zero, over both halves of every diagnostic a load produced (result.errors[].messageand everylogger.errorline). AtoContain('employer+user')assertion is the trap this card names: the label side always had its+, so that assertion passes with the NUL still in place.Measured, at the two commits, one command:
seed-loader.tsat base (dcfb364)The three that were red are exactly the three composite-diagnostic assertions; the base run's received line shows the defect literally:
(employer+user=ats_employer-1788753956811-1 usr_ats_quillstone_admin)— that gap is the raw NUL. The other five were green at base and after, which is what makes them controls rather than decoration.Anti-vacuity, in the file: each scenario first asserts that the diagnostic under test actually fired, and one test proves the counter can answer non-zero on a control string, so a silently-broken counter cannot green the suite.
5. Both negative controls
A — a single-key diagnostic is byte-identical. Pinned as a whole-string
toBe, not atoContain:It was green at the base commit and green after — the same string on both trees, which is the measurement "byte-identical" is a claim about. The three pre-existing single-key pins agree:
seed-loader-driver-text.test.ts((name=acme):),@objectstack/objectql'sseed-loader-authoring-feedback.test.ts((name=bad_row):), and the two runtime pins inpackages/runtime/src/seed-loader.test.ts. All were run — see verification below.B — the map key still separates. Two tests, and both were green at base and after:
('a','b')and('a\0b',''), still land as two distinct rows;('x','y+z')and('x+y','z')both join tox+y+zunder a+joiner and would collide, and under the real joiner they insert as two rows on the first boot and skip as two rows on replay. That test goes red on exactly the "swap the NUL for+so the log looks nicer" edit this card forbids.6. Verification
Built the dependency closure before the first measurement (
pnpm --filter '@objectstack/metadata-protocol^...' build, exit 0). Every heavy run went throughscripts/pm/os-verify-lock.sh; verdicts read from itsVERDICT command-exitline.pnpm --filter @objectstack/metadata-protocol testpnpm --filter @objectstack/metadata-protocol typechecktsc --listFilesconfirms the new test file is in the program, so the green covers it@objectstack/objectqlseed-loader-authoring-feedback.test.ts(after building its closure)@objectstack/runtimesrc/seed-loader.test.ts(after building its closure)pnpm lint(eslint . --no-inline-config, repo-wide, at 9996cb7)pnpm check:nul-bytes— squarely on-topic, so quoted whole:Every escape in the new and edited source was written as an escape spelling and never as a byte; the diff was additionally hand-scanned with
grep -naPover the ASCII control range — no hits.Gate reconciliation, verbatim (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ...):That line accounts for coverage, not verdicts. The verdicts, stated separately:
pnpm check:engine-double-contractexited 1 first — the new test file pinned two engine doubles the ledger did not record ("New pinned coverage is GOOD ... the ledger just has to learn about it"). Regenerated with--write(3 rows added, 0 lost) and committed; the re-run exits 0.check:dual-build-cjs-loads("Runpnpm buildfirst. This is NOT a pass: nothing was measured") andcheck:type-check-debt(needs the full closure built). Both read built output for the whole repo; CI builds it and runs them there.7. Changeset
Route 1 of
.github/workflows/pr-automation.yml's WHICH LEVEL block (around lines 660-690): "It releases something -> run 'pnpm changeset' and name the packages it releases."The text rejected is route 2 — "It releases nothing (.github/, .claude/, skills/, docs/, content/, examples/, tests-only, and the like) -> apply the 'skip-changeset' label." It does not apply, and the decision is a measurement of what this package actually ships, not a feeling about size:
externalIdDisplayoccurrencespackages/metadata-protocol/dist/chunk-3DGZOO2L.js(ESM)packages/metadata-protocol/dist/chunk-JBBC54YJ.cjs(CJS)packages/metadata-protocol/dist/index.d.ts/.d.ctsprivate externalIdDisplay;, one inside a doc comment)The sibling round's reading holds here too: this bundle carries comment text into
dist, so even the doc-comment half of the diff ships.seed-loader.tsis behindindexthrough code splitting, which is whyindex.jsitself shows zero — the code ships in the chunk it re-exports.Level
patch. The WHICH LEVEL rule raises tominorfor "a purely additive widening of a published package's public surface (a new exported symbol on anindex, a new accepted key or value)".externalIdDisplayis aprivateclass member — emitted into the.d.tsasprivate externalIdDisplay;beside its siblingsprivate externalIdKey;/private externalIdLabel;, unusable by a consumer — so this is "afix(that changes no public surface", which the same paragraph keeps atpatch.AGENTS.md:1028is read as the floor againstnone("A bug fix in a released package takes apatchchangeset — never none, and neverskip-changeset"), not as a ceiling; nothing here asks for a level above it.Clause-②: no
The diagnostic's rendering moves. No exported symbol changes, no key on a published payload changes, and the map-key semantics do not move — my measurement agrees with the seat's declaration on all three. (
errors[].attemptedValuekeeps its exact previous value; the message string changes only for a composite key, which is the defect.)验收备注
The triage 验收口径 (all five items), copied in as required — quoted in the original, because rewriting a ruling is rewriting the ruling:
All five are addressed above: 1 in section 1 (ten calls classified, eight untouched), 2 in section 3 (JSON, with the
+-in-a-value condition triage itself named), 3 in section 4, 4 in section 5, 5 at the top.Out of scope, noted and not filed:
externalIdValue(lines 1368 / 1379 / 1400 / 1449) hold a key, not a value. Every use is aMapkey, so this is a naming defect, not a behavioural one — and a rename is outside this card. Recorded here rather than filed.errors[].attemptedValuedeliberately keeps the NUL-joined key for a composite. No consumer in this tree renders it into a log line (checked: nothing inpackages/clireads it), and both serialisation paths escape the byte — but a future consumer that string-concatenates it would reproduce the defect at its own boundary. An observation, not a reproducible defect here.domain:devxcard, which must first answer how to inspect emitted bytes without driving a real failure — 「⛔ 不平凡」. Not filed by me.priority:p3is the consequence grade, not licence to do it partially — 「它的修法成本近乎零,所以 ⛔ 不要因为 p3 就把它排到很后面」.Generated by Claude Code