Skip to content

Commit 2392e5d

Browse files
hotlongclaude
andauthored
docs(skills): data-hooks 的 condition 一节按 before/after 拆开重述批量写绑定 (#5900) (#5989)
`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 条全绿。 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a2d9351 commit 2392e5d

1 file changed

Lines changed: 26 additions & 12 deletions

File tree

skills/objectstack-data/references/data-hooks.md

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -256,10 +256,22 @@ in neither). So:
256256
pre-write row, made total over the same declared fields, and it is the same
257257
binding a validation predicate reads.
258258
- **`previous` is UNBOUND where there is no prior state**, and a reference to an
259-
unbound root makes the whole condition unevaluable. That means: insert events
260-
(`beforeInsert` / `afterInsert`) — write those over `record` alone — and
261-
predicate (`multi: true`) bulk updates, where one write matches N rows and the
262-
hook fires once, so there is no single prior record to bind.
259+
unbound root makes the whole condition unevaluable. Two cases: insert events
260+
(`beforeInsert` / `afterInsert`) — write those over `record` alone — and the
261+
**`before*` dispatch of a predicate (`multi: true`) write**, which fires **once
262+
for the whole batch**: a `before*` hook may still rewrite the shared payload and
263+
one batch carries one payload, so there is no single prior record to bind.
264+
(`record` is that bare payload there too, so a *declared* field this write does
265+
not set is unevaluable as well.) Reading `previous` on that dispatch is rejected
266+
**by name**, and the rejection points you at the after-type event.
267+
- **`after*` hooks fire PER ROW, so a bulk write needs no special condition
268+
(#5038).** A predicate (`multi: true`) update/delete dispatches its `after*`
269+
hooks **once per matched row**, each on a single-record-shaped context —
270+
`previous` is that row's pre-image, `record` is that row's real state (not the
271+
bare payload), and `input.id` names the row. A transition condition therefore
272+
**is** available on `afterUpdate` / `afterDelete`: write it once and it means
273+
the same thing whether the write carried an id or a predicate, with no
274+
bulk-aware branch of its own.
263275
- **Guard optional values with `!= null`, never with `has(...)`.** A declared
264276
field holding `null` is *present*, so `has(record.spent)` is uniformly true and
265277
`has(record.spent) && record.spent > record.budget` still faults on
@@ -270,16 +282,18 @@ in neither). So:
270282
(`record.stauts`), a `previous` reference on an insert, or a comparison CEL has
271283
no overload for does **not** degrade to "the hook did not fire" — it **fails the
272284
write**. Until protocol 17 the gate emitted a `logger.warn` and returned `false`,
273-
which is why the two bullets above are load-bearing rather than stylistic: a
274-
`before*` guard swallowed into `false` silently let writes through, and an audit
275-
hook swallowed into `false` silently dropped records. Those are opposite
276-
failures, so "the condition said no" and "the platform could not work out what
277-
the condition says" are now different outcomes and the second one is loud.
285+
which is why the unbound-`previous` and `has(...)` bullets above are load-bearing
286+
rather than stylistic: a `before*` guard swallowed into `false` silently let
287+
writes through, and an audit hook swallowed into `false` silently dropped
288+
records. Those are opposite failures, so "the condition said no" and "the
289+
platform could not work out what the condition says" are now different outcomes
290+
and the second one is loud.
278291

279292
Practical consequence when authoring: spell keys against the object's **declared**
280-
fields, and never reach for `previous` in a hook that can fire on insert or on a
281-
`multi: true` write — that mistake used to cost you a hook that quietly never
282-
ran, and now costs you every write the hook is attached to.
293+
fields, and put a condition that reads `previous` on an **after-type** event —
294+
never on an insert event, and never on a `before*` hook that can fire on a
295+
`multi: true` write. That mistake used to cost you a hook that quietly never ran,
296+
and now costs you every write the hook is attached to.
283297

284298
#### `onError` — Error Handling
285299

0 commit comments

Comments
 (0)