Skip to content

fix(metadata-protocol): by-id bulk writes refuse a row that names no record (#5088) - #5098

Merged
os-zhuang merged 1 commit into
mainfrom
claude/updatemany-hooks-nonexistent-id-p6pxb8
Aug 4, 2026
Merged

fix(metadata-protocol): by-id bulk writes refuse a row that names no record (#5088)#5098
os-zhuang merged 1 commit into
mainfrom
claude/updatemany-hooks-nonexistent-id-p6pxb8

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #5088

问题

updateMany 对一个不存在的 id 不做任何存在性检查,直接把该行送进 engine.update。写管线里没有可供叠加的存量记录,#4770 的记录物化(stored ⊕ payload)只剩 payload 一侧,hook 的 condition 读到任何本次未写入的字段都判为缺失,于是 #4775 的「条件不可求值即中止」触发 —— 该行以 INTERNAL_ERROR 失败,诊断信息还反过来指控一个正确的 hook 引用了对象未声明的字段。操作者批量更新时带了一个过期 id,被告知的却是「你的 hook 坏了」,并被指向对象的字段清单;atomic: true 下这一行还会把整批拖成 NOT_ATTEMPTED,一个陈旧 id 读起来就像平台故障。

按 PM 分诊复核,缺陷面比 issue 正文更宽 —— #4435 的「按行诚实」只落在本文件 5 个写面中的 2 个:

写面 修前
runUpdateManyLoop ❌ 无探针,不存在的 id 进 hook 管线(本单症状)
runBatchDataLoopupdate 分支 ❌ 同一缺口
runBatchDataLoopdelete 分支 ❌ 丢弃 engine.delete 返回值,无条件 success: true —— 一批 typo 的 id 全部回报删除成功,与 #4435 正文描述的原始缺陷逐字同形
runDeleteManyLoop ✅ 已修范本(deleted === false)
单记录 updateData ✅ 已修范本(probeRecord 先探再写)

修法

三个 by-id 写面补齐同一道闸门,恢复既有不变量,不引入新契约:

  • 抽出 assertRecordExists(object, id),内部就是单记录路径那只 probeRecord。按 fix(metadata-protocol): deleteManyData has the same fake-atomic as batchData, updateManyData ignores atomic entirely #4620 对本文件的要求,by-id 写面共用一份实现,而不是「今天恰好一致的三份」。
  • 探针问的是存在性,不是可见性(系统上下文读)。这是承重点:by-id 写的授权判断仍留在 engine.update 内的 fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994 前像检查里,rls-by-id-write 证明依然能报红。同样地,探针在写之前,不从 readback 为 null 反推 not-found —— updateData 的注释已说明后者会把一次成功的写(把 owner_id 改到自己作用域之外)误判成 404。
  • 事务内仍然读得到本批次自己的未提交状态:engine.transaction 在 ambient txStore 里执行回调(ADR-0034),buildDriverOptions 在上下文没有显式 transaction 时回落到该 store,所以探针与它所守护的写走同一条连接。
  • delete 侧照 runDeleteManyLoop=== false 读法 —— 契约(IDataDriver.delete)的 positive not-found 值,不从 falsy 返回值推断,以免把「返回被删行」的第三方 driver 变成假 404。

刻意不动:upsert(missing id 仍然插入)、predicate 批量写(multi: true,无逐行 id —— 属 #4800 / #4862 的未决设计卡)、atomic 的响应形状(causal row 位置不变,其后仍 NOT_ATTEMPTED)、以及所有真实 id 的行为。

验收对照

  • updateMany 不存在 id 的行 → errors[0].code === 'RECORD_NOT_FOUND',httpStatus: 404,消息与单记录路径逐字一致(Record {id} not found in {object},花括号处为实际 id / 对象名);测试直接断言三条路径产出同一条消息。
  • ✅ hook 未被触发 —— 断言 engine.update 对该行从未被调用(不只是响应码对)。
  • atomic: true 下该行为 causal row,其余 NOT_ATTEMPTED;先行成功的行读回存储确认已回滚。
  • batchData 的 update / delete 分支同步补齐;整批 typo 的 id 现在 succeeded: 0
  • ✅ 回归:真实 id 的 succeeded 计数、droppedFieldsreturnRecords: false 的裁剪、context 透传均不变。

测试

新增 packages/metadata-protocol/src/protocol.bulk-record-not-found.test.ts(15 例,三个写面 × 非 atomic/atomic + 回归)。fake engine 对未知 id 的写逐字复现 #4775 的中止信息,所以修复前的红是 issue 里那条 INTERNAL_ERROR,而不是某个抽象的断言差异。

先证红再信绿 —— 只回退 protocol.ts、保留测试:

Test Files  1 failed (1)
     Tests  11 failed | 4 passed (15)
AssertionError: expected 'INTERNAL_ERROR' to be 'RECORD_NOT_FOUND'
AssertionError: expected [ 'INTERNAL_ERROR', 'NOT_ATTEMPTED' ] to deeply equal [ 'RECORD_NOT_FOUND', 'NOT_ATTEMPTED' ]
AssertionError: expected 3 to be 2            // batch delete 把 typo 的 id 也算成功
AssertionError: expected 2 to be +0           // 整批 typo 的 id 全报删除成功
AssertionError: expected 'Hook \'showcase_audit_task_completion…' to be 'Record definitely_missing not found i…'

修复后全绿:Test Files 37 passed (37) / Tests 334 passed (334)(@objectstack/metadata-protocol),@objectstack/rest 577 passed (577),两包 typecheck 均通过(--force,无缓存)。

两处既有 fixture 需要同步:protocol.dropped-fields.bulk.test.tsfindOne 是个「什么都不存在」的空桩(此前无人调用它),现在它就是写前的存在性探针,必须对它自称要更新的行给出答案;protocol.batch-row-conformance.test.ts 里那条断言从未分类的引擎抛错(no such record)改为断言目录化的 404 —— 那正是本 PR 要产生的改进。

备注

高频调用方需知:这三个写面的每个 by-id 行,现在写之前多一次存在性读。这与单记录路径自 #4435 起接受的代价同形,已写入 changeset。

🤖 Generated with Claude Code

https://claude.ai/code/session_01BotUP49pqhvqGY393n2HfU

…record (#5088)

`updateMany`, and `batch`'s `update` and `delete` branches, now answer
RECORD_NOT_FOUND for a row whose id resolves to nothing — same code and same
message as the single-record PATCH/DELETE have carried since #4435.

#4435's per-row honesty had landed on 2 of the 5 write faces in this file:
`updateData` (existence probe) and `runDeleteManyLoop` (`deleted === false`).
The three bulk faces went straight to the engine:

  * `runUpdateManyLoop` / `runBatchDataLoop` update — no probe, so a stale id
    entered the WRITE PIPELINE. With no stored row to overlay, #4770's record
    materialisation produced a payload-only record, a hook condition reading
    any untouched field found it absent, and #4775's unevaluable-condition
    abort fired: the row failed INTERNAL_ERROR with a diagnostic accusing a
    correct hook of naming an undeclared field. Under `atomic` that row also
    took every later row to NOT_ATTEMPTED.
  * `runBatchDataLoop` delete — discarded the driver's return and pushed
    `success: true` unconditionally, so a batch of typo'd ids reported every
    one of them deleted. Verbatim the defect #4435 fixed in `deleteMany`, ten
    lines away.

The gate is the existing `probeRecord`, extracted behind `assertRecordExists`
so the by-id faces share ONE implementation (#4620's rule for this file): it
asks EXISTENCE, not visibility, keeping the by-id write policy inside
`engine.update` (#1994) and leaving the `rls-by-id-write` proof able to go red.
Delete keeps the `=== false` reading — the contract's positive not-found value,
never an inference from a falsy return.

Unchanged on purpose: `upsert` (a missing id still inserts), the predicate bulk
writes (`multi: true`, no per-row id — #4800/#4862's open design cards), the
`atomic` response shape, and every row with a real id.

Two existing fixtures needed the probe to be answerable rather than a stub
(`findOne` returned null/undefined while nothing called it); the batch-row
conformance pin now asserts the catalogued 404 where it asserted an
unclassified engine throw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BotUP49pqhvqGY393n2HfU
@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 4:22am

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.

Copy link
Copy Markdown
Contributor Author

范围外发现(已另行开单,未在本 PR 修改)

按 Prime Directive #10 登记,两条都在本次改动读到的同一个循环里,但都不在 #5088 的验收面内:

本 PR 严格只覆盖 PM 分诊列出的三个写面(updateManybatchData.updatebatchData.delete),以及每行自带 id 的路径 —— predicate 批量写(multi: true)的 hook / flow 语义仍属 #4800 / #4862 的未决设计卡,一行未动。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 04:57
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 75bb3af Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/updatemany-hooks-nonexistent-id-p6pxb8 branch August 4, 2026 05:09
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.

data: updateMany runs hooks for a nonexistent id — the row fails INTERNAL_ERROR from a hook-condition abort instead of RECORD_NOT_FOUND

2 participants