Skip to content

Commit e88289b

Browse files
claude[bot]claude
andauthored
docs: correct before* bulk dispatch to the per-row model on two customer pages (#18220)
Fixes #17976 `Clause-②: no` Prose-only realignment onto the already-declared contract: no accept/reject behaviour moves, no public surface widens, no schema or runtime code is touched. ## What was wrong Both pages still taught the batch-dispatch model that ADR-0058 Addendum II (#5574) retired. `before*` hooks on a predicate (`multi: true`) write dispatch **once per matched row**, each on a single-record-shaped context carrying that row's `id` and `previous` — the premise the #16074 ruling was written on and PR #17249 landed into the contract. Verified on `origin/main` before rewriting, not taken from the card: - `packages/objectql/src/engine.ts` — `dispatchPerRowBeforeHooks`, docblock clauses D1/D2: "one dispatch per matched row, on the SINGLE-RECORD shape … `previous` is that row's pre-image". - `packages/spec/src/data/hook.zod.ts` — "Since #5574's engine half it fires them once PER MATCHED ROW, each on a single-record-shaped context carrying that row's `id` and `previous`". - `packages/objectql/src/bulk-write-per-row-hooks.test.ts` — `[#5574 / D1] a bulk write fires before-hooks once per matched row`, `[#5574 / D2] … binds THAT row's previous`. - `docs/adr/0058-expression-and-predicate-surface.md` — Amendment II.3 (#16074), the row-invariant-in-effect rule. ## Five sites, not the two on the card The card's repair asked for the neighbouring paragraphs. The sweep found three more, all in the same retirement: | Site | Was | Now | |:---|:---|:---| | `formulas.mdx` scope table | `previous` "unbound in `before*` hooks, which fire once for the batch" | bound per row in both phases | | `formulas.mdx` "Write it once" | framed per-row dispatch as an `after*` property only | both phases | | `formulas.mdx` ceiling paragraph | 10 000-row refusal attributed to objects "with `after*` hooks" | one ceiling covers both phases, checked before the first dispatch (D6) | | `formulas.mdx` warn Callout | `beforeUpdate`/`beforeDelete` "fire once for the whole batch", `previous` unbound, and a `previous`-reading `before*` condition "fails the write with an error naming the batch" | replaced — see below | | `data-flow.mdx` hook-order prose | "The `after*` events **instead** dispatch once per matched row" | both phases, with the surviving payload asymmetry named | The Callout was the worst of the five: besides the dispatch claim it advertised a **diagnostic that no longer has a producer**. `HookConditionLimitation` was retired at #5574 (`packages/objectql/src/index.ts`: "was exported here until #5574 and is RETIRED"; `hook-wrappers.ts`: "its two `HookConditionLimitation` members had no producer — retired under ADR-0049"). An author was being promised a guard-rail that was removed. It now carries the rule that actually binds today: the payload stays batch-scoped, so only a **row-invariant-in-effect, in-place** rewrite is in contract, backed by the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal — including the two shapes that slip past that guard and stay out of contract. ## Sweep method and hit counts Ran over both pages in full, zero results given a firing control: | Sweep | formulas.mdx | data-flow.mdx | |:---|:---|:---| | `grep -ni "batch"` | 4 hits → 3 in family, 1 benign (`read once for the whole batch`, a true statement about the row read) | 2 hits → 1 in family, 1 benign (`Query Optimizer … batch strategies`) | | `grep -nEi "unbound\|not bound\|once per\|per matched row\|before\*\|beforeUpdate\|beforeDelete\|after\*"` | 9 hits → 5 in family | 4 hits → 1 in family | | `grep -nEi "previous"` | covered by the above | 0 hits outside line 311 — **firing control**: the same pattern returns 4 hits in `skills/` and 1 in `content/docs/releases/`, so the zero is a measurement, not a dead pattern | ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **40** commands from the committed change set. All 40 run, **0 NOT-MEASURED, 0 UNRUN** (`--ran` reconciliation). Four first refused with an unmet prerequisite (three `exit 3`, one `exit 1` whose text is a prerequisite refusal: `client-react` not built) — prerequisites built, then each re-run **whole**, not patched around: ``` 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions 0 pnpm --filter @objectstack/lint run check:doc-security-posture 0 pnpm --filter @objectstack/spec run check:skill-examples 0 pnpm check:docs-transcript-drift ``` Representative verdict lines: ``` ✓ check-docs-transcript-drift: 4 declared transcript value(s) across 402 page(s) under content/docs/ equal what the registry derives today, and no undeclared block quotes one. ✅ 258 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them ✓ check:doc-formula-expressions: 22 record-scoped formula example(s) across 438 files / 1377 TS blocks judged clean by @objectstack/formula. ``` No package is in the diff, so there is no dependency-closure build to owe and no package test suite is affected. Control-character self-scan over both files: clean. ## Changeset: skip Measured, not assumed. Across the **70** packages declaring a `files[]`, neither edited path falls inside any of them; `content/` sits at the repo root, outside every package directory, and the root manifest is `private: true`. Positive control on the same matcher: `packages/spec/dist/index.js` resolves to `@objectstack/spec` via `dist`, and `packages/spec/README.md` via `README.md`. Nothing published moves. ## Acceptance notes **To file — the same retirement on a PUBLISHED, governed surface, outside this card's file face.** Two customer-shipped skill files carry the identical false claim, and both additionally restate the retired rejection-by-name diagnostic: - `skills/objectstack-formula/SKILL.md:308` — the `previous` binding table: "**unbound** — a `before*` hook fires ONCE for the whole batch … so there is no single prior record". - `skills/objectstack-data/references/data-hooks.md:240-248` — the same, plus "Reading `previous` on that dispatch is rejected **by name**, and the rejection points you at the after-type event", which has had no producer since #5574. These contradict the declared contract in `packages/spec/src/data/hook.zod.ts` (class **b**). They are strictly worse than the two pages fixed here — `skills/**` ships to customers *and* is read by AI authoring agents — but the file face for this card is the two `content/docs` pages, so they are reported rather than touched. Dedupe words: `skills before hook batch dispatch`, `objectstack-formula previous unbound`, `data-hooks.md per-row before`, `HookConditionLimitation retired skill`, `skill previous bulk write`. **Noted, not filed — already carried.** #17975 covers the sibling residue in `packages/objectql` source (`engine.ts` docblock, `bulk-write-per-row-hooks.test.ts:570`), a different retired rule (the "not so a rewrite can be aimed" sentence) on a different lane. #17975 remains open; carrier named, nothing to add here. **Noted, not filed.** `formulas.mdx` line 388's "read once for the whole batch" is correct as written (it describes the D7 single row read, not hook dispatch) and was deliberately left alone; it is the benign hit the sweep above accounts for. Carrier: none needed — no defect. ## Dedupe The card's "no existing card found" rested on a semantic search that returned `total: None` on its own control. Re-checked here: `GET /search/issues` is **proxy-blocked in this environment** (HTTP 403, "sessions are bound to their configured repositories"), so that endpoint can never have produced a proven zero. Re-ran via repository-scoped endpoints instead — all **522** open issues and **7** open PRs enumerated and title-matched against the family; the only hits were #17976 itself and #17975 (disposed above). `git ls-remote --heads origin` shows no other branch on either page. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d3d1c9 commit e88289b

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)