diff --git a/.changeset/17975-objectql-per-row-previous-docblock.md b/.changeset/17975-objectql-per-row-previous-docblock.md new file mode 100644 index 00000000000..2051b99186b --- /dev/null +++ b/.changeset/17975-objectql-per-row-previous-docblock.md @@ -0,0 +1,34 @@ +--- +'@objectstack/objectql': patch +--- + +docs(objectql): the per-row `before*` docblock states the #16074 rule — a row-invariant-in-effect rewrite is ADMITTED (#17975) + +`dispatchPerRowBeforeHooks`'s docblock (ADR-0058 Addendum II, clause D3) still +said per-row `previous` was supplied *"so a guard can REFUSE the write (throw), +not so a rewrite can be aimed"*, and a test comment in +`bulk-write-per-row-hooks.test.ts` said the same. Ruling #16074, landed in +`@objectstack/spec` by PR #17249, retired that: a per-row `previous`-conditioned +rewrite is admitted when its written KEY SET is the same on every matched row +and is assigned IN PLACE, kept safe by the engine's +`MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099). Key-set divergence, a +per-row VALUE and a row-conditioned REPLACEMENT of `ctx.input.data` all stay +outside the contract. + +This is published text, not an internal comment: JSDoc on a `private` member +survives `.d.ts` emit. Measured in the shipped `@objectstack/objectql@17.4.0` +tarball — the retired sentence is present in six published files, including +`dist/util-Dw5ZTIII.d.ts:3554`, on a member of the `ObjectQL` class that both +the `.` and `./core` entrypoints export. Every consumer's editor surfaces it on +hover, so as soon as spec's changeset is consumed the two packages would state +opposite contracts. + +No behaviour change: the engine already follows the new rule, and the three +shipped provenance stamps (`email-template-provenance.ts`, +`sharing-rule-provenance.ts`, `webhook-provenance.ts`) all assign in place. The +admitted shape's coverage already exists in +`multi-update-hook-key-divergence.test.ts`; the test comment now points at it. + +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 ②. diff --git a/packages/objectql/src/bulk-write-per-row-hooks.test.ts b/packages/objectql/src/bulk-write-per-row-hooks.test.ts index 8a668c68c30..236be89bfcc 100644 --- a/packages/objectql/src/bulk-write-per-row-hooks.test.ts +++ b/packages/objectql/src/bulk-write-per-row-hooks.test.ts @@ -566,8 +566,12 @@ describe('[#5574 / D2] the per-row before context is the SINGLE-RECORD shape', ( }); it('a `previous`-reading GUARD can now refuse a bulk write per row', async () => { - // What per-row `previous` is FOR (D3 says so in as many words): so a guard - // can REFUSE, not so a rewrite can be aimed at one row. + // ONE of the two things per-row `previous` is FOR (D3 says so in as many + // words): so a guard can REFUSE. The other, ruled on #16074, is a + // ROW-INVARIANT-IN-EFFECT rewrite — the same written key set on every + // matched row, assigned in place — which + // `multi-update-hook-key-divergence.test.ts` covers. What stays outside + // the contract is a rewrite AIMED at one row. const { engine } = await boot([hook('guard', 'beforeUpdate', (ctx) => { if ((ctx.previous as any)?.status === 'locked') throw new Error('row is locked'); })]); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index aa6ec36f32e..edf4a5eec5c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3445,10 +3445,15 @@ export class ObjectQL implements IObjectQLEngine { * its dispatch — that write-back is what makes "accumulate in dispatch order" * true for both spellings rather than only the first. * - * A rewrite CONDITIONED on the row (`ctx.previous`, `ctx.input.id`) is - * outside the contract: it does not scope itself to the row it was decided - * on, it widens to every matched row. Per-row `previous` is supplied so a - * guard can REFUSE the write (throw), not so a rewrite can be aimed. + * A rewrite CONDITIONED on the row (`ctx.previous`, `ctx.input.id`) cannot + * scope itself to the row it was decided on: it widens to every matched row. + * Per-row `previous` is supplied so a guard can REFUSE the write (throw), + * and — ruled on #16074 — so a hook can make a ROW-INVARIANT-IN-EFFECT + * rewrite: one whose written KEY SET is the same on every matched row AND is + * assigned IN PLACE (`ctx.input.data.x = 1`, never `ctx.input.data = {…}`, + * which the recording below cannot attribute). A rewrite AIMED at one row + * stays outside the contract, and what makes the admitted shape safe is that + * enforcement rather than the hook's good faith. * * ## D3, ENFORCED — divergent key sets refuse the batch [#14099] *