|
| 1 | +--- |
| 2 | +"@objectstack/objectql": minor |
| 3 | +"@objectstack/spec": patch |
| 4 | +--- |
| 5 | + |
| 6 | +feat(objectql): dispatch `before*` hooks per matched row on a predicate bulk write (#5574, #5846) |
| 7 | + |
| 8 | +A `multi: true` update or delete now dispatches `beforeUpdate` / `beforeDelete` |
| 9 | +**once per matched row**, on a single-record-shaped context carrying that row's |
| 10 | +`id` and `previous` — the same move #5038 made for the `after*` phase, held to |
| 11 | +the same yardstick. ADR-0058 Addendum II (maintainer ruling B, 2026-08-06) is |
| 12 | +the contract; `packages/spec/src/data/bulk-write-hook-conformance.ts` states it |
| 13 | +as clauses D1–D7, and its `delivered` flags flip with this change. |
| 14 | + |
| 15 | +**The harm this fixes.** `ctx.previous` was never bound in the before phase of a |
| 16 | +predicate write, so every guard written the way guards are written — |
| 17 | +`if (ctx.previous?.locked) throw` — passed **silently** on every batch. The |
| 18 | +failure direction is fail-OPEN and the optional chaining that makes it silent is |
| 19 | +exactly what an AI writes. One measured deployment had all 15 of its guard hooks |
| 20 | +bypassed by a single batch edit, including writing `null` into a `readonly: true` |
| 21 | +field that the single-id path refuses. |
| 22 | + |
| 23 | +**Two visible behaviour changes, both loud.** |
| 24 | + |
| 25 | +- **Guards now fire per row on predicate writes.** A `beforeUpdate` / |
| 26 | + `beforeDelete` hook on an object targeted by a `multi: true` write runs N times |
| 27 | + instead of once, each time with that row's `previous` bound. Zero matched rows |
| 28 | + is zero dispatches. A hook that throws refuses the whole batch before anything |
| 29 | + is written. The payload stays **batch-scoped** (D3): every per-row context |
| 30 | + carries the one payload, so a rewrite applies to every matched row whichever |
| 31 | + row's dispatch made it, rewrites accumulate in dispatch order, and no predicate |
| 32 | + write is ever split into N single-row writes — one `updateMany`, one affected |
| 33 | + count (#4639), one aggregate event. A rewrite *conditioned* on the row is |
| 34 | + therefore out of contract: it widens to the whole batch rather than scoping |
| 35 | + itself. Per-row `previous` is supplied so a guard can REFUSE, not so a rewrite |
| 36 | + can be aimed. |
| 37 | +- **The `input.id` reroute lever is retired and now refuses.** The dispatch |
| 38 | + ladder is resolved **before** the before phase — it has to be, since per-row |
| 39 | + contexts are built from the matched row set — so the id slot can no longer |
| 40 | + steer the write. Rather than ignore an assignment (a silent no-op) or honour |
| 41 | + it blindly, the write is rejected with `HookTargetRebindError` |
| 42 | + (`ERR_HOOK_TARGET_REBIND`), whose message names the retired capability and the |
| 43 | + three supported replacements. Recorded as ADR-0058 Amendment II.1. Precisely: |
| 44 | + |
| 45 | + | | CLEARED id | REBOUND to another id | |
| 46 | + |---|---|---| |
| 47 | + | `update()` by-id | refused | refused | |
| 48 | + | `delete()` by-id | refused | **honoured, unchanged** (#5272's re-read) | |
| 49 | + | either, per-row | refused (D4) | refused (D4) | |
| 50 | + |
| 51 | + Clearing is uniform because it worked by falling through to the predicate |
| 52 | + branch, and that branch is now chosen before any handler runs. Rebinding is |
| 53 | + not uniform, deliberately: the case against honouring it is that the write |
| 54 | + lands on a row whose pre-image and rules were never evaluated, and on |
| 55 | + `delete()` that is simply not true — #5272 already re-resolves the new target |
| 56 | + before `afterDelete` or the summary recompute sees it. `update()` has no such |
| 57 | + mechanism and building one would be the "silently pick re-resolution instead" |
| 58 | + the ruling forbids. Retiring the delete-side repoint is its own question, |
| 59 | + filed as #6752 rather than ridden in on an ordering change. |
| 60 | + |
| 61 | +**Also in this change.** |
| 62 | + |
| 63 | +- **One read, reused (D7).** The matched row set is read ONCE per predicate |
| 64 | + write, with the write's own composed AST, and serves per-row validation |
| 65 | + (#3106), the `readonlyWhen` strip (#3042) and both per-row dispatches. |
| 66 | +- **One ceiling, both phases (D6).** `MAX_BULK_PER_ROW_HOOK_ROWS` (10 000) now |
| 67 | + governs `before*` as well as `after*`, checked **before the first dispatch**, so |
| 68 | + an over-ceiling batch runs zero handlers and writes nothing — a refusal, never |
| 69 | + a downgrade to one dispatch. The engine's open-coded ceiling and refusal |
| 70 | + message are replaced by the spec module's `resolveBulkPerRowHookBudget`, so the |
| 71 | + number and the wording have one definition again. |
| 72 | +- **`update()` binds `previous` before the before phase (#5846 (a)).** The by-id |
| 73 | + path reads its prior row ahead of the dispatch, matching `delete()`'s shape |
| 74 | + since #5272, so both phases share one read. objectql's |
| 75 | + `sys_fetch_previous_update` builtin is **retired**: it existed to bind |
| 76 | + `previous` for the before phase behind `if (input.id && !ctx.previous)`, and |
| 77 | + that guard is now permanently false. A by-id update on a kernel used to read |
| 78 | + the same row three times; this removes one and makes the engine's read the |
| 79 | + single producer. |
| 80 | +- **`HookConditionLimitation` is retired** (ADR-0049 enforce-or-remove), with |
| 81 | + `isPredicateBulkWrite` and the `predicateBulkWrite` flag. Both members |
| 82 | + (`bulk_write_previous_unbound`, `bulk_write_stored_state_unavailable`) |
| 83 | + described a batch-scoped `before*` dispatch that no longer exists, leaving them |
| 84 | + with neither producer nor reachable consumer. A `previous`-reading `before*` |
| 85 | + condition on a bulk write now **evaluates as authored**, per row, instead of |
| 86 | + rejecting the batch. `HookConditionError` itself is unchanged — an unevaluable |
| 87 | + condition still aborts the operation (#4775). |
| 88 | + |
| 89 | +**Migrating.** A handler that cleared `ctx.input.id` — or rebound it on an |
| 90 | +`update()` — must instead write through `ctx.api` / `ctx.ql` for the row it |
| 91 | +means, have the caller pass `{ multi: true, where: … }`, or throw to refuse the |
| 92 | +write. A `beforeDelete` handler that repoints the target is unaffected. A `beforeUpdate` hook |
| 93 | +with side effects on an object that receives bulk writes should expect to run |
| 94 | +per row; a batch-wide effect belongs in a payload rewrite, which is still |
| 95 | +batch-scoped. |
0 commit comments