From 60ad118ad7f1d84bf9e3ddf5dc32ebfda5b29ed5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 10:41:22 +0000 Subject: [PATCH 1/2] docs(objectql): re-key two hook-wrappers docblocks to the per-row before* contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pickRecordPayload`'s docblock and `pickPreviousPayload`'s "when `previous` is ABSENT" list both stated the retired batch model in the present tense: that a predicate (`multi: true`) bulk write's `before*` dispatch fires once for the batch with no prior row, so `previous` stays unbound. ADR-0058 Addendum II (ruling #16074, clauses D1/D2) retired that. The engine dispatches `before*` per matched row and binds that row's pre-image (`dispatchPerRowBeforeHooks`, `previous: coerceBooleanFields(schema, row)`). The only `before*` context that still names no row is the opt-in `dispatchUnscopedMultiWrite` dispatch, fired once for a `multi: true` write carrying no caller predicate at all — which is what the second docblock now names. Prose only: no behaviour change, no assertion change. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude --- packages/objectql/src/hook-wrappers.ts | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/hook-wrappers.ts b/packages/objectql/src/hook-wrappers.ts index 141c57b64f5..b1b4ad27981 100644 --- a/packages/objectql/src/hook-wrappers.ts +++ b/packages/objectql/src/hook-wrappers.ts @@ -1080,12 +1080,13 @@ function declaredFieldsFor(ctx: HookContext): Record | undefine * Materialisation is applied only when the record's persisted state is in hand * — an insert (nothing to know) or an update whose prior row was fetched. * - * Since #5038 a predicate bulk update's AFTER dispatch is per row and DOES - * carry the row's prior state, so it merges and materialises like any - * single-record write — which is exactly what "`record` is the row's real state, - * not the bare payload" means (#4862). Its `before*` dispatch still fires once - * for the batch with no prior row, so that payload is left exactly as it is - * rather than gaining `null`s that contradict N stored rows. + * A predicate bulk update's AFTER dispatch is per row since #5038, and its + * `before*` dispatch since #5574 (ADR-0058 Addendum II, D1/D2): both carry the + * row's prior state, so both merge and materialise like any single-record + * write — which is exactly what "`record` is the row's real state, not the + * bare payload" means (#4862). A context whose prior row was never read keeps + * its payload exactly as it is rather than gaining `null`s that contradict + * stored state. * * Copies, never mutates: `ctx.previous` and `ctx.input.data` are the engine's * own objects, observed by the handlers that run after this gate. @@ -1174,12 +1175,15 @@ function pickRecordPayload(ctx: HookContext): any { * identifier from the CEL scope. Same here: * - **insert** — there is no prior state, so `previous` is unbound and any * reference to it is an author error, reported as such; - * - **the `before*` dispatch of a predicate (`multi: true`) bulk write** — - * it fires ONCE for N matched rows, so there is no single prior record to - * bind; `previous` stays unbound rather than being invented. The `after*` - * dispatch of that same write is per row since #5038 and binds the row's - * own pre-image, so a transition condition there reads exactly as it does - * on a single-record write. + * - **a `before*` dispatch that names no row** — the opt-in + * `dispatchUnscopedMultiWrite` one, fired once for a `multi: true` write + * carrying no caller predicate at all: with no row there is no single + * prior record to bind, so `previous` stays unbound rather than being + * invented. A predicate (`multi: true`) bulk write is NOT this case — it + * dispatches both phases per matched row (`after*` since #5038, `before*` + * since #5574, ADR-0058 Addendum II D1/D2), each binding the row's own + * pre-image, so a transition condition reads exactly as it does on a + * single-record write. * Binding `null`/`{}` instead would make `previous.x == null` answer "yes" * for a record whose prior state is simply unknown — a fabricated fact, the * one thing materialisation is careful never to do. From 0cd458243afb413f22aa68266963dd5f536af491 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 10:49:46 +0000 Subject: [PATCH 2/2] docs(objectql): align the two docblocks with the shipped per-row vocabulary, add the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the same two sites after merging PR #18625 (card #17975), the sibling prose repair that landed in this package. - Both sites now use the package's shipped spelling for the dispatch model: per matched row, on the single-record shape, ADR-0058 Addendum II D1/D2. - The second site no longer names the opt-in `dispatchUnscopedMultiWrite` dispatch. `wrapDeclarativeHook` is applied at registration (`hook-binder.ts:221`), but no metadata binding can set that flag today, so naming it as an absence case this function observes would be a claim that cannot be shown to be reachable. The truthful statement — any update-shaped context whose prior row is not in hand — is true by construction. - Adds the `patch` changeset: the first docblock is emitted verbatim onto the exported `hookRecordState` declaration in the published `.d.ts`. Prose only: no behaviour change, no assertion change. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude --- ...bjectql-hook-wrappers-per-row-docblocks.md | 35 +++++++++++++++++++ packages/objectql/src/hook-wrappers.ts | 18 +++++----- 2 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 .changeset/18331-objectql-hook-wrappers-per-row-docblocks.md diff --git a/.changeset/18331-objectql-hook-wrappers-per-row-docblocks.md b/.changeset/18331-objectql-hook-wrappers-per-row-docblocks.md new file mode 100644 index 00000000000..a64f616518f --- /dev/null +++ b/.changeset/18331-objectql-hook-wrappers-per-row-docblocks.md @@ -0,0 +1,35 @@ +--- +'@objectstack/objectql': patch +--- + +docs(objectql): the hook-wrapper docblocks state the per-row `before*` contract (#18331) + +Two docblocks in `hook-wrappers.ts` stated the RETIRED batch model in the +present tense: `pickRecordPayload`'s said a predicate (`multi: true`) bulk +update's `before*` dispatch "still fires once for the batch with no prior row", +and `pickPreviousPayload`'s "when `previous` is ABSENT" list named that same +dispatch as an absence case because "it fires ONCE for N matched rows". + +Ruling #16074 / ADR-0058 Addendum II (clauses D1/D2) retired that model, and the +engine already implements the replacement: `dispatchPerRowBeforeHooks` dispatches +`before*` once per matched row on the single-record shape and binds that row's +pre-image (`previous: coerceBooleanFields(schema, row)`). So both phases of a +predicate write now merge, materialise and bind `previous` exactly as a +single-record write does; what remains unbound is any update-shaped context +whose prior row is not in hand, which is what the second docblock now says. + +This is published text, not an internal comment. Measured against the shipped +`@objectstack/objectql@17.4.0` tarball: the first docblock is emitted verbatim +onto the exported `hookRecordState` declaration (`dist/util-Dw5ZTIII.d.ts:8039`, +and the matching `.d.mts`), reachable from both the `.` and `./core` +entrypoints, so every consumer's editor surfaces the retired sentence on hover. +The second docblock does NOT ship — `pickPreviousPayload` is module-private and +appears in `dist/` only as an `{@link}` reference — but it is the source a +maintainer reads, and two docblocks one screen apart stating opposite contracts +is the drift this repairs. + +No behaviour change and no assertion change: prose only. + +Graded `patch`: the act moves published PROSE. It adds no exported symbol, no +key and no accepted value — the accept set was widened by PR #17249 in +`@objectstack/spec`, not here — so this PR declares no clause ② (`Clause-②: no`). diff --git a/packages/objectql/src/hook-wrappers.ts b/packages/objectql/src/hook-wrappers.ts index b1b4ad27981..89d83464d48 100644 --- a/packages/objectql/src/hook-wrappers.ts +++ b/packages/objectql/src/hook-wrappers.ts @@ -1084,7 +1084,7 @@ function declaredFieldsFor(ctx: HookContext): Record | undefine * `before*` dispatch since #5574 (ADR-0058 Addendum II, D1/D2): both carry the * row's prior state, so both merge and materialise like any single-record * write — which is exactly what "`record` is the row's real state, not the - * bare payload" means (#4862). A context whose prior row was never read keeps + * bare payload" means (#4862). A context whose prior row is not in hand keeps * its payload exactly as it is rather than gaining `null`s that contradict * stored state. * @@ -1175,15 +1175,13 @@ function pickRecordPayload(ctx: HookContext): any { * identifier from the CEL scope. Same here: * - **insert** — there is no prior state, so `previous` is unbound and any * reference to it is an author error, reported as such; - * - **a `before*` dispatch that names no row** — the opt-in - * `dispatchUnscopedMultiWrite` one, fired once for a `multi: true` write - * carrying no caller predicate at all: with no row there is no single - * prior record to bind, so `previous` stays unbound rather than being - * invented. A predicate (`multi: true`) bulk write is NOT this case — it - * dispatches both phases per matched row (`after*` since #5038, `before*` - * since #5574, ADR-0058 Addendum II D1/D2), each binding the row's own - * pre-image, so a transition condition reads exactly as it does on a - * single-record write. + * - **any update-shaped context whose prior row is not in hand** — with no + * row there is no single prior record to bind, so `previous` stays unbound + * rather than being invented. A predicate (`multi: true`) bulk write is no + * longer one of these: since #5574 (ADR-0058 Addendum II, D1/D2) it + * dispatches BOTH phases per matched row — `after*` since #5038 — each + * context binding that row's own pre-image, so a transition condition + * reads exactly as it does on a single-record write. * Binding `null`/`{}` instead would make `previous.x == null` answer "yes" * for a record whose prior state is simply unknown — a fabricated fact, the * one thing materialisation is careful never to do.