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
55 changes: 55 additions & 0 deletions .changeset/bulk-write-previous-hook-condition-diagnostic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@objectstack/objectql": patch
---

fix(objectql): a bulk write blocked by a `previous` hook condition is told it is a VERSION limit, not an authoring mistake (#5037)

#4775 made an unevaluable hook `condition` abort the operation, and #4861 gave
the predicate-bulk-write case its own sentence instead of a raw
`Unknown variable: previous`. What that sentence still said was
*"rewrite the condition without `previous`, or target the write at one record"* —
written before the maintainer's 2026-08-04 ruling on #4800/#4862, and wrong in
its most important claim. The ruling settled the contract: **on a bulk write,
after-hooks and record-change flow triggers evaluate and fire per row** (recorded
as an ADR-0058 addendum, implemented by #5038). The author's transition condition
is legitimate; the engine is what is behind. Telling them to drop `previous` was
advising a silent semantic change — a transition ("just became done") becomes a
state test ("is done"), which fires on every row that was already done.

The rejection now says what is actually true:

- it names the batch and why there is no single prior record to bind, as before;
- it states this is a **current-version limitation**, cites the per-row contract
(ADR-0058 addendum, #4800/#4862) and the issue that retires the rejection
(#5038);
- it leads with the route that works today — target the write at one record, and
the same condition evaluates as authored — and prices the rewrite instead of
recommending it;
- it still refuses to point at a record-change flow trigger as a way out, which
remains verified rather than assumed: that trigger binds the same lifecycle
hooks and receives the same unbound `previous` on a bulk write (#4862).

**Machine-readable, so a caller never parses the prose.** `HookConditionError`
gains `limitation?: 'bulk_write_previous_unbound' |
'bulk_write_stored_state_unavailable'` (exported as `HookConditionLimitation`)
alongside the existing `predicateBulkWrite` flag. It is deliberately *not* named
`code`: ADR-0112 makes `error.code` a closed wire vocabulary
(`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) and `rest-server.ts` promotes a
thrown error's `.code` onto the response envelope, so a `.code` here would mint
an unregistered wire code as a side effect. A code that needs to travel goes
through the ledger as a decision.

**"Does this condition read `previous`" is now read off the parsed CEL AST**
(`collectCelRootIdentifiers`, the utility #4972's build gate already uses),
computed once at wrap time, with the old fault-text check kept as a fallback.
The diagnosis no longer depends on cel-js's wording, and it stays correct when
the evaluator faults on some other key the same condition reads.
`record.previous_status` is not a `previous` reference — the AST reports roots,
not member names — so it keeps the declared-field diagnosis, whose remedy is the
right one there.

Unchanged, and pinned by tests: single-record writes (bound `previous`, condition
evaluates, handler runs), bulk writes whose conditions do not name `previous`,
the plain undeclared-key typo report on a bulk write, and fail-loud itself — the
write still fails. Nothing here is an exemption; it is the same rejection with a
diagnosis attached.
54 changes: 54 additions & 0 deletions docs/adr/0058-expression-and-predicate-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,60 @@

---

> **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._
>
> 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
> write touches N rows — and that is a scope question this ADR owns, because
> "the same CEL means the same thing on every surface" is D6's premise.
>
> **The decision.** A bulk write is N record changes, so every record-scoped
> declaration on it is evaluated **per row**, with `record` = that row's state
> and `previous` = that row's pre-write state. This is not a new idea on the
> platform: validation predicates have worked this way on bulk writes since
> #3106 (`rulesNeedRows` fetches the matched rows and `evaluateValidationRules`
> runs once per row). Hook `condition`s — and the record-change flow triggers
> that ride the same lifecycle hooks — join them. An author writes one
> 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
> 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**.
>
> **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.
>
> **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.
>
> **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.

---

## TL;DR

ObjectStack exposes **~50 authorable declarations** that hold an expression — formulas, visibility/required/readonly predicates, validation rules, hook conditions, flow/edge conditions, sharing-rule conditions, RLS `using`/`check`, action/view/app visibility, notification/ETL/export/sync/connector conditions — and they all funnel through **one authoring primitive** (`ExpressionInputSchema` → `{ dialect: 'cel', source }`, helpers `cel`/`F`/`P`). The authoring surface is already unified and clean.
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export { applyInMemoryAggregation, bucketDateValue } from './in-memory-aggregati
export { bindHooksToEngine } from './hook-binder.js';
export type { BindHooksOptions, BindHooksResult } from './hook-binder.js';
export { wrapDeclarativeHook, HookConditionError } from './hook-wrappers.js';
export type { WrapDeclarativeOptions } from './hook-wrappers.js';
export type { WrapDeclarativeOptions, HookConditionLimitation } from './hook-wrappers.js';

// Validation
export { ValidationError, validateRecord } from './validation/record-validator.js';
Expand Down
Loading
Loading