Skip to content

Commit dbfb52a

Browse files
refactor(objectql): move execute and the transaction-routing bodies out of engine.ts into engine-execute-transactions.ts (#25522 cut 7) (#26160)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XWy2LjftBRjdNJSLpCUia4o)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 7 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6417034209`. Three bodies of the "execute and transactions" section move unedited into a new module, `packages/objectql/src/engine-execute-transactions.ts`: `execute`, `warnTransactionUnsupported` and `enforceTransactionOrigin`. No behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-tsjhtm` (the card thread's harness-pinned branch), cut from `origin/main` at `a6e8f95d6` (after cut 6, `3380671aef`), with `main` merged once at `d1ea2363f` because #26026 (which edits `engine.ts`) landed during the cut and this PR lands second. #26026's hunks sit in Autonumber, `insert` and `update`; none is in this PR's range. ## Why this area (the fresh seam table) Measured on `origin/main` `d1ea2363f` (the merged base; a first reading on `a6e8f95d6` picked the same area). `engine.ts` is 23,241 lines, 7,344 of them (31.6%) from the last 30 days (`git blame -s --since=2026-09-26T13:22Z`). `scripts/pm/git-history.mjs count` (window covered, exit 0): 125 commits in 30 days, 32 in 7. `class ObjectQL` holds 318 members, 255 methods plus the constructor. 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 | |---|---|---|---|---|---|---|---|---| | 4755-4980 | Instance state and constructor | `txStore` … `constructor` | 226 | 50 | 0 (0 public) | - | 0 | - | | 4981-5003 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 5004-5347 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5348-5703 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 356 | 61 | 7 (0 public) | - | 0 | - | | 5704-5864 | Action registry and activation | `registerAction` … `setActionActive` | 161 | 19 | 10 (10 public) | - | 1 | - | | 5865-6146 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6147-6900 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 546 | 15 (2 public) | - | 0 | - | | 6901-7007 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 7008-7243 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 236 | 37 | 7 (0 public) | - | 0 | - | | 7244-7574 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7575-7784 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7785-8150 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 8151-8763 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 613 | 80 | 6 (0 public) | - | 1 | - | | 8764-9428 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9429-9637 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9638-9789 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9790-9888 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 9889-10981 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1093 | 429 | 12 (0 public) | - | 0 | - | | 10982-11097 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 11098-11192 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 11193-11612 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 11613-11681 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 11682-11947 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 11948-12115 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 12116-12189 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 12190-12872 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 12873-12895 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 12896-12914 | 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 | | 12915-13365 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 13366-13619 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 13620-14063 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 444 | 310 | 5 (0 public) | - | 0 | - | | 14064-14542 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 272 | 9 (0 public) | - | 0 | - | | 14543-15099 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 15100-15757 | validate | `validate` … `validate` | 658 | 447 | 1 (1 public) | - | 0 | - | | 15758-17104 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1347 | 439 | 3 (2 public) | - | 1 | - | | 17105-19038 | update | `update` … `update` | 1934 | 275 | 1 (1 public) | - | 0 | - | | 19039-20579 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 20580-21149 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 21150-21769 | count / aggregate | `count` … `aggregate` | 620 | 236 | 3 (2 public) | - | 0 | - | | 21770-22157 | execute and transactions | `execute` … `enforceTransactionOrigin` | 388 | 63 | 8 (2 public) | - | 0 | - | | 22158-22523 | Compatibility API | `registerObject` … `create` | 366 | 41 | 16 (16 public) | - | 0 | - | The per-method move screen for every section, and the pricing below, are in the project file `pm-orders/server/25522-cut7-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/**`, a `this.update(` / `this.insert(` / `this.delete(` self-call, or a static. Bodies earlier cuts moved are counted as stubs, not as movable. Pricing (net `engine.ts` lines): a stub costs a comment, the host literal's open and close and the `.call`, plus two lines per field read (a `read*` closure and a getter) and one per method read (a late-bound closure); the import adds three. | area | 30-day lines | paying bodies | net | why not / why | |---|---|---|---|---| | Compatibility API | 41 of 366 | `syncSchemas` 63, `syncObjectSchema` 17, `createContext` 8, `registerObject` 9 | about -69 | pays slightly less, and `syncSchemas` holds 35 of the section's 41 recent lines; every body is public API | | Hook and function registry | 6 of 344 | 13 bodies of 1 to 14 lines; five setters write fields | about 0 | does not pay | | Migration flags and attestations | 81 of 683 | 176 body lines | - | five fields written, so the host needs setters (no cut has used them); `recordObservedDeviation` and `retractCreationAttestation` self-call `this.update(` | | Autonumber | 80 of 612 | 101 body lines | - | #26026 edited `createWithAutonumberResync` in this section while the cut ran | | Roll-up summaries, Datasource registry, Related-record expansion | 0 / 1 / 10 | 46 / 27 / 0 | - | as cuts 5 and 6 measured: spec-cited `buildSummaryIndex` and field writes; U = 3; private static `MAX_EXPAND_DEPTH` | | Driver options, File-reference hydration, Referential integrity, Readable-ids, validate, find | 142 to 546 | 161 to 469 | - | the hottest sections; the card says take the coldest | | **execute and transactions** (this PR) | **63 of 388** | **3 of 8 bodies, 106 body lines** | **-76 measured** | pays most among the cold sections; the three moved bodies carry 16 recent lines; one field-free host, no setter; no `packages/spec` pointer | What stays in the section: `transaction` (ADR-0119 cites it as `engine.ts#transaction`, and ADR-0067's join lives in it), and `runOpenedUnit` (4 body lines), `isClosedUnitHandle` (3), `newTransactionScope` (5) and `datasourceNameOf` (4), whose stubs would cost as much as their bodies. ## The shape (#23050 shape B, as cuts 1 to 6 apply it) - The class keeps all three methods with name, modifiers, signature, docblock and position. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25522 cut 7]`. `execute` stays public with its declared return type (a `Promise` of `any`), so it is not an inferred-return public member. - `ExecuteTransactionsHost` is new: `drivers`, `defaultDriver`, `txStore`, `transactionUnsupportedReported`, `logger`, `getDriver`, `datasourceNameOf`, `isSystemLedgerObject`, each typed as the class member (`ObjectQL['txStore']`). 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. - The five fields go as GETTERS (`registerDriver` replaces the driver map's contents and the default), so each read happens when the body makes it. The three methods go as late-bound closures over the instance, so a double or spy set on the engine still intercepts. - `warnTransactionUnsupported` keeps its default parameter (`declaredUnsupported = false`) on both the method and the function; the stub passes the value it received. - **Imports.** The module imports `ObjectQL` as a TYPE only and nothing re-exports it. `engine.ts` drops `CrossDatasourceTransactionWriteError` from its `transaction-errors.js` import, because its last code reader moved; three docblocks that stay still name it in a `{@link}` (on `planCascadeAtomicity`, `transaction` and `enforceTransactionOrigin`); their text is unchanged, so the published `.d.ts`, which prints the public `transaction` docblock, is byte-identical (measured below). `redactPropagatedDriverFault` and `TransactionUnsupportedError` still have readers in `engine.ts` and stay. The module-scope `ScopedContext` helper's call `engine.warnTransactionUnsupported?.(…)` still reaches the method on the class. ## Net lines (the seat's rule) `engine.ts` goes from 23,241 to 23,165 lines, **net -76** (31 added, 107 removed). The module is 199 lines: the header and host interface (about 66), three one-line docblocks, and the 106 moved body lines with their signatures. The diff is 346 changed lines over 3 files (+239 / -107), 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 `d1ea2363f` and the new module removed (tree only; restored with `git checkout HEAD --`, blob hashes compared, `git status` clean). Head: built at `afe19760a`, then rebuilt after the base leg and compared again. The head `dist/index.mjs` carries `execute.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 control:** removing `private` from `enforceTransactionOrigin` in the head source and rebuilding (at `b481820cd`; the edit's landing checked by occurrence count, 1 private / 0 public before, 0 / 1 after) changed all six hashes, and the chunk name with them (`util-CcX3xT6f.d.ts` `f3f2303e…7731d`, `index.d.ts` `9bd53e88…67ce`). Restored with `git checkout HEAD --` and blob hash compared. 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 against `a6e8f95d6` (before the merge) was also byte-identical. ## Proof of move-only A TypeScript-AST leaf-token compare (recursive `getChildren`, JSDoc skipped) of base `engine.ts` (`d1ea2363f`) against the head module: - For each of the three, the parameters (less `this`, default value included), 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: `execute` 263 tokens / 55 lines, `warnTransactionUnsupported` 89 / 26, `enforceTransactionOrigin` 142 / 25. - **Negative controls:** flipping `===` to `!==` in the moved `execute` (`params === undefined`) reports DIFFER; editing only one comment in the moved `enforceTransactionOrigin` fails the line rule. Both exit 1, and the module was restored from `HEAD` and blob hash compared after each. - Class: 318 members at both ends, in the same order. 315 are byte-identical (full text, docblocks included), and the 3 stubs keep docblock, modifiers and signature byte-identical up to the body. - Top level: 228 of 230 base statements are byte-identical in head. The other two are the class and the narrowed `transaction-errors.js` import. Head adds the new module import with its comment. ## Readers of the moved lines - **ADR anchor (migrated in this PR).** `scripts/adr-anchors/packages__objectql__src__engine.ts.json` carries one paragraph, the transaction seam, and it describes `enforceTransactionOrigin` (plus `buildDriverOptions` and `TransactionScope`, which stay). Per the card, the new module gets its own entry carrying that paragraph verbatim: `scripts/adr-anchors/packages__objectql__src__engine-execute-transactions.ts.json`, ADR-0057, ADR-0067 and ADR-0119, each named in the module (the moved `enforceTransactionOrigin` body cites ADR-0057 §3.6, and the module header names all three). This is a data row the existing `check-adr-anchors` reads (one JSON file per anchored path, no index to register), not a new gate; the engine entry stays as it is, because all three ids and the stubs it describes remain in `engine.ts`. `check-adr-anchors` at base `d1ea2363f`: 88 anchored files, 1,879 decision-letter citations; at head: 89 anchored files, 1,879 decision-letter citations (also 88 / 1,879 at `a6e8f95d6`). The moved bodies cite no decision letter, and the module header uses none. - No `engine.ts#NAME` pointer anywhere names the three. `docs/adr/0096` names `engine.ts` beside `engine.execute()` (file-level); the method is still on the class, so it still resolves. - No gate or source-reading test matches the moved text: every `scripts/` reader of `engine.ts` was screened, and the 32 test files that read `engine.ts` from disk. The two `packages/core` door pins and the `packages/types` caller pin (which walk every `.ts` file) were run and are green. - `packages/spec/liveness`: 46 evidence strings cite `engine.ts`, and none quotes text from the moved bodies (screened for their identifiers and refusal strings). - **Census and receivers.** `check-tenant-audit-census` is green (243 write call sites; the moved bodies hold no `this.FIELD.delete/insert/update(` site). `check:logger-receiver-detach` is green (two moved bodies read `logger` through a getter, so the receiver is kept), and so are `check-system-context-census` and `check:durability-log-level`. ## Acceptance notes - Prose that names `engine.ts` beside a moved method still resolves by method name, because every method stays on the class (for example `transaction-errors.ts` naming `enforceTransactionOrigin`). Left as it is. - The engine anchor paragraph now sits on two files, each describing the half it holds; the paragraph is carried verbatim, as the card asks, rather than split. - Serial check, re-read before `pr_create`: 36 PRs are open; the only one on `packages/objectql/**` is #26132 (`hook-run-as.test.ts` only). #26026 landed during the cut and is merged in. `origin/main` moved five commits after the merge, none on `packages/objectql/**` or `scripts/adr-anchors/**`, and `dispatch-gates` printed no stale-tree warning, so it was not merged again. - Next cut from this screen: Compatibility API (about -69, with `syncSchemas` hot); nothing else pays without a ruling (Migration flags needs setters). ## Verification At `afe19760a` (the merged head): - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` + `check:test-typecheck`, 39 files / 233 errors / 64 pinned signatures held): VERDICT command-exit 0. `pnpm exec eslint --no-inline-config packages/objectql`: exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 471 files, 8,774 tests passed. Before the merge, at `b481820cd`: 470 files, 8,748 passed. The run includes `engine-transaction-contract.test.ts`, `engine-transaction-declared-unsupported.test.ts`, `engine-transaction-observability.test.ts`, `engine-transaction-same-origin.test.ts` and `engine-scoped-transaction-late-write.test.ts`. - `packages/core` `row-serving-door-exposure.pin.test.ts` + `second-object-read-exposure.pin.test.ts`: 2 files, 7 tests passed; `packages/types` `driver-error-classification.callers.test.ts`: 11 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 `afe19760a`: `dispatch-gates --commands` derived 66 families on the real diff. Every one was run, each exit code captured before any pipe, then reconciled with `--ran`: 66 accounted for, 65 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 (3,733 files, 0 detaches), `check:durability-log-level` exit 0, `check-engine-split-ratio --days 90` 97.6%. - Hand gates outside the derivation, all exit 0 at `afe19760a`: `check-adr-anchors` at base and head (above), `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_017XKjAUHgJooyUifvSi4RjW)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d9d3ef commit dbfb52a

3 files changed

Lines changed: 239 additions & 107 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 7] The raw-command and transaction-routing bodies of `ObjectQL`,
5+
* moved out of `engine.ts` whole: `execute` (the driver a raw command goes to,
6+
* its two call shapes, and the redaction of a driver fault it propagates),
7+
* `warnTransactionUnsupported` (the once-per-driver report of ADR-0119's
8+
* declared degrade) and `enforceTransactionOrigin` (what a write routed to a
9+
* driver the open transaction does not cover gets: refused, or, for an
10+
* append-only system ledger under ADR-0057, executed outside it).
11+
*
12+
* A MOVE, not a redesign. Each exported function below is the body of the
13+
* `ObjectQL` method of the same name as it stood, re-indented one level and
14+
* otherwise unedited: the same driver-selection order, the same two refusals
15+
* before any command is sent, the same once-per-name warning and its two
16+
* causes, the same refusal before hooks run and the same once-per-transaction
17+
* debug line. Each method's docblock stays on the method in `engine.ts`, which
18+
* is still the contract.
19+
*
20+
* What stays in the class: `transaction`, because ADR-0119 cites it by
21+
* `engine.ts#transaction` and ADR-0067's join lives in it; `runOpenedUnit`,
22+
* `isClosedUnitHandle`, `newTransactionScope` and `datasourceNameOf`, whose
23+
* bodies are shorter than the host literal moving them would cost.
24+
*
25+
* ## How the moved bodies reach the class
26+
*
27+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
28+
* header carries the full reasoning), applied per method as
29+
* `engine-hook-dispatch.ts` applies it. The class keeps all three methods —
30+
* the published `.d.ts` lists each one's name and position, and `execute` is
31+
* public — and each body is one literal of exactly the
32+
* {@link ExecuteTransactionsHost} members that method reads, plus a `.call`.
33+
* The fields the bodies read (`drivers`, `defaultDriver`, `txStore`,
34+
* `transactionUnsupportedReported`, `logger`) go as GETTERS, so each read
35+
* happens when the body makes it: `registerDriver` changes the driver map and
36+
* the default, and a body sees whatever is installed at the moment it reads,
37+
* as it did. The methods they call (`getDriver`, `datasourceNameOf`,
38+
* `isSystemLedgerObject`) go as late-bound closures over the instance, so a
39+
* double or spy set on the engine is still the one a call reaches.
40+
*
41+
* This module imports the engine only as a TYPE, so there is no module cycle
42+
* at run time, and nothing re-exports it, so the published surface does not
43+
* move.
44+
*/
45+
46+
import type { IDataDriver } from '@objectstack/core';
47+
import { redactPropagatedDriverFault } from '@objectstack/types';
48+
import { CrossDatasourceTransactionWriteError } from './transaction-errors.js';
49+
import type { ObjectQL } from './engine.js';
50+
51+
/**
52+
* The `ObjectQL` members the functions below read — every one of them, and no
53+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
54+
* typed AS the class member (an indexed access on the class), so a change to a
55+
* member's type on the class is a change here too, and naming a member the
56+
* class does not have is a compile error. See the module header for which
57+
* members are getters.
58+
*/
59+
export interface ExecuteTransactionsHost {
60+
readonly drivers: ObjectQL['drivers'];
61+
readonly defaultDriver: ObjectQL['defaultDriver'];
62+
readonly txStore: ObjectQL['txStore'];
63+
readonly transactionUnsupportedReported: ObjectQL['transactionUnsupportedReported'];
64+
readonly logger: ObjectQL['logger'];
65+
getDriver: ObjectQL['getDriver'];
66+
datasourceNameOf: ObjectQL['datasourceNameOf'];
67+
isSystemLedgerObject: ObjectQL['isSystemLedgerObject'];
68+
}
69+
70+
/** The body of `ObjectQL#execute`; the method's docblock there is its contract. */
71+
export async function execute(
72+
this: Pick<ExecuteTransactionsHost, 'getDriver' | 'drivers' | 'defaultDriver'>,
73+
command: any,
74+
options?: Record<string, any>,
75+
): Promise<any> {
76+
// Driver selection priority:
77+
// 1. options.object → route via getDriver(objectName)
78+
// 2. options.datasource → explicit driver name
79+
// 3. default driver (set via datasourceMapping or defaultDriver)
80+
// This lets system services (e.g. PackageService, AuditService) issue raw
81+
// SQL against the control-plane / default DB without having to know the
82+
// object name behind every CREATE TABLE / SELECT statement.
83+
let driver: IDataDriver | undefined;
84+
if (options?.object) {
85+
driver = this.getDriver(options.object);
86+
} else if (options?.datasource && this.drivers.has(options.datasource)) {
87+
driver = this.drivers.get(options.datasource);
88+
} else if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
89+
driver = this.drivers.get(this.defaultDriver);
90+
} else if (this.drivers.size === 1) {
91+
// Single registered driver — unambiguously the right one.
92+
driver = this.drivers.values().next().value;
93+
}
94+
95+
if (!driver) {
96+
throw new Error(
97+
'Execute requires options.object to select a driver, or a default driver to be configured. ' +
98+
'Configure datasourceMapping with `default: true` or pass `{ object }` / `{ datasource }` in options.',
99+
);
100+
}
101+
if (!driver.execute) {
102+
throw new Error('Selected driver does not implement execute()');
103+
}
104+
105+
// Support both call shapes:
106+
// execute('SELECT ...', { args: [...] })
107+
// execute({ sql: 'SELECT ...', args: [...] })
108+
let rawCommand: any = command;
109+
let params: any[] | undefined = options?.args ?? options?.params;
110+
if (command && typeof command === 'object' && !Array.isArray(command) && 'sql' in command) {
111+
rawCommand = command.sql;
112+
if (params === undefined) {
113+
params = command.args ?? command.params;
114+
}
115+
}
116+
117+
// [#21274] A boundary outside the middleware seam: the raw command's
118+
// bound `params` are the caller's values, and the driver's refusal
119+
// carries the dialect error on its `cause`.
120+
//
121+
// [#21345] And the one door whose statement the engine did not compose:
122+
// it may open with a word the shared leak predicate does not list (a
123+
// common-table-expression form, a dialect's upsert or merge verb), so
124+
// the cut must not wait for the predicate to recognise it. The door
125+
// knows it sent a statement, and says so.
126+
try {
127+
return await driver.execute(rawCommand, params, options);
128+
} catch (e) {
129+
throw redactPropagatedDriverFault(e, { statementSent: true });
130+
}
131+
}
132+
133+
/** The body of `ObjectQL#warnTransactionUnsupported`; the method's docblock there is its contract. */
134+
export function warnTransactionUnsupported(
135+
this: Pick<ExecuteTransactionsHost, 'transactionUnsupportedReported' | 'logger'>,
136+
datasource: string | undefined,
137+
declaredUnsupported = false,
138+
): void {
139+
const name = datasource ?? '<no default datasource>';
140+
if (this.transactionUnsupportedReported.has(name)) return;
141+
this.transactionUnsupportedReported.add(name);
142+
// [#18063] The two reasons reach the same degrade and must not read the
143+
// same. Telling an operator a Turso REMOTE datasource "has no
144+
// beginTransaction" sends them looking for a missing method on a class that
145+
// publishes one; the fix for that reason is a different transport, not a
146+
// different driver.
147+
const cause = declaredUnsupported
148+
? `driver '${name}' declares supports.transactionsUnsupported — its transport cannot carry a ` +
149+
'transaction handle even though it inherits beginTransaction'
150+
: `driver '${name}' has no beginTransaction`;
151+
const remedy = declaredUnsupported
152+
? 'Point this datasource at a transport that honours transactions (for libSQL: the local or ' +
153+
'embedded-replica mode rather than the remote one), or have the caller fail '
154+
: 'Register a driver that implements beginTransaction for this datasource, or have the caller fail ';
155+
this.logger.warn(
156+
`transaction() requested a transaction but ${cause} — ` +
157+
'running WITHOUT transaction or rollback. Every write the callback makes commits as it executes, ' +
158+
'so a later throw leaves the earlier ones PERSISTED even though the call rejects as if the whole ' +
159+
'unit of work had been undone; no caller is told, and the records stay behind. ' +
160+
remedy +
161+
"closed itself when it cannot tolerate losing atomicity (batchData's atomic gate, ADR-0119 D4, is " +
162+
'the pattern). Reported once per driver per engine instance.',
163+
{ datasource: name, declaredUnsupported },
164+
);
165+
}
166+
167+
/** The body of `ObjectQL#enforceTransactionOrigin`; the method's docblock there is its contract. */
168+
export function enforceTransactionOrigin(
169+
this: Pick<ExecuteTransactionsHost, 'txStore' | 'datasourceNameOf' | 'isSystemLedgerObject' | 'logger'>,
170+
objectName: string,
171+
driver: IDataDriver,
172+
operation: 'insert' | 'update' | 'delete',
173+
): void {
174+
const scope = this.txStore.getStore()?.scope;
175+
// No engine-owned transaction in scope (or a handle threaded explicitly by
176+
// the sandbox trio, which this store never sees) — nothing to be outside
177+
// of. See `transactionCoversDriverFor` for why that limit is declared.
178+
if (!scope) return;
179+
// Identity, not name: this is about riding the same connection.
180+
if (driver === scope.driver) return;
181+
const target = this.datasourceNameOf(driver);
182+
183+
if (!this.isSystemLedgerObject(objectName)) {
184+
throw new CrossDatasourceTransactionWriteError(objectName, operation, target, scope.datasource);
185+
}
186+
187+
if (scope.reportedOutOfScope.has(target)) return;
188+
scope.reportedOutOfScope.add(target);
189+
this.logger.debug(
190+
`${operation} of '${objectName}' inside transaction() is routed to datasource '${target}' while the ` +
191+
`transaction is open on '${scope.datasource}' — executing it OUTSIDE the transaction, on its own ` +
192+
'connection (ADR-0057 §3.6 system ledger — the one class carved out of the cross-datasource write ' +
193+
'refusal). It commits independently and will ' +
194+
'SURVIVE a rollback of this transaction: an audit/telemetry/event row may describe a write that was ' +
195+
'undone. That is the decided direction of error for an append-only ledger — an extra reconcilable ' +
196+
'row beats a missing row for a write that did commit. Said once per transaction per datasource.',
197+
{ object: objectName, operation, datasource: target, transactionDatasource: scope.datasource },
198+
);
199+
}

‎packages/objectql/src/engine.ts‎

Lines changed: 31 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ import {
133133
dispatchPerRowBeforeHooks,
134134
inAfterAbortUnit,
135135
} from './engine-hook-dispatch.js';
136+
// [#25522 cut 7] The raw-command and transaction-routing bodies; the methods of
137+
// the same names below hand each body the members it reads (`ExecuteTransactionsHost`).
138+
import { enforceTransactionOrigin, execute, warnTransactionUnsupported } from './engine-execute-transactions.js';
136139
import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel';
137140
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
138141
import type { FlowFunctionEffect } from '@objectstack/spec/automation';
@@ -184,7 +187,7 @@ import {
184187
import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js';
185188
import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js';
186189
import { SummaryRecomputeError, type SummaryRecomputeFailure } from './summary-errors.js';
187-
import { CrossDatasourceTransactionWriteError, TransactionUnsupportedError } from './transaction-errors.js';
190+
import { TransactionUnsupportedError } from './transaction-errors.js';
188191
import {
189192
aggregateSummaryValue,
190193
summaryEmptySetValue,
@@ -21780,61 +21783,15 @@ export class ObjectQL implements IObjectQLEngine {
2178021783
* whenever feasible — they auto-apply tenancy + soft-delete + audit warnings.
2178121784
*/
2178221785
async execute(command: any, options?: Record<string, any>): Promise<any> {
21783-
// Driver selection priority:
21784-
// 1. options.object → route via getDriver(objectName)
21785-
// 2. options.datasource → explicit driver name
21786-
// 3. default driver (set via datasourceMapping or defaultDriver)
21787-
// This lets system services (e.g. PackageService, AuditService) issue raw
21788-
// SQL against the control-plane / default DB without having to know the
21789-
// object name behind every CREATE TABLE / SELECT statement.
21790-
let driver: IDataDriver | undefined;
21791-
if (options?.object) {
21792-
driver = this.getDriver(options.object);
21793-
} else if (options?.datasource && this.drivers.has(options.datasource)) {
21794-
driver = this.drivers.get(options.datasource);
21795-
} else if (this.defaultDriver && this.drivers.has(this.defaultDriver)) {
21796-
driver = this.drivers.get(this.defaultDriver);
21797-
} else if (this.drivers.size === 1) {
21798-
// Single registered driver — unambiguously the right one.
21799-
driver = this.drivers.values().next().value;
21800-
}
21801-
21802-
if (!driver) {
21803-
throw new Error(
21804-
'Execute requires options.object to select a driver, or a default driver to be configured. ' +
21805-
'Configure datasourceMapping with `default: true` or pass `{ object }` / `{ datasource }` in options.',
21806-
);
21807-
}
21808-
if (!driver.execute) {
21809-
throw new Error('Selected driver does not implement execute()');
21810-
}
21811-
21812-
// Support both call shapes:
21813-
// execute('SELECT ...', { args: [...] })
21814-
// execute({ sql: 'SELECT ...', args: [...] })
21815-
let rawCommand: any = command;
21816-
let params: any[] | undefined = options?.args ?? options?.params;
21817-
if (command && typeof command === 'object' && !Array.isArray(command) && 'sql' in command) {
21818-
rawCommand = command.sql;
21819-
if (params === undefined) {
21820-
params = command.args ?? command.params;
21821-
}
21822-
}
21823-
21824-
// [#21274] A boundary outside the middleware seam: the raw command's
21825-
// bound `params` are the caller's values, and the driver's refusal
21826-
// carries the dialect error on its `cause`.
21827-
//
21828-
// [#21345] And the one door whose statement the engine did not compose:
21829-
// it may open with a word the shared leak predicate does not list (a
21830-
// common-table-expression form, a dialect's upsert or merge verb), so
21831-
// the cut must not wait for the predicate to recognise it. The door
21832-
// knows it sent a statement, and says so.
21833-
try {
21834-
return await driver.execute(rawCommand, params, options);
21835-
} catch (e) {
21836-
throw redactPropagatedDriverFault(e, { statementSent: true });
21837-
}
21786+
// [#25522 cut 7] The body moved to `engine-execute-transactions.ts` (see `ExecuteTransactionsHost`).
21787+
const readDrivers = () => this.drivers;
21788+
const readDefaultDriver = () => this.defaultDriver;
21789+
const host: ThisParameterType<typeof execute> = {
21790+
getDriver: (...a) => this.getDriver(...a),
21791+
get drivers() { return readDrivers(); },
21792+
get defaultDriver() { return readDefaultDriver(); },
21793+
};
21794+
return execute.call(host, command, options);
2183821795
}
2183921796

2184021797
/**
@@ -22051,32 +22008,14 @@ export class ObjectQL implements IObjectQLEngine {
2205122008
* doubles, foreign engines) reach it on EVERY call.
2205222009
*/
2205322010
private warnTransactionUnsupported(datasource: string | undefined, declaredUnsupported = false): void {
22054-
const name = datasource ?? '<no default datasource>';
22055-
if (this.transactionUnsupportedReported.has(name)) return;
22056-
this.transactionUnsupportedReported.add(name);
22057-
// [#18063] The two reasons reach the same degrade and must not read the
22058-
// same. Telling an operator a Turso REMOTE datasource "has no
22059-
// beginTransaction" sends them looking for a missing method on a class that
22060-
// publishes one; the fix for that reason is a different transport, not a
22061-
// different driver.
22062-
const cause = declaredUnsupported
22063-
? `driver '${name}' declares supports.transactionsUnsupported — its transport cannot carry a ` +
22064-
'transaction handle even though it inherits beginTransaction'
22065-
: `driver '${name}' has no beginTransaction`;
22066-
const remedy = declaredUnsupported
22067-
? 'Point this datasource at a transport that honours transactions (for libSQL: the local or ' +
22068-
'embedded-replica mode rather than the remote one), or have the caller fail '
22069-
: 'Register a driver that implements beginTransaction for this datasource, or have the caller fail ';
22070-
this.logger.warn(
22071-
`transaction() requested a transaction but ${cause} — ` +
22072-
'running WITHOUT transaction or rollback. Every write the callback makes commits as it executes, ' +
22073-
'so a later throw leaves the earlier ones PERSISTED even though the call rejects as if the whole ' +
22074-
'unit of work had been undone; no caller is told, and the records stay behind. ' +
22075-
remedy +
22076-
"closed itself when it cannot tolerate losing atomicity (batchData's atomic gate, ADR-0119 D4, is " +
22077-
'the pattern). Reported once per driver per engine instance.',
22078-
{ datasource: name, declaredUnsupported },
22079-
);
22011+
// [#25522 cut 7] The body moved to `engine-execute-transactions.ts` (see `ExecuteTransactionsHost`).
22012+
const readTransactionUnsupportedReported = () => this.transactionUnsupportedReported;
22013+
const readLogger = () => this.logger;
22014+
const host: ThisParameterType<typeof warnTransactionUnsupported> = {
22015+
get transactionUnsupportedReported() { return readTransactionUnsupportedReported(); },
22016+
get logger() { return readLogger(); },
22017+
};
22018+
return warnTransactionUnsupported.call(host, datasource, declaredUnsupported);
2208022019
}
2208122020

2208222021
/**
@@ -22128,31 +22067,16 @@ export class ObjectQL implements IObjectQLEngine {
2212822067
driver: IDataDriver,
2212922068
operation: 'insert' | 'update' | 'delete',
2213022069
): void {
22131-
const scope = this.txStore.getStore()?.scope;
22132-
// No engine-owned transaction in scope (or a handle threaded explicitly by
22133-
// the sandbox trio, which this store never sees) — nothing to be outside
22134-
// of. See `transactionCoversDriverFor` for why that limit is declared.
22135-
if (!scope) return;
22136-
// Identity, not name: this is about riding the same connection.
22137-
if (driver === scope.driver) return;
22138-
const target = this.datasourceNameOf(driver);
22139-
22140-
if (!this.isSystemLedgerObject(objectName)) {
22141-
throw new CrossDatasourceTransactionWriteError(objectName, operation, target, scope.datasource);
22142-
}
22143-
22144-
if (scope.reportedOutOfScope.has(target)) return;
22145-
scope.reportedOutOfScope.add(target);
22146-
this.logger.debug(
22147-
`${operation} of '${objectName}' inside transaction() is routed to datasource '${target}' while the ` +
22148-
`transaction is open on '${scope.datasource}' — executing it OUTSIDE the transaction, on its own ` +
22149-
'connection (ADR-0057 §3.6 system ledger — the one class carved out of the cross-datasource write ' +
22150-
'refusal). It commits independently and will ' +
22151-
'SURVIVE a rollback of this transaction: an audit/telemetry/event row may describe a write that was ' +
22152-
'undone. That is the decided direction of error for an append-only ledger — an extra reconcilable ' +
22153-
'row beats a missing row for a write that did commit. Said once per transaction per datasource.',
22154-
{ object: objectName, operation, datasource: target, transactionDatasource: scope.datasource },
22155-
);
22070+
// [#25522 cut 7] The body moved to `engine-execute-transactions.ts` (see `ExecuteTransactionsHost`).
22071+
const readTxStore = () => this.txStore;
22072+
const readLogger = () => this.logger;
22073+
const host: ThisParameterType<typeof enforceTransactionOrigin> = {
22074+
get txStore() { return readTxStore(); },
22075+
datasourceNameOf: (...a) => this.datasourceNameOf(...a),
22076+
isSystemLedgerObject: (...a) => this.isSystemLedgerObject(...a),
22077+
get logger() { return readLogger(); },
22078+
};
22079+
return enforceTransactionOrigin.call(host, objectName, driver, operation);
2215622080
}
2215722081

2215822082
// ============================================

0 commit comments

Comments
 (0)