Skip to content

Commit 76af143

Browse files
committed
docs: correct before* bulk dispatch to the per-row model on two customer pages
Both pages still described the batch-dispatch model ADR-0058 Addendum II retired: `before*` hooks dispatch once per matched row and `previous` is bound per row, the premise the #16074 ruling was written on. Five sites, not the two quoted on the card — the neighbouring paragraphs carried the same retirement: - formulas.mdx scope table: `previous` was documented as unbound in `before*`. - formulas.mdx "Write it once": described the per-row model as an `after*` property only. - formulas.mdx ceiling paragraph: attributed the 10 000-row refusal to objects with `after*` hooks; one ceiling covers both phases and is checked before the first dispatch. - formulas.mdx warn Callout: stated `beforeUpdate`/`beforeDelete` fire once for the whole batch, that `previous` is unbound there, and that a `previous`-reading `before*` condition fails the write with a diagnostic naming the batch — that diagnosis is retired and has had no producer since the per-row dispatch landed. Replaced with the rule that is now load-bearing: the payload stays batch-scoped, so only a row-invariant-in-effect, in-place rewrite is in contract, backed by the engine's key-divergence refusal. - data-flow.mdx hook-order prose: "The `after*` events instead dispatch once per matched row" implied `before*` does not. Docs-only; `content/` is outside every package's `files[]`, so nothing published moves. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d3d1c9 commit 76af143

2 files changed

Lines changed: 34 additions & 19 deletions

File tree

‎content/docs/api/data-flow.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ flowchart TD
308308
end
309309
```
310310

311-
The lifecycle events are defined by the `HookEvent` enum (8 events). Reads fire `beforeFind`/`afterFind` — for **both** `find` and `findOne`, so one subscription covers every read shape. Bulk writes (`multi: true`) fire the **same** `beforeUpdate`/`beforeDelete`/`afterUpdate`/`afterDelete` events as single-id writes. A bulk write hands hooks **no** row-scoping predicate: it lives on the engine-internal `OperationContext.ast`, so the RLS / sharing filters composed onto it bind the driver call itself, where no handler can widen them — scope a batch through `options.where` at the caller. The `after*` events instead dispatch **once per matched row**, each on a single-record-shaped context whose `input.id` names that row. There are deliberately no per-method (`findOne`/`count`/`aggregate`) or `*Many` events: read authorization and row filtering are RLS/permission-rule concerns, and field masking is field-level metadata.
311+
The lifecycle events are defined by the `HookEvent` enum (8 events). Reads fire `beforeFind`/`afterFind` — for **both** `find` and `findOne`, so one subscription covers every read shape. Bulk writes (`multi: true`) fire the **same** `beforeUpdate`/`beforeDelete`/`afterUpdate`/`afterDelete` events as single-id writes. A bulk write hands hooks **no** row-scoping predicate: it lives on the engine-internal `OperationContext.ast`, so the RLS / sharing filters composed onto it bind the driver call itself, where no handler can widen them — scope a batch through `options.where` at the caller. Both phases dispatch **once per matched row**, each on a single-record-shaped context whose `input.id` names that row and whose `previous` is that row's own pre-image — `after*` since #5038, `before*` since #5574 (ADR-0058 Addendum II). Zero matched rows is zero dispatches. One difference survives: the `after*` result is copied per row, while every per-row `before*` context carries **the** one batch payload, so a rewrite made on any row's dispatch applies to all of them — see [Transitions on bulk writes](/docs/data-modeling/formulas#transitions-on-bulk-writes). There are deliberately no per-method (`findOne`/`count`/`aggregate`) or `*Many` events: read authorization and row filtering are RLS/permission-rule concerns, and field masking is field-level metadata.
312312

313313
| Hook | Phase | Can Modify? | Can Abort? |
314314
|:---|:---|:---|:---|

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ Keep them pure, dependency-free, and AI-readable.
202202
| Binding | Source | Available in |
203203
|:---|:---|:---|
204204
| `record` | the row being evaluated | formulas, validation, sharing, visibility |
205-
| `previous` | row before update — on a `multi: true` write, that row's own pre-write state in `after*` hooks / record-change triggers (per row); unbound in `before*` hooks, which fire once for the batch | hooks, validation on update |
205+
| `previous` | row before update — on a `multi: true` write, that row's own pre-write state, in `before*` and `after*` hooks alike and in record-change triggers: since ADR-0058 Addendum II both phases dispatch once per matched row | hooks, validation on update |
206206
| `input` | hook payload | hooks |
207207
| `current_user` | the authenticated subject — the canonical binding (ADR-0068). `user`, `ctx.user` and `os.user` are aliases of the **same** object | predicates with identity |
208208
| `os.user` | alias of `current_user` | seed, predicates with identity |
@@ -371,12 +371,13 @@ The condition above is a **transition** — `record.status == 'escalated'` alone
371371
would be true on every update of an already-escalated case, so "just became" is
372372
only expressible by comparing against `previous`.
373373

374-
Write it once. A predicate (`multi: true`) write is N record changes, so
375-
`after*` hooks — and the record-change flow triggers that ride them — are
376-
evaluated and fired **once per matched row**, with `previous` bound to that
377-
row's own pre-write state and `record` holding that row's real state rather than
378-
the write's payload. The same condition therefore means the same thing whether
379-
the write targets one id or matches a thousand rows:
374+
Write it once. A predicate (`multi: true`) write is N record changes, so hooks
375+
in **both** phases — `before*` and `after*` alike, and the record-change flow
376+
triggers that ride the latter — are evaluated and fired **once per matched
377+
row**, with `previous` bound to that row's own pre-write state and `record`
378+
holding that row's own state (stored ⊕ payload) rather than the bare payload.
379+
The same condition therefore means the same thing whether the write targets one
380+
id or matches a thousand rows:
380381

381382
```ts
382383
await data.update('case', { status: 'escalated' }, { multi: true, where: { severity: 'high' } });
@@ -385,19 +386,33 @@ await data.update('case', { status: 'escalated' }, { multi: true, where: { sever
385386
```
386387

387388
The matched rows are read once for the whole batch and reused for every per-row
388-
evaluation, so this costs one extra query per write, not one per row. Above
389-
~10 000 matched rows a predicate write against an object with `after*` hooks is
390-
**refused** rather than fanned out — paginate the write. The refusal is loud;
391-
the platform never silently downgrades it to a single hook call.
389+
evaluation in both phases, so this costs one extra query per write, not one per
390+
row. Above 10 000 matched rows a predicate write against an object carrying
391+
per-row hooks in **either** phase is **refused** rather than fanned out — one
392+
ceiling covers `before*` and `after*` together, and it is checked before the
393+
first dispatch, so nothing is written and no handler runs. Paginate the write.
394+
The refusal is loud (`ERR_BULK_PER_ROW_HOOK_LIMIT`, naming the event, the
395+
matched count and the limit); the platform never silently downgrades it to a
396+
single hook call.
392397

393398
<Callout type="warn">
394-
`before*` hooks are the exception, by nature rather than by omission.
395-
`beforeUpdate` / `beforeDelete` fire **once for the whole batch** — they may
396-
still rewrite the payload, and a bulk write carries exactly one payload — so
397-
`previous` is unbound there and a `before*` condition that reads it fails the
398-
write with an error naming the batch and pointing at the matching `after*`
399-
event. Keep transition conditions on `after*`; keep `before*` conditions to the
400-
fields the incoming payload actually sets.
399+
`before*` hooks dispatch per row too, but what they WRITE is still batch-scoped.
400+
A transition *condition* is safe in either phase — `previous` is that row's own
401+
pre-image in `beforeUpdate` / `beforeDelete` as much as in `after*`. The payload
402+
is the asymmetry: `driver.updateMany` takes one SET clause for N rows, so every
403+
per-row `before*` context carries **the** one payload, and a rewrite made on any
404+
row's dispatch lands on every matched row. What the contract admits is therefore
405+
a **row-invariant-in-effect** rewrite — you may DECIDE per row, but the key set
406+
you write must be the same for every row and must be assigned **in place**
407+
(`ctx.input.data.customized = true`). The engine enforces it: it records the
408+
keys each row's chain assigned and refuses the whole batch before any write when
409+
two rows disagree (`MULTI_UPDATE_HOOK_KEY_DIVERGENCE`, HTTP 400, nothing
410+
written). Two shapes slip past that guard and stay out of contract anyway —
411+
writing the same key with a per-row **value** (key sets match, so the last
412+
dispatch's value silently wins for all rows), and **replacing** `ctx.input.data`
413+
instead of mutating it (the recording sees nothing, so the batch is judged by
414+
nothing at all). Need a genuinely per-row write? Write those rows by id, or go
415+
through `ctx.api` from inside the handler.
401416
</Callout>
402417

403418
---

0 commit comments

Comments
 (0)