From 531dc95687fa73346461b50f83e87324ce99d940 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:55:21 +0000 Subject: [PATCH] =?UTF-8?q?docs(skills):=20data-hooks=20=E7=9A=84=20condit?= =?UTF-8?q?ion=20=E4=B8=80=E8=8A=82=E6=8C=89=20before/after=20=E6=8B=86?= =?UTF-8?q?=E5=BC=80=E9=87=8D=E8=BF=B0=E6=89=B9=E9=87=8F=E5=86=99=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=20(#5900)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- .../objectstack-data/references/data-hooks.md | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 6e7a31fd57..8a5824704e 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -256,10 +256,22 @@ in neither). So: pre-write row, made total over the same declared fields, and it is the same binding a validation predicate reads. - **`previous` is UNBOUND where there is no prior state**, and a reference to an - unbound root makes the whole condition unevaluable. That means: insert events - (`beforeInsert` / `afterInsert`) — write those over `record` alone — and - predicate (`multi: true`) bulk updates, where one write matches N rows and the - hook fires once, so there is no single prior record to bind. + unbound root makes the whole condition unevaluable. Two cases: insert events + (`beforeInsert` / `afterInsert`) — write those over `record` alone — and the + **`before*` dispatch of a predicate (`multi: true`) write**, which fires **once + for the whole batch**: a `before*` hook may still rewrite the shared payload and + one batch carries one payload, so there is no single prior record to bind. + (`record` is that bare payload there too, so a *declared* field this write does + not set is unevaluable as well.) Reading `previous` on that dispatch is rejected + **by name**, and the rejection points you at the after-type event. +- **`after*` hooks fire PER ROW, so a bulk write needs no special condition + (#5038).** A predicate (`multi: true`) update/delete dispatches its `after*` + hooks **once per matched row**, each on a single-record-shaped context — + `previous` is that row's pre-image, `record` is that row's real state (not the + bare payload), and `input.id` names the row. A transition condition therefore + **is** available on `afterUpdate` / `afterDelete`: write it once and it means + the same thing whether the write carried an id or a predicate, with no + bulk-aware branch of its own. - **Guard optional values with `!= null`, never with `has(...)`.** A declared field holding `null` is *present*, so `has(record.spent)` is uniformly true and `has(record.spent) && record.spent > record.budget` still faults on @@ -270,16 +282,18 @@ in neither). So: (`record.stauts`), a `previous` reference on an insert, or a comparison CEL has no overload for does **not** degrade to "the hook did not fire" — it **fails the write**. Until protocol 17 the gate emitted a `logger.warn` and returned `false`, -which is why the two bullets above are load-bearing rather than stylistic: a -`before*` guard swallowed into `false` silently let writes through, and an audit -hook swallowed into `false` silently dropped records. Those are opposite -failures, so "the condition said no" and "the platform could not work out what -the condition says" are now different outcomes and the second one is loud. +which is why the unbound-`previous` and `has(...)` bullets above are load-bearing +rather than stylistic: a `before*` guard swallowed into `false` silently let +writes through, and an audit hook swallowed into `false` silently dropped +records. Those are opposite failures, so "the condition said no" and "the +platform could not work out what the condition says" are now different outcomes +and the second one is loud. Practical consequence when authoring: spell keys against the object's **declared** -fields, and never reach for `previous` in a hook that can fire on insert or on a -`multi: true` write — that mistake used to cost you a hook that quietly never -ran, and now costs you every write the hook is attached to. +fields, and put a condition that reads `previous` on an **after-type** event — +never on an insert event, and never on a `before*` hook that can fire on a +`multi: true` write. That mistake used to cost you a hook that quietly never ran, +and now costs you every write the hook is attached to. #### `onError` — Error Handling