Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .changeset/bulk-write-per-row-hook-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
"@objectstack/objectql": minor
"@objectstack/example-showcase": patch
---

feat(objectql)!: a predicate bulk write evaluates and fires after-hooks PER ROW (#5038)

The 2026-08-04 maintainer ruling on #4800 / #4862, recorded as ADR-0058's
bulk-write addendum: **a bulk write is N record changes**, so every record-scoped
declaration on it is evaluated per row — `record` = that row's state, `previous` =
that row's pre-write state. Validation predicates have worked this way since
#3106; hook `condition`s and the record-change flow triggers riding the same
lifecycle hooks now join them.

**What was broken.** A `multi: true` update reaches `driver.updateMany`, which
resolves an affected COUNT. The lifecycle hook fired **once**, `previous` was
never assigned (only the single-id branch fetched a prior row), and `record`
degraded to the write's bare payload. So the transition condition the docs, the
formula skill and ten showcase flows all teach —
`status == "done" && previous.status != "done"` — could not be evaluated on a
bulk write. Hook conditions rejected the write (#4775/#5037); record-change flow
triggers were **silent**, firing zero times or once for a record that did not
exist. A missing audit row is the one failure nobody goes looking for.

**What changed.** The engine's bulk `update` / `delete` branches now read the
matched row set **once** — the same `driver.find` #3106 already issues, with
"this object has after-hooks" added to its demand test — and dispatch
`afterUpdate` / `afterDelete` once per matched row, each on a context with the
**single-record shape**: `input.id` = the row, `previous` = its pre-image,
`result` = its state. That is #2922's batch-INSERT ruling restated, and it is why
this fix has no code in the consumers: `hook-wrappers`' `record`/`previous`
bindings, the record-change trigger's context builder and plugin-audit's diff all
read those same fields and became correct at the producer.

- **Per-row dispatch is uniform across after-hooks.** It is deliberately NOT
keyed on whether a condition mentions `previous` — the ruling rejected that as
a hidden rule that would make a hook's firing count depend on its condition
text.
- **`ctx.result` per row is the ROW**, composed as `row ⊕ payload` from the
pre-image already in hand, so the batch still costs one extra query, not one
per row. A bulk DELETE has no post-state: its per-row context sets no `result`,
and consumers fall back to `previous`.
- **`onError` needed no new meaning** — it governs a handler on a record-scoped
context, which is now what it always gets: `abort` fails the operation, `log`
swallows that row and the batch continues.
- **A ceiling, enforced as a refusal.** Past 10 000 matched rows a predicate
write against an object with after-hooks is rejected *before* the driver call
(`ERR_BULK_PER_ROW_HOOK_LIMIT`), so nothing is written. It is never downgraded
to one dispatch for the batch — that would skip the hook for N-1 rows silently.

**Breaking for hook authors, in the direction the contract declares.** An
after-hook on an object that takes predicate writes now runs once per matched row
instead of once per batch: a notification hook sends N messages, a
cache-invalidation hook runs N times. Objects with no after-hooks are untouched
and pay for no extra read. The write's own contract is unchanged — a predicate
write still resolves the affected count and still publishes ONE aggregate
`data.records.updated` (#4639).

**`before*` hooks stay batch-scoped, and that is not a gap.** `beforeUpdate` /
`beforeDelete` fire once for the whole batch because they may still rewrite the
payload, and one `updateMany` carries one payload. #5037's `HookConditionError`
and its `limitation` discriminator therefore **survive, rescoped to that
dispatch** — with a message that no longer promises an expiry that has already
happened, names the phase as the reason, and points at the matching `after*`
event where the same condition evaluates per row as authored. It also now names a
record-change flow trigger as a real route: #5037 refused to, on measured
evidence that the trigger shared the same unbound `previous`; that fact changed.

Docs (`data-modeling/formulas.mdx`) and `skills/objectstack-formula` §5 are
updated to teach one transition shape for both write forms, with the `before*`
exception called out.
37 changes: 36 additions & 1 deletion content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Keep them pure, dependency-free, and AI-readable.
| Binding | Source | Available in |
|:---|:---|:---|
| `record` | the row being evaluated | formulas, validation, sharing, visibility |
| `previous` | row before update | hooks, validation on update |
| `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 |
| `input` | hook payload | hooks |
| `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 |
| `os.user` | alias of `current_user` | seed, predicates with identity |
Expand Down Expand Up @@ -349,6 +349,41 @@ JS `body` is a different surface: it is wrapped as `new AsyncFunction('ctx', sou
so inside it the record is `ctx.input` (there is no bare `record`), and every
`ctx` API it touches must be covered by a declared capability.

#### Transitions on bulk writes

The condition above is a **transition** — `record.status == 'escalated'` alone
would be true on every update of an already-escalated case, so "just became" is
only expressible by comparing against `previous`.

Write it once. A predicate (`multi: true`) write is N record changes, so
`after*` hooks — and the record-change flow triggers that ride them — are
evaluated and fired **once per matched row**, with `previous` bound to that
row's own pre-write state and `record` holding that row's real state rather than
the write's payload. The same condition therefore means the same thing whether
the write targets one id or matches a thousand rows:

```ts
await data.update('case', { status: 'escalated' }, { multi: true, where: { severity: 'high' } });
// → `notify_on_escalation` fires once per case that ACTUALLY transitioned;
// cases already escalated do not fire.
```

The matched rows are read once for the whole batch and reused for every per-row
evaluation, so this costs one extra query per write, not one per row. Above
~10 000 matched rows a predicate write against an object with `after*` hooks is
**refused** rather than fanned out — paginate the write. The refusal is loud;
the platform never silently downgrades it to a single hook call.

<Callout type="warn">
`before*` hooks are the exception, by nature rather than by omission.
`beforeUpdate` / `beforeDelete` fire **once for the whole batch** — they may
still rewrite the payload, and a bulk write carries exactly one payload — so
`previous` is unbound there and a `before*` condition that reads it fails the
write with an error naming the batch and pointing at the matching `after*`
event. Keep transition conditions on `after*`; keep `before*` conditions to the
fields the incoming payload actually sets.
</Callout>

---

## Formula patterns
Expand Down
94 changes: 63 additions & 31 deletions docs/adr/0058-expression-and-predicate-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@

---

> **Addendum (2026-08, #4800 / #4862 / #5037) — BULK-WRITE SCOPE: on a predicate
> (`multi: true`) write, after-hooks and record-change flow triggers evaluate and
> fire PER ROW.** _Contract recorded here; implementation tracked by #5038; the
> rc window ships a named diagnostic in its place._
> **Addendum (2026-08, #4800 / #4862 / #5037 / #5038) — BULK-WRITE SCOPE: on a
> predicate (`multi: true`) write, after-hooks and record-change flow triggers
> evaluate and fire PER ROW.** _Contract recorded here; **implemented by #5038**
> (see "How it landed" below). `before*` hooks are outside it, by nature._
>
> The addendum above settles what happens when a write-path predicate cannot be
> evaluated. It does not settle **what the evaluation is even over** when one
Expand All @@ -76,37 +76,69 @@
> transition condition (`previous.done != true && record.done == true`) and it
> means the same thing whether the write carries an id or a predicate.
>
> **What the engine does today, measured (#4862).** A `multi: true` update
> **What the engine did before #5038, measured (#4862).** A `multi: true` update
> reaches `driver.updateMany`, which resolves an affected COUNT; the lifecycle
> hook fires **once**, `hookContext.previous` is never assigned (only the
> single-id branch fetches a prior row), and `record` degrades to the write's
> bare payload. So a condition naming `previous` is unevaluable and — since the
> #4775 row above — **rejects the write**.
> hook fired **once**, `hookContext.previous` was never assigned (only the
> single-id branch fetched a prior row), and `record` degraded to the write's
> bare payload. So a condition naming `previous` was unevaluable and — since the
> #4775 row above — **rejected the write**. The rc window (#5037) kept the
> rejection (fail loud takes no exception; logging-and-skipping was considered
> and refused on #4800, because a missing audit row is the one failure nobody
> goes looking for) but made it name the limitation instead of the author.
>
> **The rc-window stopgap (#5037).** The rejection stands: fail loud takes no
> exception here (the alternatives — logging an error and skipping the hook, or
> skipping it silently — were considered and refused on #4800, because a missing
> audit row is the one failure nobody goes looking for). What changed is that it
> must no longer read as an author's mistake. `HookConditionError` carries a
> machine-readable `limitation` (`bulk_write_previous_unbound`,
> `bulk_write_stored_state_unavailable`) and a message that names the batch, says
> the CURRENT VERSION is what cannot bind the row's prior state, points at the
> contract above, and gives the route that works today (target the write at one
> record). It is a stopgap with an expiry: when #5038 lands per-row evaluation
> the condition evaluates as authored and this rejection has nothing left to
> report.
> **How it landed (#5038).** The engine's bulk branch reads the matched row set
> **once** — the same `driver.find` #3106 already issues for per-row validation,
> now also demanded when the object has after-hooks — and then dispatches
> `afterUpdate` / `afterDelete` **once per matched row**, on a context with the
> single-record shape: `input.id` = the row, `previous` = its pre-image,
> `result` = its state. That shape is #2922's ruling for batch INSERT restated
> (a single array-shaped context "broke every consumer built for the single
> shape"), and it is why the fix has no code in the consumers: `hook-wrappers`'
> `record`/`previous` bindings, the record-change trigger's `buildContext` and
> plugin-audit's diff all read those same fields and became correct at the
> producer. The write's own contract is untouched — a predicate write still
> resolves an affected count (#4639), and still publishes ONE aggregate
> `data.records.updated`, because per-row dispatch changed hook granularity, not
> what the write is.
>
> **Deliberately not written into that message:** "use a record-change flow
> trigger instead". Verified, not assumed — that trigger subscribes to these very
> lifecycle hooks, so on a bulk write it fires once with the same unbound
> `previous` (#4862). Naming it would have made the error that fixes a
> `declared ≠ delivered` into another one.
> **The consequences, priced as this addendum required.**
>
> **Consequences to price when #5038 implements this**: an after-hook that fires
> once per batch today fires N times (notification hooks send N messages,
> cache-invalidation hooks run N times), so the shape of `ctx.result` per row,
> the per-row meaning of `onError`, and a ceiling on very large matched sets are
> part of that implementation, not free riders on it.
> - **`ctx.result` per row is the ROW, not the batch** — composed as
> `row ⊕ payload` from the pre-image already in hand, so the guardrail above
> ("read the row set once") stays literal: no second full-set query after the
> write. A bulk DELETE has no post-state, so its per-row context sets no
> `result` and consumers fall back to `previous`, which is what `record` means
> for a delete.
> - **`onError` needed no per-row meaning.** It governs a HANDLER on a
> record-scoped context, and per-row dispatch is what finally gives it one:
> `abort` propagates and fails the operation (as on the single-record and
> batch-insert paths), `log` swallows that row and the batch continues.
> - **The ceiling is a refusal, not a downgrade.** Past
> `MAX_BULK_PER_ROW_HOOK_ROWS` (10 000) a predicate write against an object
> with after-hooks is rejected BEFORE the driver call, so nothing is written.
> Falling back to one dispatch for the batch would skip the hook for N-1 rows
> silently — the failure shape this whole family exists to abolish.
>
> **`before*` hooks are NOT per row, and that is not a version gap.** A
> `beforeUpdate` / `beforeDelete` fires once for the whole batch because it may
> still rewrite the payload, and one `updateMany` carries one payload — there is
> nothing per-row to hand it. So #5037's `HookConditionError` and its
> `limitation` discriminator (`bulk_write_previous_unbound`,
> `bulk_write_stored_state_unavailable`) **survive, rescoped to that dispatch**,
> and their message no longer promises an expiry that has already happened: it
> names the phase as the reason and points at the matching `after*` event, where
> the same condition evaluates per row exactly as authored. Authors put
> transition conditions on `after*`; `before*` conditions stay over the incoming
> payload.
>
> **One refusal reversed on evidence.** #5037 deliberately did NOT offer "use a
> record-change flow trigger instead", because that trigger subscribes to these
> very lifecycle hooks and so fired once with the same unbound `previous`
> (#4862) — naming it would have made the error that fixes a
> `declared ≠ delivered` into another one. #5038 fixed it at the producer, so an
> after-type record-change trigger now rides the per-row dispatch and the route
> is real. The message names it because the fact changed, not because the
> constraint was relaxed.

---

Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"devDependencies": {
"@objectstack/cli": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@playwright/test": "^1.62.1",
"typescript": "^6.0.3",
Expand Down
Loading
Loading