Skip to content

fix(metadata-protocol): seed-loader pass 2 目标解析成功但源记录无 id 时,回填不再被静默丢弃 (#5127) - #5254

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5127-seed-loader-deferred-dropped
Aug 4, 2026
Merged

fix(metadata-protocol): seed-loader pass 2 目标解析成功但源记录无 id 时,回填不再被静默丢弃 (#5127)#5254
os-zhuang merged 1 commit into
mainfrom
claude/issue-5127-seed-loader-deferred-dropped

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5127

问题

packages/metadata-protocol/src/seed-loader.tsresolveDeferredUpdates() 里,if (recordId) 没有 else。pass 2 已经把目标解析成功了(resolvedValue 是一个真实内部 id),却在 insertedRecords 里找不到源记录的内部 id,于是整条 deferred 回填直接从函数里掉出去:

  • 不写库 —— 引用永久保持 NULL;
  • 不进 errors / allErrors —— success 仍然是 true;
  • 不动 errored;
  • 一行日志都没有。

唯一残留的痕迹是 pass 1 记下、再也没还回来的那一笔 referencesDeferred(只有成功回填才会 resultEntry.referencesDeferred--)—— 结果对象里挂着一个悬空数字,却没有任何一条错误解释它。

这是左右两个兄弟分支的更深一格:#4729 修的是「有计数、日志停在 warn」,#4997 修的是「有计数、完全没日志」,这一处是「没计数、也没日志」。

改动

命中同一条客观判据(该结局是否进入 errors/allErrors?)—— 进入,所以按 AGENTS.md「Degradation log levels」(#4632)记账 + 打一条 error,写明后果修法:

  1. 补上 else 分支,走 recordDeferredError(→ errors/allErrors + errored,load 报 success: false);
  2. referencesDeferred 保持原语义 —— 「已 defer 且始终没落地的引用数」,只由成功回填递减,与另外两个失败分支的处理完全一致。变的不是算术,而是那笔悬空计数从此一定配得上一条 errors 记录;
  3. 两种触发情形分开措辞,因为它们不是同一个故障:
    • recordExternalId空串 = 纯静默丢失externalIdKey 在键字段为空、或组合键任一分量为空时返回 '',行本身写得好好的躺在库里,全局没有第二处会吭声 —— 这行日志是读者唯一能拿到的信号,所以它必须点名是哪个键空了(为此在 file-local 的 DeferredUpdate 上加了 externalIdLabel,值形如 name+region);
    • 非空键但不在 map 里 = 源记录压根没落地,而那次写失败pass-1 写入点已经按 [metadata-protocol] seed-loader 的「LOUD FAILURE」注释要求 error,代码却记 warn —— 第二类降级(#4632 规则),本轮因包冻结未修 #4729error 报过了,所以这行指向那条错误而不是复述它 —— 一行,不是对同一根因的二次淹没。同时它不会谎称「引用停在 NULL」:那行根本不存在,没有 NULL 可停。

protocol.tspackages/spec/** 零改动;DeferredUpdate 是本文件私有(未导出)接口,新增字段不构成公开契约变更;计数器、结果对象形状、既有 result.errors 文案均未改。

测试

新增 packages/metadata-protocol/src/seed-loader-deferred-dropped.test.ts(6 例):

  • 纯静默丢失(判据 3c 必须覆盖项):组合键 ['name','region']region 为空 —— 行成功写入库、inserted: 1,head_id 仍为 null;errored: 1success: false、恰好一条 error 日志(且是本次 load 唯一一条),含 name+regionEMPTY keystays NULL、修法与 recordExternalId: '' 的 meta;
  • 计数自洽(判据 2):referencesDeferred === 1errors.length === 1;并对所有结果条目断言「计数大于 0 则必须有错误解释」;
  • 对照组(判据 3b):同一组合键、region 填值 —— 正常回填、success: truetotalReferencesDeferred: 0logger.error/warn 均未被调用。这证明触发点是空键而非组合键本身;
  • 源记录 pass 1 写失败(判据 3c 另一半):两处损失都被报告(行 + 关联),dropped 回填只多打一条日志并指向 pass-1 那条错误,CHECK constraint failed 那条 [metadata-protocol] seed-loader 的「LOUD FAILURE」注释要求 error,代码却记 warn —— 第二类降级(#4632 规则),本轮因包冻结未修 #4729 日志原样保留;另有一例专门钉住它不会stays NULL / EMPTY key

回归有效性已反向验证:把 seed-loader.ts 还原成 origin/main 后,6 例中 5 例失败(唯一通过的正是对照组)。

pnpm --filter @objectstack/metadata-protocol test
  Test Files  40 passed (40)
       Tests  354 passed (354)

pnpm --filter @objectstack/metadata-protocol build          # tsup ESM/CJS/DTS 全绿
node scripts/check-durability-degradation-log-level.mjs     # 14 seam(s), all loud
pnpm exec eslint <改动的两个文件>                            # 0 problems
tsc --noEmit -p packages/metadata-protocol                  # seed-loader.ts 自身 0 error

下游消费方(本改动会让原本静默的路径开始报错)也跑过:@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

…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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 12:40pm

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit da538b1 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5127-seed-loader-deferred-dropped branch August 4, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[metadata-protocol] seed-loader pass 2:目标解析成功但记录 id 找不到时,整条 deferred 回填被静默丢弃(既不计数也不打日志)

2 participants