Skip to content

Commit 343ddb2

Browse files
refactor(objectql): facade ObjectRepository and ScopedContext in engine.ts with the codemod's second pass (body-identical; 20/24 methods) (#30264)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgW7JcnJ43AQ7kfgoftnyJAw)_ Fixes #30052 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`scripts/codemod-split-engine.mjs` 加了第二遍 `--scoped`,用同一套切法和证明把 `engine.ts` 里的 `ObjectRepository`、`ScopedContext` 门面化:24 个方法里 20 个方法体原样移进 `engine-object-repository.ts` / `engine-scoped-context.ts`,每个实例一个 host(`engine-scoped-hosts.ts`)。生成物未手改。 - 为什么改:维护者在 #30012 上说「我觉得文件还是太长,还有进一步的拆分优化空间吗」「可以后续立卡处理」「合并之后再继续」;#30012 只门面化了 `ObjectQL`。 - 风险与回滚:只是搬移。20/20 方法体逐字节相同,撤销拆分逐字节还原 base `engine.ts`;公开 `.d.ts` 相等;objectql 测试 base 548 文件 / 10,863 = head;回滚就是 revert。 - 先看:① 下方「留在类上的 4 个方法」表;② `engine-scoped-hosts.ts` 和两个类末尾的 static 块;③ 门禁 `check-facade-delegation-only` 改为一个文件可登记多个 facade 类。 - 两分钟验证:`pnpm install --frozen-lockfile` 后运行 `node scripts/codemod-split-engine.mjs --scoped --verify --base 5fa9375`,应看到 `20/20 moved bodies identical`、`13587 -> 13522 lines`,退出码 0;`--self-test` 86 例通过。 - 需要你做的:改动共 1,553 行(未超 3,000 行线),本 PR 保持草稿,按席位安排落地。 ## What changes - **`scripts/codemod-split-engine.mjs`: a second pass, `--scoped`.** It facades the two classes `engine.ts` declares after `ObjectQL`, with the first pass's cut pattern, stay rules and byte-identity proof, and touches no other byte. - **Input and proof.** The pass reads the `engine.ts` the first pass left (any `main` after #29986) and is proved against that same file, so it is re-run on the latest `main` at landing, never rebased. Its marker is `[#30052 codemod]`, so the two passes' stubs and files are told apart. `--scoped --verify --base <ref>` re-proves the split on disk. - **One facet module per class** (`SCOPED_CLASSES`): `engine-object-repository.ts` (`ObjectRepositoryFacet`) and `engine-scoped-context.ts` (`ScopedContextFacet`), each a stateless class of static methods that are the bodies, unedited, with `this` typed as a `Pick` of the class's host interface (members typed AS the class members). - **One host per instance** (`engine-scoped-hosts.ts`, `InstanceHost<E, H>`): #30051's shape, keyed by the instance instead of the engine. Each class's static block installs its factory; a method goes as a closure called on the instance at call time (spies and overrides are still reached), a field as a getter, and a field a body assigns would also get a setter (no moved body assigns one). The module imports types only, so it forms no evaluation cycle. - **Two new stay reasons.** `foreign-private`: the body reads a private or protected instance member off another instance (legal in the class body, refused in a module function). `private-static`: the body reads a non-public static (the statics route is `ObjectQL`'s; the scoped classes declare none). - **One fix to the `already-moved` rule.** A method was taken for a hand-series stub when an `engine-*` import shares its name. It now also has to read that import. `ObjectRepository#count` and `#execute` share names with the hand-series `count` and `execute` and read neither, so they move. - **The first pass is unchanged.** Run on its base `c2d353d2` with this script, it prints the same output tree sha256 as #30083 (`09c80319…e3554`). - `--self-test`: 86 cases in 7 batteries (was 68 in 6). The new battery "scoped classes (second pass)" (floor 16) covers the stay rules, the proof, determinism, the hosts and the refusals, and feeds the proof inputs it must refuse. - **The generated split.** `--scoped --write` on main `5fa9375e`, unedited. ### What moved | Class | Moved (20) | Stays (4) | |:--|:--|:--| | `ObjectRepository` | all 11: `find`, `findOne`, `insert`, `create`, `update`, `updateById`, `delete`, `deleteById`, `count`, `aggregate`, `execute` | none | | `ScopedContext` | `object`, `sudo`, `withRunAs`, `releaseJoinedHandle`, `txDriver`, `beginTransaction`, `commitTransaction`, `rollbackTransaction`, `serveFormulaField` | see below | Public docblocks stay on the class (ruling 6624345766 item 1). The two private methods' docblocks (`releaseJoinedHandle`, `txDriver`) moved with their bodies. ### Bodies that stay on the class (4, as #30019 exception rows) | Member | Reason | Card | |:--|:--|:--| | `ScopedContext.transaction` (137 lines) | bare `this`: `callback(this, …)` hands the instance on | #30020 | | `ScopedContext.[SEED_USER_GRANTS]` (72) | bare `this` (`return this`), and a symbol-keyed name | #30020 | | `ScopedContext.[APP_AUTHORED_HOOK_API]` (19) | a symbol-keyed name; it also writes `appAuthored` on another instance | #30147 | | `ScopedContext.derive` (6) | `foreign-private`: it writes `child.appAuthored` | #30147 | ### Line counts | File | Before (`5fa9375e`) | After | |:--|--:|--:| | `packages/objectql/src/engine.ts` | 13,587 | 13,522 (−65) | | `engine-object-repository.ts` | — | 152 | | `engine-scoped-context.ts` | — | 174 | | `engine-scoped-hosts.ts` | — | 51 | The two classes were 696 lines. Most of what stays is docblocks on public members (they stay by ruling) and the four bodies above, so `engine.ts` loses 65 lines: 155 body lines out, 80 stub lines in, plus the imports and the two install blocks. ## The #30019 gate (`check-facade-delegation-only`) - The registry was keyed by file, so a second class in `engine.ts` was refused as "registered twice". A `facades` row is now keyed by file and class. An `exceptions` row may name its `class`, and must when its file registers more than one facade. The success line now counts classes and files. - Registry: two `facades` rows (`ObjectRepository`, `ScopedContext`, card #30052), the four exception rows above, and `"class": "ObjectQL"` added to the 30 existing `engine.ts` rows (their content is unchanged). - `--self-test`: 37 cases (floor 36 → 37). New case E10: two facades in one file are each judged; a row without its `class` there, a `class` that is not registered, and one class registered twice each go red. - Gate on this head: `ObjectRepository` 13/13 members are delegations; `ScopedContext` 17 delegations, 2 fields, 4 named exceptions; `5 facade class(es) in 3 file(s) hold delegations only, 92 named exception(s)`. ## Readers of `engine.ts` by path Each one was run on this head and passed. None needed a change, because no moved body carries a site any of them counts: - objectql `federated-injected-column-readers.test.ts` (in the suite below). - core `second-object-read-exposure.pin.test.ts` and `row-serving-door-exposure.pin.test.ts` (7/7). - `check-system-context-census`: OK, register unchanged. - eslint `query-options/no-any-erasure` (`pnpm lint`): exit 0. ## Verification All at head `8b84cef8` unless stated. - **Byte-identity.** `node scripts/codemod-split-engine.mjs --scoped --verify --base 5fa9375`: exit 0. 20/20 moved bodies identical (body, parameters, return type, async). Every stub equals the generated one-call stub, the host module and both install blocks are the generated ones, and the undo rebuilds base `engine.ts` byte for byte (0 import bindings pruned). `engine.ts 13587 -> 13522 lines`. - **Determinism.** Two `--scoped --write` runs from base `engine.ts` (sha256 `83dfea29…2d774`), one in a separate worktree, print the same output tree sha256 `3bbd66188e82e6a18a5b9f871a67b052f237e49e2ba2bd6f9d4f4d286d335a66`. Both trees equal the committed head byte for byte. - **`.d.ts`.** `packages/objectql/dist` built at base `5fa9375e` twice and at head twice. Base 1 = base 2 and head 1 = head 2, byte for byte. Base against head: `core` and `index` (`.d.ts` and `.d.mts`) are byte-identical once the shared chunk's content hash in the import specifier is normalized. `util-HASH` has 193/193 statements and 332/332 private members; its public surface (each class's private members removed with their docblocks) is **EQUAL**. The raw difference is the two moved private docblocks, 21 lines. None of the facet, host or `InstanceHost` names appears in any `.d.ts`. Controls: a private member made public is caught, and a public return type changed is caught. - **Suites.** objectql `test`: base 548 files / 10,863 passed, head 548 / 10,863. `test:repo`: 1 file / 5 = 5. objectql `typecheck` (tsc + check:test-typecheck): exit 0. - **Build and lint.** `npx turbo run build --concurrency=3` exit 0. `pnpm lint` exit 0. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 75 commands and each was run. All exit 0. One (`check-plugin-teardown-shape --self-test`) exited 3 on the first sweep because its pinned fixture commit was missing in the shallow clone; it passed after `git fetch origin 621a487…`. `--ran`: 75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN. - **Run by hand.** `codemod-split-engine.mjs --self-test` (86 cases) and `check-facade-delegation-only.mjs --self-test` (37 cases). ## Acceptance notes - **The first pass's `--verify --base c2d353d` no longer holds on `main`, and that predates this PR.** #30151, #30179 and #30183 edited the split files by hand after #30083. On `main` `5fa9375e` it already fails, at `homeAmountReadsFor` and `ENGINE_DRIVER_PASSTHROUGH_KEYS`. That is why the second pass proves against the `engine.ts` it reads and does not regenerate the first pass. Re-proving the first pass on today's `main` would need its own card. Carrier: the seat. - A changeset (`patch`, `@objectstack/objectql`) is included, following AGENTS.md checklist item 3 for a change to a released package. #30012 and #30083 shipped `skip-changeset` instead. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (the designated thread branch, as the Claim records) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6Cbo4rDxRL3yugqM5c7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 15f808a commit 343ddb2

8 files changed

Lines changed: 1380 additions & 173 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
refactor(objectql): `ObjectRepository` and `ScopedContext` in `engine.ts` are facades over their own facet modules, moved by the `engine.ts` codemod's second pass; no behaviour or published declaration changes
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- 20 of the 24 methods of the two classes keep their signatures and public docblocks on the class and forward, in one call, to an unedited copy of their body in `engine-object-repository.ts` or `engine-scoped-context.ts`. Each instance gets one host, built on first use (`engine-scoped-hosts.ts`), so a spy or a subclass override on an instance is still the method a body reaches.
11+
- Four `ScopedContext` methods keep their bodies on the class, each named in the facade gate's registry with the card that will move it: `transaction` and the symbol-keyed `SEED_USER_GRANTS` method hand the instance itself on; `derive` and the symbol-keyed `APP_AUTHORED_HOOK_API` method set a private flag on another instance.
12+
- Nothing to do on upgrade: the exported names, the published `.d.ts` surface and what every method does are the same.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30052 codemod] The "Repository bound to one object and one execution
5+
* context" section: the bodies of `ObjectRepository`'s methods `find`,
6+
* `findOne`, `insert`, `create`, `update`, `updateById`, `delete`,
7+
* `deleteById`, `count`, `aggregate`, `execute` moved out of `engine.ts` whole
8+
* by `scripts/codemod-split-engine.mjs` (its second pass).
9+
*
10+
* A MOVE, not a redesign. {@link ObjectRepositoryFacet} is a stateless facet of
11+
* `ObjectRepository`: each static method of it is the body of the
12+
* `ObjectRepository` method of the same name exactly as it stood: not
13+
* re-indented, not formatted, not edited. The method stays on the class as a
14+
* one-call stub with its signature, so the published surface, an instance spy
15+
* and a subclass override are what they were. A public method keeps its
16+
* docblock on the class, the contract; a private method's docblock moved here
17+
* with its body. The state stays on `ObjectRepository`.
18+
*
19+
* The bodies reach the instance through {@link ObjectRepositoryHost}: exactly
20+
* the `ObjectRepository` members they read, private ones included. Every stub
21+
* hands its body, by a `.call`, the same host: `objectRepositoryHost` in
22+
* `engine-scoped-hosts.ts`, built once per instance by the class's static
23+
* block. Methods go as closures that call them on the instance at call time;
24+
* fields and accessors go as GETTERS, read when the body reads them, and a
25+
* field a body assigns goes with a SETTER that assigns it on the instance.
26+
*
27+
* Values declared in `engine.ts` are imported from it (lever (a) of
28+
* ruling 6591753301); every use is inside a function body, so the import
29+
* cycle is never read at evaluation time. `index.ts` re-exports nothing from
30+
* this module.
31+
*/
32+
33+
import type { ExecutionContext } from '@objectstack/spec/kernel';
34+
import { markAppAutomationElevation } from '@objectstack/core';
35+
import { ScopedContext } from './engine.js';
36+
import type { ObjectRepository } from './engine.js';
37+
38+
/**
39+
* The `ObjectRepository` members the bodies of {@link ObjectRepositoryFacet}
40+
* read — every one of them, and no other. Each is typed AS the class member (an
41+
* indexed access on the class), so a change to a member's signature on the
42+
* class is a change here too. `readonly` members are the getters; the others
43+
* are fields a body assigns.
44+
*/
45+
export interface ObjectRepositoryHost {
46+
readonly context: ObjectRepository['context'];
47+
readonly engine: ObjectRepository['engine'];
48+
insert: ObjectRepository['insert'];
49+
readonly objectName: ObjectRepository['objectName'];
50+
}
51+
52+
/** The bodies of `ObjectRepository`'s methods: a stateless facet, called by the class's stubs. */
53+
export class ObjectRepositoryFacet {
54+
/** The body of `ObjectRepository#find`; the method's docblock there is its contract. */
55+
static async find(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<any[]> {
56+
return this.engine.find(this.objectName, {
57+
...query,
58+
context: this.context,
59+
});
60+
}
61+
62+
/** The body of `ObjectRepository#findOne`; the method's docblock there is its contract. */
63+
static async findOne(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<Record<string, any> | null> {
64+
return this.engine.findOne(this.objectName, {
65+
...query,
66+
context: this.context,
67+
});
68+
}
69+
70+
/** The body of `ObjectRepository#insert`; the method's docblock there is its contract. */
71+
static async insert(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, data: any): Promise<any> {
72+
return this.engine.insert(this.objectName, data, {
73+
context: this.context,
74+
});
75+
}
76+
77+
/** The body of `ObjectRepository#create`; the method's docblock there is its contract. */
78+
static async create(this: Pick<ObjectRepositoryHost, 'insert'>, data: any): Promise<any> {
79+
return this.insert(data);
80+
}
81+
82+
/** The body of `ObjectRepository#update`; the method's docblock there is its contract. */
83+
static async update(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, data: any, options: any = {}): Promise<Record<string, any> | number | null> {
84+
return this.engine.update(this.objectName, data, {
85+
...options,
86+
context: this.context,
87+
});
88+
}
89+
90+
/** The body of `ObjectRepository#updateById`; the method's docblock there is its contract. */
91+
static async updateById(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, id: string | number, data: any): Promise<any> {
92+
return this.engine.update(this.objectName, { ...data, id: id }, {
93+
where: { id: id },
94+
context: this.context,
95+
});
96+
}
97+
98+
/** The body of `ObjectRepository#delete`; the method's docblock there is its contract. */
99+
static async delete(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, options: any = {}): Promise<any> {
100+
return this.engine.delete(this.objectName, {
101+
...options,
102+
context: this.context,
103+
});
104+
}
105+
106+
/** The body of `ObjectRepository#deleteById`; the method's docblock there is its contract. */
107+
static async deleteById(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, id: string | number): Promise<any> {
108+
return this.engine.delete(this.objectName, {
109+
where: { id: id },
110+
context: this.context,
111+
});
112+
}
113+
114+
/** The body of `ObjectRepository#count`; the method's docblock there is its contract. */
115+
static async count(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<number> {
116+
return this.engine.count(this.objectName, {
117+
...query,
118+
context: this.context,
119+
});
120+
}
121+
122+
/** The body of `ObjectRepository#aggregate`; the method's docblock there is its contract. */
123+
static async aggregate(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<any[]> {
124+
return this.engine.aggregate(this.objectName, {
125+
...query,
126+
context: this.context,
127+
});
128+
}
129+
130+
/** The body of `ObjectRepository#execute`; the method's docblock there is its contract. */
131+
static async execute(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, actionName: string, params?: any): Promise<any> {
132+
if (this.engine.executeAction) {
133+
// [#25653] With the `'action'` mark, as `buildActionExecutionContext`
134+
// (runtime) gives the REST and MCP doors' action bodies: the action this
135+
// reaches is app-authored automation running elevated, so its writes to
136+
// the four identity-assignment objects are refused (plugin-security).
137+
const executionContext: ExecutionContext = markAppAutomationElevation(
138+
{ ...this.context, isSystem: true },
139+
'action',
140+
);
141+
return this.engine.executeAction(this.objectName, actionName, {
142+
...params,
143+
userId: this.context.userId,
144+
tenantId: this.context.tenantId,
145+
roles: this.context.positions,
146+
executionContext,
147+
api: new ScopedContext(executionContext, this.engine),
148+
});
149+
}
150+
throw new Error(`Actions not supported by engine`);
151+
}
152+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30052 codemod] The "Scoped execution context: repositories, elevation,
5+
* transactions" section: the bodies of `ScopedContext`'s methods `object`,
6+
* `sudo`, `withRunAs`, `releaseJoinedHandle`, `txDriver`, `beginTransaction`,
7+
* `commitTransaction`, `rollbackTransaction`, `serveFormulaField` moved out of
8+
* `engine.ts` whole by `scripts/codemod-split-engine.mjs` (its second pass).
9+
*
10+
* A MOVE, not a redesign. {@link ScopedContextFacet} is a stateless facet of
11+
* `ScopedContext`: each static method of it is the body of the `ScopedContext`
12+
* method of the same name exactly as it stood: not re-indented, not formatted,
13+
* not edited. The method stays on the class as a one-call stub with its
14+
* signature, so the published surface, an instance spy and a subclass override
15+
* are what they were. A public method keeps its docblock on the class, the
16+
* contract; a private method's docblock moved here with its body. The state
17+
* stays on `ScopedContext`.
18+
*
19+
* The bodies reach the instance through {@link ScopedContextHost}: exactly the
20+
* `ScopedContext` members they read, private ones included. Every stub hands
21+
* its body, by a `.call`, the same host: `scopedContextHost` in
22+
* `engine-scoped-hosts.ts`, built once per instance by the class's static
23+
* block. Methods go as closures that call them on the instance at call time;
24+
* fields and accessors go as GETTERS, read when the body reads them, and a
25+
* field a body assigns goes with a SETTER that assigns it on the instance.
26+
*
27+
* Values declared in `engine.ts` are imported from it (lever (a) of
28+
* ruling 6591753301); every use is inside a function body, so the import
29+
* cycle is never read at evaluation time. `index.ts` re-exports nothing from
30+
* this module.
31+
*/
32+
33+
import { driverSupportsTransactions } from '@objectstack/spec/data';
34+
import type { IScopedContext } from '@objectstack/spec/contracts';
35+
import { appAutomationElevationOf, markAppAutomationElevation } from '@objectstack/core';
36+
import { UnscopedHookApi } from './hook-run-as.js';
37+
import type { HookRunAs, HookRunAsRef } from './hook-run-as.js';
38+
import { ObjectRepository } from './engine.js';
39+
import type { ObjectQL, ScopedContext } from './engine.js';
40+
41+
/**
42+
* The `ScopedContext` members the bodies of {@link ScopedContextFacet} read —
43+
* every one of them, and no other. Each is typed AS the class member (an
44+
* indexed access on the class), so a change to a member's signature on the
45+
* class is a change here too. `readonly` members are the getters; the others
46+
* are fields a body assigns.
47+
*/
48+
export interface ScopedContextHost {
49+
readonly appAuthored: ScopedContext['appAuthored'];
50+
derive: ScopedContext['derive'];
51+
readonly engine: ScopedContext['engine'];
52+
readonly executionContext: ScopedContext['executionContext'];
53+
readonly joinedHandles: ScopedContext['joinedHandles'];
54+
releaseJoinedHandle: ScopedContext['releaseJoinedHandle'];
55+
txDriver: ScopedContext['txDriver'];
56+
}
57+
58+
/** The bodies of `ScopedContext`'s methods: a stateless facet, called by the class's stubs. */
59+
export class ScopedContextFacet {
60+
/** The body of `ScopedContext#object`; the method's docblock there is its contract. */
61+
static object(this: Pick<ScopedContextHost, 'engine' | 'executionContext'>, name: string): ObjectRepository {
62+
return new ObjectRepository(name, this.executionContext, this.engine as any);
63+
}
64+
65+
/** The body of `ScopedContext#sudo`; the method's docblock there is its contract. */
66+
static sudo(this: Pick<ScopedContextHost, 'appAuthored' | 'derive' | 'executionContext'>): ScopedContext {
67+
const elevated = { ...this.executionContext, isSystem: true };
68+
return this.derive(
69+
this.appAuthored && !appAutomationElevationOf(elevated)
70+
? markAppAutomationElevation(elevated, 'hook')
71+
: elevated,
72+
);
73+
}
74+
75+
/** The body of `ScopedContext#withRunAs`; the method's docblock there is its contract. */
76+
static withRunAs(this: Pick<ScopedContextHost, 'derive' | 'executionContext'>, runAs: Exclude<HookRunAs, 'inherit'>, ref: HookRunAsRef): IScopedContext {
77+
if (runAs === 'system') {
78+
return this.derive(
79+
markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'),
80+
);
81+
}
82+
if (!this.executionContext.userId) return new UnscopedHookApi(ref);
83+
return this.derive({ ...this.executionContext, isSystem: false });
84+
}
85+
86+
/**
87+
* Was `handle` JOINED by this context rather than opened by it? Consumes the
88+
* record, so the trio's terminal call is also what forgets the handle.
89+
*
90+
* Two independent signals, because the trio's callers are exactly the ones
91+
* that cannot keep a closure on the stack and may not close on the same
92+
* object they opened on:
93+
*
94+
* 1. this context's own {@link joinedHandles} record, and
95+
* 2. identity with the CURRENT ambient handle — a handle the engine is
96+
* holding open right now is, by construction, not one the trio opened
97+
* (the trio never publishes into `txStore`, so a trio-owned handle is
98+
* never the ambient one).
99+
*
100+
* Both point the same way and both fail SAFE: the ambiguous answer is
101+
* "abstain", never "commit a transaction we do not own".
102+
*/
103+
static releaseJoinedHandle(this: Pick<ScopedContextHost, 'engine' | 'joinedHandles'>, handle: unknown): boolean {
104+
if (this.joinedHandles.delete(handle)) return true;
105+
if (handle == null) return false;
106+
const ambient = (this.engine as any)?.txStore?.getStore?.() as
107+
| { transaction?: unknown }
108+
| undefined;
109+
return ambient?.transaction === handle;
110+
}
111+
112+
/**
113+
* Resolve the default driver, if it exposes transaction primitives.
114+
* Shared by {@link transaction} and the discrete begin/commit/rollback trio.
115+
*/
116+
static txDriver(this: Pick<ScopedContextHost, 'engine'>): any | undefined {
117+
const engine = this.engine as any;
118+
const driver = engine.defaultDriver
119+
? engine.drivers?.get(engine.defaultDriver)
120+
: undefined;
121+
// [#18063] Declaration, not bare method presence — the trio's `begin`
122+
// returns `null` and `commit`/`rollback` abstain for a transport that
123+
// declared it cannot honour a handle, which is the same graceful degrade a
124+
// driver with no `beginTransaction` already gets.
125+
return driverSupportsTransactions(driver) ? driver : undefined;
126+
}
127+
128+
/** The body of `ScopedContext#beginTransaction`; the method's docblock there is its contract. */
129+
static async beginTransaction(this: Pick<ScopedContextHost, 'derive' | 'engine' | 'executionContext' | 'joinedHandles' | 'txDriver'>): Promise<{ ctx: ScopedContext; handle: unknown; owned: boolean } | null> {
130+
// ADR-0067 D2 — JOIN before the driver lookup, the same first move and the
131+
// same position as both callback faces (#6168 / #6406). An ambient
132+
// transaction IS a transaction, so there is nothing to look a driver up for.
133+
const ambient = (this.engine as any)?.txStore?.getStore?.() as
134+
| { transaction?: unknown }
135+
| undefined;
136+
if (ambient?.transaction) {
137+
// Threaded explicitly into the child context, identity-equal to the
138+
// store's handle — so `buildDriverOptions` binds every op on it to the
139+
// outer connection and `transactionCoversDriverFor` still attributes the
140+
// handle to the OUTER owner (#5351 unchanged).
141+
const ctx = this.derive({ ...this.executionContext, transaction: ambient.transaction });
142+
this.joinedHandles.add(ambient.transaction);
143+
return { ctx, handle: ambient.transaction, owned: false };
144+
}
145+
const driver = this.txDriver();
146+
if (!driver) return null;
147+
const trx = await driver.beginTransaction();
148+
const ctx = this.derive({ ...this.executionContext, transaction: trx });
149+
return { ctx, handle: trx, owned: true };
150+
}
151+
152+
/** The body of `ScopedContext#commitTransaction`; the method's docblock there is its contract. */
153+
static async commitTransaction(this: Pick<ScopedContextHost, 'releaseJoinedHandle' | 'txDriver'>, handle: unknown): Promise<void> {
154+
if (this.releaseJoinedHandle(handle)) return;
155+
const driver = this.txDriver();
156+
if (!driver) return;
157+
if (driver.commit) await driver.commit(handle);
158+
else if (driver.commitTransaction) await driver.commitTransaction(handle);
159+
}
160+
161+
/** The body of `ScopedContext#rollbackTransaction`; the method's docblock there is its contract. */
162+
static async rollbackTransaction(this: Pick<ScopedContextHost, 'releaseJoinedHandle' | 'txDriver'>, handle: unknown): Promise<void> {
163+
if (this.releaseJoinedHandle(handle)) return;
164+
const driver = this.txDriver();
165+
if (!driver) return;
166+
if (driver.rollback) await driver.rollback(handle);
167+
else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle);
168+
}
169+
170+
/** The body of `ScopedContext#serveFormulaField`; the method's docblock there is its contract. */
171+
static async serveFormulaField(this: Pick<ScopedContextHost, 'engine' | 'executionContext'>, objectName: string, record: Record<string, unknown>, field: string): Promise<unknown> {
172+
return (this.engine as unknown as ObjectQL).serveFormulaField(objectName, record, field, this.executionContext);
173+
}
174+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30052 codemod] The host of each facet of `ObjectRepository` and `ScopedContext`,
5+
* built ONCE per instance: what every stub of a facet hands that facet's
6+
* bodies as `this`. Each host below is filled by the factory its class's
7+
* static block installs, the one place that can read the class's private
8+
* members; the host is built on the first call that needs it and kept for
9+
* the instance's life. A method goes as a closure that calls it on the
10+
* instance at call time, a field or accessor as a getter (and a setter when
11+
* a body assigns it), so a body reads and writes the instance exactly as
12+
* the method did.
13+
*
14+
* Written by `scripts/codemod-split-engine.mjs` (its second pass). It imports
15+
* types only, so it forms no evaluation cycle and is evaluated before the
16+
* classes whichever module loads first. `index.ts` re-exports nothing from
17+
* this module.
18+
*/
19+
20+
import type { ObjectRepository, ScopedContext } from './engine.js';
21+
import type { ObjectRepositoryHost } from './engine-object-repository.js';
22+
import type { ScopedContextHost } from './engine-scoped-context.js';
23+
24+
/** One facet's host for each instance of its class: built on first use, kept for the instance's life. */
25+
export class InstanceHost<E extends object, H> {
26+
private readonly hosts = new WeakMap<E, H>();
27+
private build: ((instance: E) => H) | undefined;
28+
29+
constructor(private readonly facet: string, private readonly owner: string) {}
30+
31+
/** Called once, from the owning class's static block. */
32+
install(build: (instance: E) => H): void {
33+
this.build = build;
34+
}
35+
36+
/** The host of `instance`, built on first use. */
37+
of(instance: E): H {
38+
let host = this.hosts.get(instance);
39+
if (host === undefined) {
40+
if (this.build === undefined) {
41+
throw new Error(`${this.facet}: ${this.owner} has not installed its host factory`);
42+
}
43+
host = this.build(instance);
44+
this.hosts.set(instance, host);
45+
}
46+
return host;
47+
}
48+
}
49+
50+
export const objectRepositoryHost = new InstanceHost<ObjectRepository, ObjectRepositoryHost>('ObjectRepositoryFacet', 'ObjectRepository');
51+
export const scopedContextHost = new InstanceHost<ScopedContext, ScopedContextHost>('ScopedContextFacet', 'ScopedContext');

0 commit comments

Comments
 (0)