Skip to content

feat(objectql)!: 批量写按行语义 —— after 型 hook 按行触发,record-change trigger 按行绑定 previous/record (#5038) - #5270

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5038-bulk-write-per-row-semantics
Aug 4, 2026
Merged

feat(objectql)!: 批量写按行语义 —— after 型 hook 按行触发,record-change trigger 按行绑定 previous/record (#5038)#5270
os-zhuang merged 3 commits into
mainfrom
claude/issue-5038-bulk-write-per-row-semantics

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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 起就是这个形状;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",在批量写上根本无法求值:

实现

engine 的批量 update / delete 分支把匹配行集一次性读出(复用 #3106 已有的那次 driver.find,只是把「本对象有 after 型 hook」并入它的取数条件),然后按匹配行逐行派发 afterUpdate / afterDelete,每次的上下文都是单记录形状:input.id 为该行、previous 为其前置像、result 为其状态。

这正是 #2922 为批量 INSERT 定下的规则的复述(「单个数组形状的上下文把每一个按单记录形状写成的消费方都弄坏了」),也是本修复在消费端一行代码都不需要改的原因 —— hook-wrappersrecord/previous 绑定、record-change trigger 的 buildContext、plugin-audit 的 diff 读的都是这四个字段,在生产端修好即全部随之正确。契约优先:缺陷在生产者,就不在消费者加宽容分支。

  • 按行派发对 after 型 hook 统一生效,刻意不以「条件文本里有没有 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 的定义,不是版本落后。

因此 #5037HookConditionError 与其 limitation 判别字段(bulk_write_previous_unbound / bulk_write_stored_state_unavailable)保留并收窄到该次派发:

判别机制无需加 phase 判断:isPredicateBulkWrite 找的就是 input.id,而按行派发恰好提供它、批次派发恰好没有。

验证面

  • objectql:新增 bulk-write-per-row-hooks.test.ts(23 例)—— 触发粒度、按行绑定、「行集只读一次 / 无 hook 不读」、资源上限拒绝且不写入、onError 两支、批量 delete 同契约。
  • trigger-record-change:新增 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 例全部失败。
  • app-showcase:新增 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 按新契约重写,并显式钉住「不得再承诺已兑现的过期」。
objectql               116 files / 1848 passed
service-automation      55 files /  665 passed
trigger-record-change    5 files /   55 passed
example-showcase        12 files /  120 passed
plugin-audit / plugin-sharing / plugin-auth   51 files passed
turbo typecheck + test (4 包,merge origin/main 之后重跑)  65 tasks successful
check-adr-anchors / check-doc-authoring / check-doc-formula-expressions  OK

声明范围之外的两处改动(请一并复核)

派发词的文件面未列 docs/adr/**scripts/adr-anchors.json,但两者都是关于本 PR 所改文件的记录性断言,被本改动证伪;留着就是新造一个 declared ≠ delivered:

  1. docs/adr/0058 addendum —— 原文写「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 按行含义、大批量上限)的答案写在提问的地方。
  2. scripts/adr-anchors.jsonhook-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/** 零改动(HookContextinput/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

os-zhuang and others added 2 commits August 4, 2026 13:36
…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
@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 1:51pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling size/xl 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/objectql.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

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.

新增的 `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
os-zhuang marked this pull request as draft August 4, 2026 13:54
@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 15:03
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 3905c00 Aug 4, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5038-bulk-write-per-row-semantics branch August 4, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment