Skip to content

docs(skills): data-hooks 的 condition 一节按 before/after 拆开重述批量写绑定 (#5900) - #5989

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-5900-data-hooks-bulk-after
Aug 6, 2026
Merged

docs(skills): data-hooks 的 condition 一节按 before/after 拆开重述批量写绑定 (#5900)#5989
hotlong merged 1 commit into
mainfrom
claude/issue-5900-data-hooks-bulk-after

Conversation

@hotlong

@hotlong hotlong commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5900

纯散文修正:skills/objectstack-data/references/data-hooks.mdcondition CEL 绑定一节仍按 #5038 之前教「multi: true 批量写只触发一次、previous 无从绑定」,并结尾明令作者「never reach for previous in a hook that can fire on insert or on a multi: true write」。这对 before* 成立,对 after* 不成立。不动引擎、不动 spec、不动 releases。

前提核实(对 origin/main = 628b028)

issue 的断言 结果
两处措辞仍在 main 上 ✅ 成立(行号按 #5901 落地后漂移 +4::258-262:279-282,issue 正文写的是 :254-257 / :274-278)
这两处对 after* 不成立 bulk-write-per-row-hooks.test.ts 全文钉死按行派发,其常量 TRANSITION = 'record.status == "done" && previous.status != "done"' 正是 skill 明令禁止的写法,且按行求值通过
hook.zod.ts 已按 #5038 措辞 packages/spec/src/data/hook.zod.ts:352-357:「the after* events on a bulk write dispatch ONCE PER MATCHED ROW … input.id names that row, previous is its pre-image and result its post-state」
before* 那半句仍真 hook-condition-bulk-previous.test.ts 专门钉死这条不对称:before* 批次派发仍具名拒收(limitation: 'bulk_write_previous_unbound')并中止写入
同文件已自相矛盾 ✅ 同一份文件 :65-66(#5901 刚落地)已经写着 after* 按匹配行派发,而 :261-262 还写着「the hook fires once」

无断言被证伪。 唯一偏差是行号漂移,已按内容定位。

改了什么

按 issue 的验收口径拆成三段,原来的一条 bullet 变成两条:

  1. before*(含批量)整批触发一次,previous 无从绑定 —— 保留,并补上「为什么」:before* 仍可改写共享 payload,而一个批次只有一份 payload,所以没有按行的东西可交给它;同时补上同源事实 —— 那个派发上 record裸 payload,故这次写入未设置的已声明字段同样不可求值。读 previous 会被具名拒收,并被指向 after 事件。
  2. 新增:after* 在批量写上按匹配行派发([17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038) —— previous 是该行 pre-image,record 是该行真实状态(不是裸 payload),input.id 命名该行;因此 transition condition 在 afterUpdate / afterDelete可用的,写一次即可,不需要任何 bulk 分支。
  3. 插入事件(beforeInsert / afterInsert)previous 无从绑定 —— 保留,与批量无关。

结尾那段「Practical consequence」从「凡是可能批量触发就别用 previous」改为「把读 previous 的 condition 放在 after 类事件上 —— 别放在插入事件,也别放在可能被 multi: true 命中的 before* hook 上」。

一处被迫的连带修改

#4775 那段(不可求值 condition 中止写入)语义未动,只把其中「the two bullets above are load-bearing」改成按名指代(unbound-previoushas(...) 两条)。上面由两条 bullet 变成三条,计数式指代会失真;按名指代同时对将来的增删免疫。

措辞不自行发明:与 skills/objectstack-formula/SKILL.md:311-313 已落地的那张绑定表、hook.zod.ts:352-357hook-wrappers.ts:625-640 / :930-937 的注释同源。

验证 —— 反向验证方向说明(重要)

本单没有可 revert 的代码,所以模板里「恢复删掉的分支 → 新测试转红」那条对它不适用;照那个形状伪造一段证据比留空更糟。这里的真值验证是另一条:我写进文档的每一句,都对应仓内一条已绿的 pin 断言。两份 pin 测试全文实测:

Test Files  2 passed (2)
     Tests  44 passed (44)

句子 → 断言的映射(全部来自这 44 条):

新写的句子 钉住它的断言
after* 按匹配行派发 a bulk write fires after-hooks once per matched row > N matched rows ⇒ N dispatches;… > is UNIFORM — a condition that never mentions previous fires per row too
previous 是该行 pre-image each dispatch carries THAT row's previous / record > previous is the row's own pre-write state, not a shared one
record 是该行真实状态、非裸 payload … > record is the row's REAL state, not the bare payload (#4862 fact 4)
input.id 命名该行 … > input.id names the row, giving the single-record context shape (#2922)
transition condition 在 after* 上可用 … > the condition discriminates per row — only real transitions fire;the diagnostic is RETIRED for after-type hooks > the bulk write that #5037 rejected now succeeds, firing the hook per row
before* 整批触发一次、previous 无从绑定 the batch dispatch of a bulk write, whose condition reads previous > rejects with a machine-readable limitation, not just prose(bulk_write_previous_unbound)
原因是「phase」而非版本差 … > names the batch, the missing binding, and the PHASE as the reason
拒收会指向 after 事件 … > leads with the after-type event — the route the contract just made real;… > names beforeDelete for a delete-shaped batch dispatch
该处 record 是裸 payload,已声明未设置字段同样不可求值 a batch dispatch with no previous in the condition is unaffected > keeps the DECLARED-but-unset field diagnosis on its own limitation name(bulk_write_stored_state_unavailable)
#4775 语义未动(仍中止写入) … > still ABORTS the write — the rescoping is not an exemption

没有引入测试没钉住的新断言:上表左列每一句都能落到右列;反过来,凡钉子没覆盖的(例如批量写的性能护栏、行数上限)都没写进 skill。

门禁与测试

命令 结果
pnpm check:nul-bytes OK(5745 文件;另对改动文件自扫 [\x00-\x08\x0b\x0c\x0e-\x1f\x7f],零命中)
pnpm check:doc-authoring ✅ 362 files clean
pnpm check:docs-audit-scope ✅ 178 hand-written doc(s) in sync;releases 9 页 review-only
pnpm check:skill-frame-sync ✅ 4 copies isomorphic(39 markdown 扫描)
pnpm check:role-word OK(43 baselined,无新增)
pnpm check:adr-anchors OK(34 anchored)
pnpm check:org-identifier / check:release-notes / check:published-files OK
--filter @objectstack/spec check:skill-docs ✅ Skill docs in sync
--filter @objectstack/spec check:skill-refs ✅ 9 generated files in sync
--filter @objectstack/spec check:skill-examples ✅ 207 prose examples type-check
pnpm lint(eslint,--no-inline-config) exit 0
--filter @objectstack/objectql test 2129 passed (128 files)
--filter @objectstack/objectql typecheck exit 0

changeset:无 → 已自行加 skip-changeset 标签

照抄同文件同性质的前单 PR #5901(#5670,c15fcee,今日已合入):无 changeset,labels 为 documentation + skip-changeset。理由同源 —— 本 PR 零包源码改动,不发布任何 npm 包内容,按 .github/workflows/pr-automation.yml(#5292 / PR #5467)的处方取路线 2(skip-changeset 标签,标注 PREFERRED);路线 3(空 frontmatter changeset)在同一处方里是 LAST RESORT,因为它是 changesets/action 的真实输入(#4898)。

metadata.version 未 bump:本单未触碰任何 SKILL.md,且 skills/objectstack-data/ 内容修改的历史先例(72c3c8613cbb6a5ca228ad90e9a,其中 cbb6a5ca2 改的正是本文件)均未 bump。

#5947(观察类单)的关系:本单不试图解决它,但给它添一个读数 —— 「已发布 skill 变更走空 changeset」的先例链并非一致。同日的 #5901(2026-08-06 11:34 UTC 合入)在 skills/** 上走的是路线 2,时间上晚于 #5947 举证的最新反例 77adf297f(06:57 UTC)。本 PR 与之同口径,是路线 2 在 skills/** 上的第二个同日样本。

顺带发现

无。 全仓复查同句(grep -rniE "never reach for .previous|previous. is unbound|no single prior record|hook fires once",排除 node_modules 与测试):skills/objectstack-formula/SKILL.md:313content/docs/data-modeling/formulas.mdx:381packages/objectql/src/hook-wrappers.ts 三处均已按 before* 限定,措辞正确;packages/objectql/CHANGELOG.md 是已发布的历史记录,按 CLAUDE.md 不在代码 PR 里改。派发面点名排除的 skills/objectstack-data/rules/hooks.md 经核实不含本单点名的措辞(它那句 previous(undefined on insert)只讲插入,仍真;:115-122 的批量段落已由 #5901 更新到 #5038 口径),故无需搭车、也无可立项。


Generated by Claude Code

`skills/objectstack-data/references/data-hooks.md` 的 `condition` CEL 绑定一节
仍按 #5038 之前教「`multi: true` 批量写只匹配 N 行、hook 只触发一次,`previous`
无从绑定」,并在结尾明令「never reach for `previous` in a hook that can fire on
insert or on a `multi: true` write」。这对 `before*` 成立,对 `after*` 不成立 ——
后者正是 #5038(ADR-0058 批量写增补)修掉的行为,平台现在按匹配行派发,并有
`packages/objectql/src/bulk-write-per-row-hooks.test.ts` 逐条钉死。危害方向是
skill **明令禁止**平台已支持且已 pin 的模式:按它写的 AI 作者会给批量写路径手工
绕开 transition condition,或干脆不给批量写挂 `after*` 审计/通知 hook —— 而
#5038 的原始动机恰恰是那类 automation「静默不发生」。

按 issue 的验收口径拆成三段,每句都对应仓内一条绿断言:

- `before*`(含批量)整批触发一次,`previous` 无从绑定 —— 保留,并补上「为什么」
  (`before*` 仍可改写共享 payload,一个批次只有一份 payload)与该处 `record` 是
  裸 payload 这一同源事实;读 `previous` 会被具名拒收并指向 after 事件。
- 新增一条:`after*` 在批量写上**按匹配行**派发,`previous` 是该行 pre-image,
  `record` 是该行真实状态(非裸 payload),`input.id` 命名该行(#5038)——
  transition condition 因此在 `afterUpdate` / `afterDelete` 上**可用**,写法与
  单记录写完全一致。
- 插入事件(`beforeInsert` / `afterInsert`)`previous` 无从绑定 —— 保留,与批量
  无关。

#4775 那段(不可求值的 condition 中止写入)语义未动;只把其中「the two bullets
above」改成按名指代(unbound-`previous` 与 `has(...)` 两条),因为上面由两条
bullet 变成了三条,计数指代会失真。

不新增测试没钉住的断言:三段措辞逐句对应
`bulk-write-per-row-hooks.test.ts`(按行派发、每行绑定、`record` 为真实状态、
`input.id` 命名该行)与 `hook-condition-bulk-previous.test.ts`(`before*` 批次
派发具名拒收 `bulk_write_previous_unbound`、`record` 为裸 payload 时的
`bulk_write_stored_state_unavailable`、仍然中止写入),两文件本次实测 44 条全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
@vercel

vercel Bot commented Aug 6, 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 6, 2026 1:58pm

Request Review

@github-actions github-actions Bot added the size/s label Aug 6, 2026
@hotlong hotlong added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/s labels Aug 6, 2026 — with Claude
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 6, 2026
@hotlong
hotlong marked this pull request as ready for review August 6, 2026 14:10
@hotlong
hotlong added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 2392e5d Aug 6, 2026
27 of 28 checks passed
@hotlong
hotlong deleted the claude/issue-5900-data-hooks-bulk-after branch August 6, 2026 14:22
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 skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant