fix(metadata-protocol): seed-loader pass 2 目标解析成功但源记录无 id 时,回填不再被静默丢弃 (#5127) - #5254
Merged
Merged
Conversation
…source record has no id (#5127) `resolveDeferredUpdates()` looked the source record's internal id up in `insertedRecords` and, when it wasn't there, ran off the end of an `if` with no `else`. Pass 2 had already RESOLVED the target, and the back-fill then evaporated: no write, no `errors`/`allErrors` entry (so the load still reported `success: true`), no `errored`, not one log line. The only trace was the `referencesDeferred` the record booked in pass 1 and never gave back — only a SUCCESSFUL back-fill decrements it — i.e. a result object carrying a dangling number with nothing in it that explains the number, while the declared association stayed absent forever. It is the deeper cousin of the two branches on either side of it: #4729 fixed "counted, but logged at `warn`"; #4997 fixed "counted, never logged"; this one was "never counted, never logged". The loss now goes through `recordDeferredError` under the same objective criterion (does the outcome enter `errors`/`allErrors`?) and is logged once at `error` per AGENTS.md → "Degradation log levels" (#4632), with the consequence and the fix. `referencesDeferred` keeps its meaning — "deferred references that never landed", decremented only by a successful back-fill, exactly as the two sibling failure branches leave it — so what changes is that a leftover count now always has a matching entry in `errors`. The two ways to get here are worded differently because they are different failures. An EMPTY `recordExternalId` is the PURE silent loss: `externalIdKey` returns `''` when a component of a composite key is blank, the row itself wrote perfectly, and nothing else in the load reports anything — this line is the only signal that will ever exist, so it names which key came out empty (carried on `DeferredUpdate.externalIdLabel`, added for it). A real key simply absent from the map means the source row never landed, and that write failure was already reported at `error` by the pass-1 site, so the line points AT that error instead of restating it — one line, not a second flood over the same root cause. Regression tests cover both triggers, the counter/error pairing, and a control proving the failure comes from the EMPTY key rather than from composite keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
os-zhuang
marked this pull request as ready for review
August 4, 2026 12:37
os-zhuang
enabled auto-merge
August 4, 2026 12:37
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5127
问题
packages/metadata-protocol/src/seed-loader.ts的resolveDeferredUpdates()里,if (recordId)没有else。pass 2 已经把目标解析成功了(resolvedValue是一个真实内部 id),却在insertedRecords里找不到源记录的内部 id,于是整条 deferred 回填直接从函数里掉出去:errors/allErrors——success仍然是true;errored;唯一残留的痕迹是 pass 1 记下、再也没还回来的那一笔
referencesDeferred(只有成功回填才会resultEntry.referencesDeferred--)—— 结果对象里挂着一个悬空数字,却没有任何一条错误解释它。这是左右两个兄弟分支的更深一格:#4729 修的是「有计数、日志停在
warn」,#4997 修的是「有计数、完全没日志」,这一处是「没计数、也没日志」。改动
命中同一条客观判据(该结局是否进入
errors/allErrors?)—— 进入,所以按 AGENTS.md「Degradation log levels」(#4632)记账 + 打一条error,写明后果与修法:else分支,走recordDeferredError(→errors/allErrors+errored,load 报success: false);referencesDeferred保持原语义 —— 「已 defer 且始终没落地的引用数」,只由成功回填递减,与另外两个失败分支的处理完全一致。变的不是算术,而是那笔悬空计数从此一定配得上一条errors记录;recordExternalId为空串 = 纯静默丢失。externalIdKey在键字段为空、或组合键任一分量为空时返回'',行本身写得好好的躺在库里,全局没有第二处会吭声 —— 这行日志是读者唯一能拿到的信号,所以它必须点名是哪个键空了(为此在 file-local 的DeferredUpdate上加了externalIdLabel,值形如name+region);error报过了,所以这行指向那条错误而不是复述它 —— 一行,不是对同一根因的二次淹没。同时它不会谎称「引用停在 NULL」:那行根本不存在,没有 NULL 可停。protocol.ts、packages/spec/**零改动;DeferredUpdate是本文件私有(未导出)接口,新增字段不构成公开契约变更;计数器、结果对象形状、既有result.errors文案均未改。测试
新增
packages/metadata-protocol/src/seed-loader-deferred-dropped.test.ts(6 例):['name','region']且region为空 —— 行成功写入库、inserted: 1,head_id仍为 null;errored: 1、success: false、恰好一条error日志(且是本次 load 唯一一条),含name+region、EMPTY key、stays NULL、修法与recordExternalId: ''的 meta;referencesDeferred === 1且errors.length === 1;并对所有结果条目断言「计数大于 0 则必须有错误解释」;region填值 —— 正常回填、success: true、totalReferencesDeferred: 0、logger.error/warn均未被调用。这证明触发点是空键而非组合键本身;CHECK constraint failed那条 [metadata-protocol] seed-loader 的「LOUD FAILURE」注释要求 error,代码却记 warn —— 第二类降级(#4632 规则),本轮因包冻结未修 #4729 日志原样保留;另有一例专门钉住它不会说stays NULL/EMPTY key。回归有效性已反向验证:把
seed-loader.ts还原成origin/main后,6 例中 5 例失败(唯一通过的正是对照组)。下游消费方(本改动会让原本静默的路径开始报错)也跑过:
@objectstack/objectql的 3 个 seed/publish 用例(24 passed)、@objectstack/runtime的 4 个 seed 用例(56 passed)。兼容性
用户可见:命中该路径的 load 过去返回
success: true且计数干净,现在返回success: false并把这次丢失计入errored。种子数据一直是不完整的,只是过去不肯说。已附.changeset/plenty-poems-brush.md(patch)。🤖 Generated with Claude Code
https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Generated by Claude Code