Skip to content

Commit 3380671

Browse files
refactor(objectql): move the predicate-write hook dispatch bodies out of engine.ts into engine-hook-dispatch.ts (#25522 cut 6) (#26102)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5Hj2h25nVuAGKfmJ1qtm6m)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 6 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6414562831`. Four bodies of the "Hook dispatch internals" section move unedited into a new module, `packages/objectql/src/engine-hook-dispatch.ts`: `buildPerRowAfterContexts`, `dispatchPerRowBeforeHooks`, `assertBulkPerRowHookBudget` and `inAfterAbortUnit`. No hook behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-2f5vpq` (the card thread's harness-pinned branch), cut from `origin/main` at `712770bee` (after cut 5, `4bea75589f`), with `main` merged in twice (`be7897799`, then `aa2f8e31f`, because `dispatch-gates` reported a stale gate script). Neither merge touched `engine.ts`. ## Why this area (the fresh seam table) Measured on `origin/main` `712770bee`; `engine.ts` is byte-identical at `aa2f8e31f`. `engine.ts` is 23,351 lines, 7,317 of them (31.3%) from the last 30 days (`git blame -s --since=2026-09-26T11:15Z`). `scripts/pm/git-history.mjs count` (window covered, exit 0): 123 commits in 30 days, 31 in 7. `class ObjectQL` holds 318 members, 256 methods, 148 private or protected. Sections are cut 1's contiguous member ranges, re-located by first member name, docblocks included. "inferred public" stays; "U" counts `this.FIELD.delete/insert/update(` sites (AST, casts stripped); "private statics" stay. | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 4750-4975 | Instance state and constructor | `txStore` … `constructor` | 226 | 50 | 1 (1 public) | - | 0 | - | | 4976-4998 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 4999-5342 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5343-5819 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 477 | 53 | 7 (0 public) | - | 0 | - | | 5820-5980 | Action registry and activation | `registerAction` … `setActionActive` | 161 | 19 | 10 (10 public) | - | 1 | - | | 5981-6262 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6263-7016 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 546 | 15 (2 public) | - | 0 | - | | 7017-7123 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 7124-7359 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 236 | 37 | 7 (0 public) | - | 0 | - | | 7360-7690 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7691-7900 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7901-8266 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 8267-8877 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 611 | 78 | 6 (0 public) | - | 1 | - | | 8878-9542 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9543-9751 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9752-9903 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9904-10002 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 10003-11095 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1093 | 429 | 12 (0 public) | - | 0 | - | | 11096-11211 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 11212-11306 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 11307-11726 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 11727-11795 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 11796-12061 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 12062-12229 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 12230-12303 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 12304-12986 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 12987-13009 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 13010-13028 | Expand/cascade limits (statics) | `MAX_EXPAND_DEPTH` … `MAX_BULK_PER_ROW_HOOK_ROWS` | 19 | 0 | 0 (0 public) | - | 0 | MAX_EXPAND_DEPTH, MAX_CASCADE_DEPTH | | 13029-13479 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 13480-13733 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 13734-14177 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 444 | 310 | 5 (0 public) | - | 0 | - | | 14178-14656 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 272 | 9 (0 public) | - | 0 | - | | 14657-15213 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 15214-15871 | validate | `validate` … `validate` | 658 | 447 | 1 (1 public) | - | 0 | - | | 15872-17216 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1345 | 436 | 3 (2 public) | - | 1 | - | | 17217-19148 | update | `update` … `update` | 1932 | 272 | 1 (1 public) | - | 0 | - | | 19149-20689 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 20690-21259 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 21260-21879 | count / aggregate | `count` … `aggregate` | 620 | 236 | 3 (2 public) | - | 0 | - | | 21880-22267 | execute and transactions | `execute` … `enforceTransactionOrigin` | 388 | 63 | 8 (2 public) | - | 0 | - | | 22268-22633 | Compatibility API | `registerObject` … `create` | 366 | 41 | 16 (16 public) | - | 0 | - | The per-method move screen for every section is in the project file `pm-orders/server/25522-cut6-seam-table.md`. A body stays if any of these holds: an inferred-return public method, a U site, a private-static read, an `engine.ts#NAME` pointer under `packages/spec/**` or `docs/adr/**`, or a `this.update(` / `this.insert(` / `this.delete(` self-call. The pick: | area | 30-day lines | movable | why not / why | |---|---|---|---| | Roll-up summaries | 0 of 451 | 46 body lines | `buildSummaryIndex` is cited from `packages/spec` (`functional-completeness.ts`); `recomputeSummaries` self-calls `this.update(`; `ensureSummaryIndexes` writes three fields. Does not pay | | Datasource registry | 1 of 209 | 27 body lines | `unregisterDriver` and `clearDatasourceUnavailable` are U sites (U = 3); `registerDatasourceDef` is cited from `liveness/datasource.json`. Does not pay | | Related-record expansion | 10 of 254 | 0 | `expandRelatedRecords` reads the private static `MAX_EXPAND_DEPTH` and is cited from `liveness/query.json` and `liveness/field.json` | | Migration flags and attestations | 81 of 683 | 176 body lines | `recordObservedDeviation` and `retractCreationAttestation` self-call `this.update(`; the rest write five fields, so the host needs setters, a shape no cut has used | | Driver options, File-reference hydration, Referential integrity, Readable-ids | 209 to 546 recent | 161 to 438 | the hottest sections; the card says take the coldest | | **Hook dispatch internals** (this PR) | **53 of 477** | **4 of 7 bodies, 141 body lines** | the coldest section with more than 100 movable lines; reads one field and no setter; no `packages/spec` or `docs/adr` pointer | Three bodies of the section stay in the class: `hasHooksFor`, `opensAfterAbortUnit` and `dispatchUnscopedMultiWriteHooks`. Each calls `hookMatchesObject`, a function `engine.ts` declares and exports at module level. Reaching it from the new module would be a value import of `engine.ts`, which would be the series' first run-time module cycle (every `engine-*.ts` module imports the engine as a type only). ## The shape (#23050 shape B, as cuts 1 to 5 apply it) - The class keeps all four methods with name, modifiers, signature, docblock and position. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25522 cut 6]`. - `HookDispatchHost` is new: `_registry`, `triggerHooks`, `transaction`, each typed as the class member (`ObjectQL['triggerHooks']`). Each moved function types its `this` as the `Pick` of exactly the members its body reads, and each stub annotates its literal as `ThisParameterType` of that function. There is no cast, and no member changes visibility. - `_registry` goes as a GETTER. `triggerHooks` and `transaction` go as late-bound closures over the instance, so a double or spy set on the engine still intercepts. - `inAfterAbortUnit` is generic, so its stub instantiates the function with the method's own type parameter `T` before `.call(host, write)`. A bare `.call` would erase `T` to `unknown`. The emitted JS is a plain `.call`. - `assertBulkPerRowHookBudget` reads no member and is called directly. - **Imports.** The module imports `ObjectQL` as a TYPE only and nothing re-exports it. `engine.ts` drops the names whose last code reader moved: `resolveBulkPerRowHookBudget`, `divergingHookPayloadKeys` and `MultiUpdateHookKeyDivergenceError`. The last one is still named by a `{@link}` in the `dispatchPerRowBeforeHooks` docblock that stays, and that docblock goes to no published `.d.ts`, because private members print without JSDoc. `SummaryRecomputeError`, `HookTargetRebindError`, `coerceBooleanFields`, `recordHookPayloadWrites` and `MAX_BULK_PER_ROW_HOOK_ROWS` still have readers in `engine.ts` and stay. ## Net lines (the seat's rule) `engine.ts` goes from 23,351 to 23,234 lines, **net -117**. The module is 240 lines: the header and host interface (about 66), four one-line docblocks, and the 141 moved body lines with their signatures. The diff is 415 changed lines over 2 files (+269 / -146), far under the 3,000-line human-merge threshold. ## The published `.d.ts` does not change (ruling `6348891160`) Base: `@objectstack/objectql` built with `engine.ts` restored from `be7897799` and the new module removed (tree only; restored with `git checkout HEAD --`, then `git status` was clean). Head: built at `1b00a88a9`. The second merge brought no package change. The head `dist/index.mjs` carries `inAfterAbortUnit.call(host` once and the base build has it zero times, so each leg built the source it names. All six declaration files are **byte-identical**: | file | sha256, base = head | |---|---| | `core.d.ts` | `1d20303a455a909e4b55fd0802fb0b0bcb59ec51784daf27d5ef4c0659410953` | | `index.d.ts` | `0454d5e70b156d8effbf27b8915587c7cbee241dcd7d698013c0a4c369a02aa5` | | `util-CVzOGxAn.d.ts` | `85ef1cbec2b016d5f7afabaa21689c86c6dffc573076fdfdeb8ea0f1a1b1ad53` | | `core.d.mts` | `ef178bf0725eccc2f358f082ce13dc5122a6d5a53c19ef630017256f67de03b4` | | `index.d.mts` | `ccf900240aa137abe72f82b8abfc872d42c5645467400134c580285f2a3b3090` | | `util-CVzOGxAn.d.mts` | `85ef1cbec2b016d5f7afabaa21689c86c6dffc573076fdfdeb8ea0f1a1b1ad53` | **Positive controls:** (1) making `dispatchPerRowBeforeHooks` `public` in the source and rebuilding (an earlier base, `712770bee`) changed all six hashes, and the method printed with its full signature at `util-35eaTeZh.d.ts:5034`. (2) Removing `private` from line 5063 of the head `util-CVzOGxAn.d.ts` (`private dispatchPerRowBeforeHooks;`) gives `47f9b025059291111682c59ff64f77688fd34cb0077bf8a753443bb6ce3a18d0`. Export lists are the same for ESM and CJS at both ends: `.` 182 names (sorted sha256 `881616cc…0adaa`) and `./core` 54 (`393e8069…90f49`). No moved name is exported. The same six-file comparison at `712770bee` was also byte-identical. ## Proof of move-only A TypeScript-AST leaf-token compare (recursive `getChildren`, JSDoc skipped) of base `engine.ts` against the head module: - For each of the four, the parameters (less `this`), return type, type parameters and body tokens are **SAME**, `async` matches, and every body line equals the base line less two leading spaces, comments included: `dispatchPerRowBeforeHooks` 454 tokens / 103 lines, `buildPerRowAfterContexts` 165 / 17, `inAfterAbortUnit` 73 / 12 (type parameter `T` on both), `assertBulkPerRowHookBudget` 73 / 9. - **Negative controls:** flipping `===` to `!==` in the moved `assertBulkPerRowHookBudget` reports DIFFER. Editing only the `// D4.` comment in the moved `dispatchPerRowBeforeHooks` fails the line rule. Both exit 1. - Class: 318 members at both ends, in the same order. 314 are byte-identical (full text, docblocks included), and the 4 stubs keep docblock, modifiers and signature byte-identical up to the body. - Top level: 226 of 229 base statements are byte-identical in head. The other three are the class, the narrowed `@objectstack/spec/data` import and the removed `multi-update-hook-key-divergence.js` import. Head adds the new module import with its comment. ## Readers of the moved lines - No path reader names the four. No `engine.ts#NAME` pointer anywhere in the repository names one. No `scripts/adr-anchors/**` paragraph describes them: the engine entry's paragraph is the transaction seam. No gate or source-reading test matches their text (screened: every `scripts/` gate, `docs/qa`, `content/docs`, and the 14 test files that read `engine.ts` from disk). - `packages/spec/liveness`: 40 entries cite `engine.ts`, and every one of their keys is still mentioned in `engine.ts` outside the moved range, so the key-mention check loses nothing. - **ADR anchors.** The moved bodies carry no `ADR-NNNN` citation (the `ADR-0058` and `ADR-0119 D1` citations sit in the docblocks, which stay). `check-adr-anchors` counts the same at base and head: 88 anchored files and 1,864 decision-letter citations at `712770bee`, and 88 and 1,878 at `be7897799` against head `1b00a88a9`. No anchor entry is needed. - **Census and receivers.** `check-tenant-audit-census` is green: 243 write call sites, and the moved bodies hold no `this.FIELD.delete/insert/update(` site. `check:logger-receiver-detach` is green (the moved bodies read no logger), and so is `check-system-context-census`. ## Acceptance notes - Prose that names `engine.ts` beside a moved method still resolves by method name, because every method stays on the class. Examples are `service-storage`'s `attachment-lifecycle.ts` and `plugin-audit`'s `audit-writers.ts` comments, and `spec`'s `bulk-write-hook-conformance.ts` naming `ObjectQL.assertBulkPerRowHookBudget`. They are left as they are. - The moved `dispatchPerRowBeforeHooks` body still says "see the class doc above" next to the D3 accumulate step. The doc it means is the method's docblock in `engine.ts`. The comment is unedited because this is a move. - Serial check, re-read before `pr_create`: 38 PRs are open. One touches `engine.ts`: #26026 (seat 1's #24768, `claude/project-thread-otcr21` at `50c3929ba`), with hunks at lines 209, 8621-8674, 17122, 17310 and 18743-19275. This PR's range is 5343-5819. `git merge-tree --write-tree` of the two heads is clean (exit 0). Whichever lands second merges `main`. ## Verification Tests at `1b00a88a9`, which has the same package sources as the final head `69d8f91a6` (the second merge touched only `.github/`, `scripts/` and `ui/`): - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` + `check:test-typecheck`, 39 files / 233 errors / 64 pinned signatures held): exit 0. `eslint --no-inline-config packages/objectql`: exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 470 files, 8,748 tests, 8,748 passed, 0 failed, 0 skipped. The same suite was also green before the first merge (`50dae828a`: 470 files, 8,742 tests). The run includes `bulk-write-per-row-hooks.test.ts`, `multi-update-hook-key-divergence.test.ts`, `engine-after-hook-abort-unit.test.ts` and `hook-exclude-objects.test.ts`. - `packages/core` `row-serving-door-exposure.pin.test.ts` and `second-object-read-exposure.pin.test.ts` (both scan the repository for read doors): 2 files, 7 tests passed. - Dependants: every changed member keeps its declaration and all six published `.d.ts` files are byte-identical, so no dependant can see a difference. CI's `TypeScript Type Check` covers them. - Gates at the final head `69d8f91a6`: `dispatch-gates --commands` derived 56 families on the real diff. Every one was run, each exit code captured before any pipe, then reconciled with `--ran`: 56 accounted for, 55 run, 1 NOT MEASURED. **NOT MEASURED** (CI runs it): `check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET. It needs every package's `dist/`; the export lists above answer it for objectql. Named readings: `check-system-context-census` OK (151 read sites, 144 of 144 symbols cited), `check:logger-receiver-detach` OK, `check:durability-log-level` OK, `check-engine-split-ratio --days 90` 97.6%. - Hand gates outside the derivation, all exit 0 at `69d8f91a6`: `check-adr-anchors` at base `aa2f8e31f` and at head (88 files and 1,878 letters at both ends), `check-adr-symbol-anchors` (2,658 anchors resolve), `check-tenant-audit-census` (243 write call sites), `check-nul-bytes`, `check-platform-checklist --changed-from origin/main` (13 anchors judged, 0 lost) and `check-commit-card-trailers --range origin/main..HEAD`. - No ablation, because no tested behaviour changed. `test:repo` was not run locally; CI's `Test Core` runs it. `skip-changeset`: move-only. The published `.d.ts` files and export lists are byte-identical, `liveness/` and every other published path are untouched, and the module's runtime is the same code under a different file name. --- _Generated by [Claude Code](https://claude.ai/code/session_01VbCrrAuovPD2joHGR1XXRZ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d284ff3 commit 3380671

2 files changed

Lines changed: 269 additions & 146 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

Comments
 (0)