|
| 1 | +--- |
| 2 | +"@objectstack/objectql": minor |
| 3 | +"@objectstack/spec": minor |
| 4 | +--- |
| 5 | + |
| 6 | +fix(objectql,spec): refuse a `multi: true` update whose per-row `beforeUpdate` hooks write divergent key sets (#14099) |
| 7 | + |
| 8 | +**BREAKING** accept-set narrowing on a published write path, shipped as `minor` |
| 9 | +under the repo's launch-window convention for breaking changes. A `multi: true` |
| 10 | +update that succeeds today is REFUSED when its `beforeUpdate` handlers assign |
| 11 | +different sets of payload keys to different matched rows. |
| 12 | + |
| 13 | +**What it fixes.** `driver.updateMany` takes one `SET` clause for N rows, so a |
| 14 | +predicate update has exactly one payload (ADR-0058 Addendum II D3) — whatever a |
| 15 | +`beforeUpdate` handler writes for one row was applied to every matched row. The |
| 16 | +transition stamp is the shape this breaks, and it is the standard way to record |
| 17 | +when a record entered a state: |
| 18 | + |
| 19 | +```ts |
| 20 | +// beforeUpdate — correct per record, silently wrong on a batch |
| 21 | +if (previous.status !== 'done' && next.status === 'done') patch.completed_at = now; |
| 22 | +``` |
| 23 | + |
| 24 | +Measured against published `17.2.0`: two rows, one open and one completed |
| 25 | +earlier, updated in a single `multi: true` call. The already-completed row's |
| 26 | +`completed_at` moved from `…:26.560Z` to `…:26.571Z`. It never transitioned, |
| 27 | +nothing errored, and the corrupted row is byte-for-byte indistinguishable from |
| 28 | +one genuinely completed late — so every on-time measure reading the column turns |
| 29 | +a compliant record into a breach, with no audit entry and nothing in the data |
| 30 | +that shows it happened. The whole class is exposed: `approved_at`, `closed_at`, |
| 31 | +`shipped_at`, `first_responded_at`. |
| 32 | + |
| 33 | +**What changed.** The engine still dispatches the before phase once per matched |
| 34 | +row with that row's pre-image, and D3 still stands — the payload stays |
| 35 | +batch-scoped and the engine never splits its own write. It now also RECORDS, |
| 36 | +per row, the set of payload keys that row's hook chain assigned (the #14088 |
| 37 | +provenance recorder, armed once more per row). If two rows disagree, the whole |
| 38 | +batch is refused before any write — not after the first row, not inside a |
| 39 | +transaction that then rolls back — with the ADR-0112 envelope |
| 40 | +`MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (HTTP `400`, |
| 41 | +`MultiUpdateHookKeyDivergenceError`), naming the object, the diverging keys and |
| 42 | +the remedy. When every row's key set is identical the batch proceeds as one |
| 43 | +`updateMany`, exactly as before. |
| 44 | + |
| 45 | +**The criterion is the key SET, never the values.** That is what keeps honest |
| 46 | +batches honest: objectql's own `sys_stamp_audit_update` builtin is registered on |
| 47 | +`'*'` and reads the clock inside the per-record stamp, so an ordinary bulk |
| 48 | +update writes `updated_at` on every row with different values. Every in-repo |
| 49 | +`beforeUpdate` payload rewrite was measured on a mixed batch before this shipped |
| 50 | +— the audit stamp (`['updated_at','updated_by']` on every row), plugin-pinyin's |
| 51 | +companion projection (`['__search']` on every row) and service-storage's |
| 52 | +copy-on-claim (`[]` on every row) — and all three are row-invariant, so none of |
| 53 | +them is refused. |
| 54 | + |
| 55 | +**Migration — how to write a per-record rewrite on a batch.** Two supported |
| 56 | +routes, both available in this release: |
| 57 | + |
| 58 | +1. **Route 2, from inside the handler.** Write the affected records with |
| 59 | + `ctx.api`, aimed with the per-row signals the hook sandbox now carries |
| 60 | + (`ctx.dispatch.mode === 'per-row'`, `ctx.input.id`, `ctx.input.options`), |
| 61 | + and leave the batch payload alone. ⚠️ Those signals are NOT in `17.2.0` — |
| 62 | + they land in this same release, which is why the refusal and its |
| 63 | + prescription ship together rather than the refusal arriving first. |
| 64 | +2. **By-id updates from the caller.** Issue the updates per record when the |
| 65 | + value genuinely differs per record. |
| 66 | + |
| 67 | +`objectstack-ai/hotcrm` and `objectstack-ai/duly` both carry hooks of this |
| 68 | +shape and should take route 1: `duly`'s `duly_task.completed_at` stamp is the |
| 69 | +measured instance, and hotcrm's `previous`-reading handlers are the same family. |
| 70 | + |
| 71 | +**Known limit, carried openly rather than hidden.** A handler that writes the |
| 72 | +SAME key on every row but with a per-row VALUE (a per-row derived priority, say) |
| 73 | +still passes this test, and still applies the last dispatch's value to every |
| 74 | +matched row. That is D3's declared cost; the two routes above are the exit for |
| 75 | +it, and it is tracked as its own finding. ⛔ It is deliberately NOT closed by |
| 76 | +comparing values: a value comparison refuses honest audit-stamp batches |
| 77 | +non-deterministically (one clock read per row) and re-opens #14088's own |
| 78 | +`completed_at: null` row, where a hook that writes the value the caller also |
| 79 | +sent is indistinguishable from a hook that never touched the key. |
| 80 | + |
| 81 | +<!-- adr-0087: not-required (no-migration-prescription) A runtime accept-set narrowing on the engine's predicate-update path: no authorable metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. The affected artifact is HOOK BODY CODE, whose per-row intent no mechanical rewrite can infer — choosing between a `ctx.api` per-row write and by-id updates is an authoring decision. The refusal itself is the notification channel, raised at the write site with the object, the diverging keys and both routes in the envelope. --> |
0 commit comments