|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25522 cut 6] The predicate-write hook dispatch bodies of `ObjectQL`, moved |
| 5 | + * out of `engine.ts` whole: the per-row `after*` contexts a `multi: true` write |
| 6 | + * dispatches (`buildPerRowAfterContexts`), the per-row `before*` dispatch with |
| 7 | + * its D3 accumulate and key-divergence halves (`dispatchPerRowBeforeHooks`), |
| 8 | + * the ceiling on that fan-out (`assertBulkPerRowHookBudget`), and the unit an |
| 9 | + * aborting `after*` chain runs in (`inAfterAbortUnit`). |
| 10 | + * |
| 11 | + * A MOVE, not a redesign. Each exported function below is the body of the |
| 12 | + * `ObjectQL` method of the same name as it stood, re-indented one level and |
| 13 | + * otherwise unedited: the same single-record context shape, the same one |
| 14 | + * payload carried by reference through every row, the same refusal before any |
| 15 | + * write, and the same commit-then-rethrow for `SummaryRecomputeError`. Each |
| 16 | + * method's docblock stays on the method in `engine.ts`, which is still the |
| 17 | + * contract. |
| 18 | + * |
| 19 | + * ## How the moved bodies reach the class |
| 20 | + * |
| 21 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 22 | + * header carries the full reasoning), applied per method as |
| 23 | + * `engine-secret-internal-fields.ts` applies it. The class keeps all four |
| 24 | + * methods — the published `.d.ts` lists each one's name and position — and |
| 25 | + * each body is one literal of exactly the {@link HookDispatchHost} members |
| 26 | + * that method reads, plus a `.call`. The one field the bodies read |
| 27 | + * (`_registry`) goes as a GETTER, so the read happens when the body makes it. |
| 28 | + * The two methods they call (`triggerHooks`, `transaction`) go as late-bound |
| 29 | + * closures over the instance, so a double or spy set on the engine is still |
| 30 | + * the one a call reaches. `assertBulkPerRowHookBudget` reads no member and is |
| 31 | + * called directly. |
| 32 | + * |
| 33 | + * The three other members of the same section (`hasHooksFor`, |
| 34 | + * `opensAfterAbortUnit`, `dispatchUnscopedMultiWriteHooks`) stay in the class: |
| 35 | + * each calls `hookMatchesObject`, a function `engine.ts` declares at module |
| 36 | + * level, and reaching it from here would be a value import of the engine. |
| 37 | + * |
| 38 | + * This module imports the engine only as a TYPE, so there is no module cycle |
| 39 | + * at run time, and nothing re-exports it, so the published surface does not |
| 40 | + * move. |
| 41 | + */ |
| 42 | + |
| 43 | +import type { HookContext } from '@objectstack/spec/data'; |
| 44 | +import { resolveBulkPerRowHookBudget } from '@objectstack/spec/data'; |
| 45 | +import { SummaryRecomputeError } from './summary-errors.js'; |
| 46 | +import { HookTargetRebindError } from './hook-target-rebind-errors.js'; |
| 47 | +import { coerceBooleanFields } from './validation/record-validator.js'; |
| 48 | +import { recordHookPayloadWrites } from './hook-write-provenance.js'; |
| 49 | +import { |
| 50 | + divergingHookPayloadKeys, |
| 51 | + MultiUpdateHookKeyDivergenceError, |
| 52 | +} from './multi-update-hook-key-divergence.js'; |
| 53 | +import type { ObjectQL } from './engine.js'; |
| 54 | + |
| 55 | +/** |
| 56 | + * The `ObjectQL` members the functions below read — every one of them, and no |
| 57 | + * other; each function's `this` is the `Pick` of the members IT reads. Each is |
| 58 | + * typed AS the class member (an indexed access on the class), so a change to a |
| 59 | + * member's type on the class is a change here too, and naming a member the |
| 60 | + * class does not have is a compile error. See the module header for which |
| 61 | + * members are getters. |
| 62 | + */ |
| 63 | +export interface HookDispatchHost { |
| 64 | + readonly _registry: ObjectQL['_registry']; |
| 65 | + triggerHooks: ObjectQL['triggerHooks']; |
| 66 | + transaction: ObjectQL['transaction']; |
| 67 | +} |
| 68 | + |
| 69 | +/** The body of `ObjectQL#inAfterAbortUnit`; the method's docblock there is its contract. */ |
| 70 | +export async function inAfterAbortUnit<T>( |
| 71 | + this: Pick<HookDispatchHost, 'transaction'>, |
| 72 | + write: () => Promise<T>, |
| 73 | +): Promise<T> { |
| 74 | + let written: SummaryRecomputeError | undefined; |
| 75 | + const result = await this.transaction(async () => { |
| 76 | + try { |
| 77 | + return await write(); |
| 78 | + } catch (e) { |
| 79 | + if (!(e instanceof SummaryRecomputeError)) throw e; |
| 80 | + written = e; |
| 81 | + return undefined as T; |
| 82 | + } |
| 83 | + }); |
| 84 | + if (written) throw written; |
| 85 | + return result; |
| 86 | +} |
| 87 | + |
| 88 | +/** The body of `ObjectQL#buildPerRowAfterContexts`; the method's docblock there is its contract. */ |
| 89 | +export function buildPerRowAfterContexts( |
| 90 | + this: Pick<HookDispatchHost, '_registry'>, |
| 91 | + object: string, |
| 92 | + event: 'afterUpdate' | 'afterDelete', |
| 93 | + rows: Record<string, unknown>[], |
| 94 | + batchCtx: HookContext, |
| 95 | + payload?: Record<string, unknown>, |
| 96 | +): HookContext[] { |
| 97 | + const schema = this._registry.getObject(object); |
| 98 | + const options = (batchCtx.input as { options?: unknown } | undefined)?.options; |
| 99 | + return rows.map((row, index) => ({ |
| 100 | + ...batchCtx, |
| 101 | + event, |
| 102 | + input: payload |
| 103 | + ? { id: (row as { id?: unknown }).id, data: { ...payload }, options } |
| 104 | + : { id: (row as { id?: unknown }).id, options }, |
| 105 | + // [#6966] `mode` and `scope` ride over from the batch context; only the |
| 106 | + // position differs. Spreading `batchCtx` would carry index 0 onto every |
| 107 | + // row, which is the one member a per-row consumer keys "do this once" on. |
| 108 | + dispatch: { ...(batchCtx.dispatch as object), index } as HookContext['dispatch'], |
| 109 | + previous: coerceBooleanFields(schema as any, row as any), |
| 110 | + result: payload |
| 111 | + ? coerceBooleanFields(schema as any, { ...row, ...payload } as any) |
| 112 | + : undefined, |
| 113 | + }) as unknown as HookContext); |
| 114 | +} |
| 115 | + |
| 116 | +/** The body of `ObjectQL#dispatchPerRowBeforeHooks`; the method's docblock there is its contract. */ |
| 117 | +export async function dispatchPerRowBeforeHooks( |
| 118 | + this: Pick<HookDispatchHost, '_registry' | 'triggerHooks'>, |
| 119 | + object: string, |
| 120 | + event: 'beforeUpdate' | 'beforeDelete', |
| 121 | + rows: Record<string, unknown>[], |
| 122 | + batchCtx: HookContext, |
| 123 | +): Promise<void> { |
| 124 | + const schema = this._registry.getObject(object); |
| 125 | + const carriesPayload = event === 'beforeUpdate'; |
| 126 | + // ── [#14099] D3's ENFORCEMENT half: one observation window per row ────── |
| 127 | + // |
| 128 | + // The #14088 recorder, armed a SECOND time for this loop and NESTED over |
| 129 | + // the batch-scoped recording `update()` already armed at its entry. The |
| 130 | + // nesting is what makes both readings true at once: a write through this |
| 131 | + // view lands on the outer recording's view, which lands on the real |
| 132 | + // payload — so the outer record (the one the read-only strips read for |
| 133 | + // provenance) still sees every hook write, while `closeWindow()` gives |
| 134 | + // this loop what only it needs: the keys assigned by ONE row's chain. |
| 135 | + // |
| 136 | + // ⭐ ONE recording for the whole loop, not one per row, and that is a |
| 137 | + // contract point rather than a saving: every per-row context must carry |
| 138 | + // THE SAME payload object (D3, pinned by reference identity in |
| 139 | + // `bulk-write-per-row-hooks.test.ts`). A fresh view per row would still be |
| 140 | + // write-through onto one payload — no copy, nothing to reconcile — but it |
| 141 | + // would hand each row a DIFFERENT object, which is a difference an author |
| 142 | + // can observe and which this contract says is not there. |
| 143 | + const batchPayloadAtEntry = (batchCtx.input as { data?: unknown }).data; |
| 144 | + const hookWrites = |
| 145 | + carriesPayload && batchPayloadAtEntry !== null && typeof batchPayloadAtEntry === 'object' |
| 146 | + ? recordHookPayloadWrites(batchPayloadAtEntry as Record<string, unknown>) |
| 147 | + : undefined; |
| 148 | + if (hookWrites) (batchCtx.input as { data?: unknown }).data = hookWrites.payload; |
| 149 | + const perRowHookWrittenKeys: ReadonlySet<string>[] = []; |
| 150 | + for (let index = 0; index < rows.length; index++) { |
| 151 | + const row = rows[index]; |
| 152 | + const rowId = (row as { id?: unknown }).id; |
| 153 | + const options = (batchCtx.input as { options?: unknown }).options; |
| 154 | + const rowCtx = { |
| 155 | + ...batchCtx, |
| 156 | + event, |
| 157 | + // [#6966] See `buildPerRowAfterContexts` — same rule. The `scope` |
| 158 | + // identity carried over from `batchCtx` is what lets a `before*` |
| 159 | + // handler leave something an `after*` handler can still find: a per-row |
| 160 | + // context is a fresh object, so a stash written on the context itself |
| 161 | + // dies with the row that held it. |
| 162 | + dispatch: { ...(batchCtx.dispatch as object), index } as HookContext['dispatch'], |
| 163 | + // D3: THE payload, read fresh so a previous row's REPLACEMENT is what |
| 164 | + // this row sees. Never a copy. |
| 165 | + input: carriesPayload |
| 166 | + ? { id: rowId, data: (batchCtx.input as { data?: unknown }).data, options } |
| 167 | + : { id: rowId, options }, |
| 168 | + previous: coerceBooleanFields(schema as any, row as any), |
| 169 | + // D2: no post-state in the before phase. |
| 170 | + result: undefined, |
| 171 | + } as unknown as HookContext; |
| 172 | + |
| 173 | + await this.triggerHooks(event, rowCtx); |
| 174 | + |
| 175 | + // D3, the accumulate half — see the class doc above. |
| 176 | + if (carriesPayload) { |
| 177 | + (batchCtx.input as { data?: unknown }).data = (rowCtx.input as { data?: unknown }).data; |
| 178 | + // [#14099] and the enforcement half: close THIS row's window, so the |
| 179 | + // next row opens a fresh one. |
| 180 | + if (hookWrites) perRowHookWrittenKeys.push(hookWrites.closeWindow()); |
| 181 | + } |
| 182 | + // D4. |
| 183 | + const observed = (rowCtx.input as { id?: unknown }).id; |
| 184 | + if (observed !== rowId) { |
| 185 | + throw new HookTargetRebindError({ |
| 186 | + object, event, path: 'per-row', expectedId: rowId, observedId: observed, |
| 187 | + }); |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + // [#14099] Close this loop's recording and put the underlying payload back |
| 192 | + // in `batchCtx.input.data`, so no recording VIEW travels on to the outer |
| 193 | + // seal, the strips or a driver. |
| 194 | + // |
| 195 | + // `hookWrittenKeys` is `undefined` exactly when a hook REPLACED the payload |
| 196 | + // object somewhere in this batch (`hook-write-provenance.ts`'s KNOWN |
| 197 | + // LIMIT). Then the whole batch ABSTAINS: the windows collected before the |
| 198 | + // replacement describe writes the replacement discarded, so refusing on |
| 199 | + // them would be a verdict about a payload that is no longer the one being |
| 200 | + // written. Abstaining keeps the pre-#14099 behaviour for that shape, which |
| 201 | + // is the same fail-safe direction #14088 chose for the same limit. |
| 202 | + const sealedLoopWrites = hookWrites?.seal((batchCtx.input as { data?: unknown }).data); |
| 203 | + if (sealedLoopWrites) { |
| 204 | + (batchCtx.input as { data?: unknown }).data = sealedLoopWrites.data; |
| 205 | + } |
| 206 | + |
| 207 | + // [#14099] The refusal, ruled 2026-09-02 (recommendation C). Placed after |
| 208 | + // the loop and not inside it, for two reasons that are both about the |
| 209 | + // envelope rather than about cost: the diverging set is `union` minus |
| 210 | + // `intersection` over EVERY row, so the message names every offending key |
| 211 | + // instead of the first pair to disagree, and it is order-independent — the |
| 212 | + // same batch answers the same way whatever order the driver returned the |
| 213 | + // matched rows in. |
| 214 | + // |
| 215 | + // ⭐ Still BEFORE any write, which is the load-bearing half. This method is |
| 216 | + // called from `update()`'s predicate branch ahead of the outer hook-write |
| 217 | + // seal, both readonly strips, `evaluateValidationRules` and every |
| 218 | + // `driver.updateMany` — and it runs outside `update()`'s own `try`, so the |
| 219 | + // envelope reaches the caller undecorated. Not "after the first row", not |
| 220 | + // "inside a transaction that then rolls back": nothing was written. |
| 221 | + if (sealedLoopWrites?.hookWrittenKeys !== undefined) { |
| 222 | + const diverging = divergingHookPayloadKeys(perRowHookWrittenKeys); |
| 223 | + if (diverging.length > 0) { |
| 224 | + throw new MultiUpdateHookKeyDivergenceError(object, diverging, rows.length); |
| 225 | + } |
| 226 | + } |
| 227 | +} |
| 228 | + |
| 229 | +/** The body of `ObjectQL#assertBulkPerRowHookBudget`; the method's docblock there is its contract. */ |
| 230 | +export function assertBulkPerRowHookBudget(object: string, event: string, matched: number): void { |
| 231 | + const verdict = resolveBulkPerRowHookBudget({ object, event, matched }); |
| 232 | + if (verdict.kind === 'ok') return; |
| 233 | + throw Object.assign(new Error(verdict.message), { |
| 234 | + code: verdict.code, |
| 235 | + object: verdict.object, |
| 236 | + event: verdict.event, |
| 237 | + matched: verdict.matched, |
| 238 | + limit: verdict.limit, |
| 239 | + }); |
| 240 | +} |
0 commit comments