feat(objectql)!: 批量写按行语义 —— after 型 hook 按行触发,record-change trigger 按行绑定 previous/record (#5038) - #5270
Merged
Conversation
…ecord (#5038) 2026-08-04 维护者在 #4800 / #4862 上的裁定,已记入 ADR-0058 的 bulk-write addendum:**一次 predicate(`multi: true`)写就是 N 次记录变更**,所以其上每一条 以记录为作用域的声明都按行求值 —— `record` 为该行的实际状态,`previous` 为该行的 前置状态。validation 侧自 #3106 起就是这个形状;hook `condition` 与骑在同一批 生命周期 hook 上的 record-change flow trigger 现在与之对齐。 原先的问题:`multi: true` 更新走到 `driver.updateMany`,它只解析出受影响行数。 生命周期 hook 只触发**一次**,`previous` 从不赋值(只有单 id 分支会取前置行), `record` 退化成本次写入的裸 payload。于是文档、formula skill 和 showcase 里十条 flow 共同教的过渡条件 `status == "done" && previous.status != "done"`,在批量写上 根本无法求值:hook 条件会拒绝该次写入(#4775/#5037),而 flow trigger **是静默的** —— 要么不触发,要么按一条并不存在的记录触发一次。审计行的缺失是最难被发现的那种 故障。 实现:engine 的批量 update / delete 分支现在把匹配行集**一次性**读出(复用 #3106 已有的那次 `driver.find`,只是把「本对象有 after 型 hook」并入它的取数条件), 然后按匹配行逐行派发 `afterUpdate` / `afterDelete`,每次的上下文都是**单记录形状**: `input.id` 为该行、`previous` 为其前置像、`result` 为其状态。这正是 #2922 为批量 INSERT 定下的规则的复述,也是本修复在消费端一行代码都不需要改的原因 —— hook-wrappers 的 `record`/`previous` 绑定、record-change trigger 的上下文构造、 plugin-audit 的 diff 读的都是这四个字段,在生产端修好即全部随之正确。 - 按行派发对 after 型 hook **统一生效**,刻意不以「条件文本里有没有 previous」为 开关 —— 裁定明确否决了那种让触发次数取决于条件字符串的隐性规则。 - `ctx.result` 按行为**该行**,由已在手的前置像 `row ⊕ payload` 组合而成,因此整批 仍只多一次查询,而非每行一次。批量 DELETE 没有后态,其按行上下文不设 `result`, 消费方回落到 `previous`。 - `onError` 无需新的按行含义 —— 它管的是跑在记录作用域上下文里的 handler,而按行 派发正是终于给了它这样一个上下文:`abort` 失败整个操作,`log` 吞掉该行、批次继续。 - 资源上限以**拒绝**落地:匹配行数超过 10 000 时,对有 after 型 hook 的对象的 predicate 写在调用 driver **之前**被拒(`ERR_BULK_PER_ROW_HOOK_LIMIT`),不写入 任何数据;绝不降级为整批一次派发 —— 那会静默漏掉 N-1 行的 hook。 对 hook 作者是破坏性变更,但方向正是契约声明的方向:接受 predicate 写的对象上的 after 型 hook,现在按匹配行触发而不是按批次触发(通知类 hook 发 N 条,缓存失效类 执行 N 次)。没有 after 型 hook 的对象完全不受影响,也不付任何额外读取成本。写入 自身的契约未变 —— predicate 写仍返回受影响行数,仍只发布一条聚合的 `data.records.updated`(#4639)。 `before*` hook 仍是批次作用域,且这不是待补的缺口:`beforeUpdate` / `beforeDelete` 之所以整批触发一次,是因为它们仍可能改写 payload,而一次 `updateMany` 只有一份 payload。因此 #5037 的 `HookConditionError` 与其 `limitation` 判别字段**保留并 收窄到该次派发**,文案不再承诺一个已经兑现的过期时间,而是点名 phase 就是原因, 并指向对应的 `after*` 事件 —— 在那里同一条件按行求值、与作者所写完全一致。文案现在 也把 record-change flow trigger 列为真实出路:#5037 依据实测拒绝这么写(当时该 trigger 共享同样未绑定的 `previous`),而这个事实已经改变。 文档(`data-modeling/formulas.mdx`)与 `skills/objectstack-formula` §5 同步收口: 两种写入形式共用一种过渡写法,并单独点出 `before*` 这个例外。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
…k-write-per-row-semantics
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 4, 2026 13:48
This was referenced Aug 4, 2026
Closed
新增的 `bulk-write-per-row-context.test.ts` 照抄了同目录既有测试的
`kernel.getService('objectql') as any` 写法,而那两个文件是记在
`scripts/slot-lookup-baseline.json` 里的历史遗留项(按文件豁免、只减不增)。
新文件不在基线内,`no-restricted-syntax`(#4127/#4251)因此在 CI 上报了两处错误。
改为 `getService<IObjectQLEngine>('objectql')` / `getService<IDataEngine>('data')`。
`registerObject` 不在 `EngineSchemaRegistryView`(objectql 槽位公布的 registry
契约,只读 + 包生命周期)上,它是引擎注册表的测试期接缝 —— 因此单独用一个结构化
类型 `TestObjectRegistry` 收窄,而不是把整个服务查询抹成 `any`:槽位查询保持完整
契约检查,只有这一个成员被断言。
os-zhuang
marked this pull request as draft
August 4, 2026 13:54
os-zhuang
marked this pull request as ready for review
August 4, 2026 15:03
This was referenced Aug 4, 2026
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 #5038
Closes #4862
Closes #4800
2026-08-04 维护者在 #4800 / #4862 上的裁定(方案 A),已记入 ADR-0058 的 bulk-write addendum:一次 predicate(
multi: true)写就是 N 次记录变更,所以其上每一条以记录为作用域的声明都按行求值 ——record为该行的实际状态,previous为该行的前置状态。validation 侧自 #3106 起就是这个形状;hookcondition与骑在同一批生命周期 hook 上的 record-change flow trigger 现在与之对齐。原先的问题
multi: true更新走到driver.updateMany,它只解析出受影响行数。生命周期 hook 只触发一次,previous从不赋值(只有单 id 分支会取前置行),record退化成本次写入的裸 payload。于是文档、formula skill 和 showcase 里十条 flow 共同教的过渡条件status == "done" && previous.status != "done",在批量写上根本无法求值:condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 / [rc 止血] 批量写上引用 previous 的 hook 条件:泛化失败换成点名批量限制的专门诊断 + ADR 补遗 #5037 的止血诊断);实现
engine 的批量
update/delete分支把匹配行集一次性读出(复用 #3106 已有的那次driver.find,只是把「本对象有 after 型 hook」并入它的取数条件),然后按匹配行逐行派发afterUpdate/afterDelete,每次的上下文都是单记录形状:input.id为该行、previous为其前置像、result为其状态。这正是 #2922 为批量 INSERT 定下的规则的复述(「单个数组形状的上下文把每一个按单记录形状写成的消费方都弄坏了」),也是本修复在消费端一行代码都不需要改的原因 ——
hook-wrappers的record/previous绑定、record-change trigger 的buildContext、plugin-audit 的 diff 读的都是这四个字段,在生产端修好即全部随之正确。契约优先:缺陷在生产者,就不在消费者加宽容分支。previous」为开关 —— 裁定明确否决了那种让触发次数取决于条件字符串的隐性规则。ctx.result按行为该行,由已在手的前置像row ⊕ payload组合而成,因此整批仍只多一次查询,而非每行一次(issue 的性能护栏「行集读取一次完成,求值批内复用」)。批量 DELETE 没有后态,其按行上下文不设result,消费方回落到previous—— 那正是 delete 语境下record的含义。onError无需新的按行含义 —— 它管的是跑在记录作用域上下文里的 handler,而按行派发正是终于给了它这样一个上下文:abort失败整个操作(与单记录、批量 insert 两条路一致),log吞掉该行、批次继续。MAX_BULK_PER_ROW_HOOK_ROWS(10 000)时,对有 after 型 hook 的对象的 predicate 写在调用 driver 之前被拒(ERR_BULK_PER_ROW_HOOK_LIMIT),不写入任何数据。绝不降级为整批一次派发 —— 那会静默漏掉 N-1 行的 hook,正是本系列要根除的形状。破坏性变更(方向即契约声明的方向)
接受 predicate 写的对象上的 after 型 hook,现在按匹配行触发而不是按批次触发:通知类 hook 发 N 条,缓存失效类执行 N 次。没有 after 型 hook 的对象完全不受影响,也不付任何额外读取成本。写入自身的契约未变 —— predicate 写仍返回受影响行数,仍只发布一条聚合的
data.records.updated(#4639)。派发词要求「核实退役对该诊断覆盖的每一种情形是否都成立;若某种情形并未被按行语义解决,则为该情形保留诊断并在报告中说明」。核实结论:并非全部成立。
before*hook 仍是批次作用域,且这不是待补的缺口:beforeUpdate/beforeDelete之所以整批触发一次,是因为它们仍可能改写 payload,而一次updateMany只有一份 payload —— 没有任何按行的东西可以交给它。这是 phase 的定义,不是版本落后。因此 #5037 的
HookConditionError与其limitation判别字段(bulk_write_previous_unbound/bulk_write_stored_state_unavailable)保留并收窄到该次派发:after*事件作为第一条出路 —— 在那里同一条件按行求值、与作者所写完全一致;previous,record-change flow trigger 在 predicate 批量写上:previous不绑定、record只是裸 payload —— 官方文档与 showcase 正在教的 transition 起始条件在批量写上不成立 #4862),而这个事实已被本 PR 在生产端改变。唯一根据事实反转的refusal,不是把约束放松了。判别机制无需加 phase 判断:
isPredicateBulkWrite找的就是input.id,而按行派发恰好提供它、批次派发恰好没有。验证面
bulk-write-per-row-hooks.test.ts(23 例)—— 触发粒度、按行绑定、「行集只读一次 / 无 hook 不读」、资源上限拒绝且不写入、onError两支、批量 delete 同契约。bulk-write-per-row-context.test.ts(4 例,整栈 kernel + objectql + automation + trigger)—— 逐条对应 record-change flow trigger 在 predicate 批量写上:previous不绑定、record只是裸 payload —— 官方文档与 showcase 正在教的 transition 起始条件在批量写上不成立 #4862 正文的五点实测事实。已验证其非空洞性:把 engine.ts 的改动 stash 掉并重建后,这 4 例全部失败。bulk-write-transition-flows.test.ts(37 例)—— 从allFlows读真实元数据,用 automation engine 相同的变量形状求值。showcase 的 10 条 transition flow 一个字都没改:它们从头到尾都是对的,错的是平台。 因此测试从 flow 自己的条件文本生成两行(真过渡 / 已在目标态),而不是把期望手抄进测试(手抄会在 flow 改目标态时一起漂移还照样通过)。hook-condition-bulk-previous.test.ts/hook-condition-fail-loud.test.ts/hook-condition-previous-scope.test.ts按新契约重写,并显式钉住「不得再承诺已兑现的过期」。声明范围之外的两处改动(请一并复核)
派发词的文件面未列
docs/adr/**与scripts/adr-anchors.json,但两者都是关于本 PR 所改文件的记录性断言,被本改动证伪;留着就是新造一个declared ≠ delivered:docs/adr/0058addendum —— 原文写「implementation tracked by [17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038;the rc window ships a named diagnostic in its place」「when [17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 lands … this rejection has nothing left to report」。后半句现在是错的(before*仍有话要报)。已改为记录落地方式,并把 addendum 自己点名要求 price 的三项(ctx.result形状、onError按行含义、大批量上限)的答案写在提问的地方。scripts/adr-anchors.json中hook-wrappers.ts的 invariant —— 原文「this rejection is a dated stopgap and its message says so」,同样被证伪,已按 before/after 的非对称改写。另:
examples/app-showcase/package.json加了@objectstack/formula为 devDependency —— showcase 的 flow 起始条件用的是裸标识符(status == "done"),只有 automation engine 的作用域能解析,故测试需要真实求值器而非wrapDeclarativeHook。未触碰的禁区
packages/spec/**零改动(HookContext的input/result/previous本就是开放形状,按行契约无需改 schema)、metadata-protocol/src/protocol.ts零改动、content/docs/releases/**未碰(输入是本 PR 的 changeset)、其余在飞面(plugin-email、service-settings、metadata loaders、objectql/src/validation、lint、rest-server/runtime)均未触及。🤖 Generated with Claude Code
https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Generated by Claude Code