Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/30052-facade-scoped-classes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/objectql': patch
---

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

Clause-②: no
Metadata-protocol: none

- 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.
- 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.
- Nothing to do on upgrade: the exported names, the published `.d.ts` surface and what every method does are the same.
152 changes: 152 additions & 0 deletions packages/objectql/src/engine-object-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#30052 codemod] The "Repository bound to one object and one execution
* context" section: the bodies of `ObjectRepository`'s methods `find`,
* `findOne`, `insert`, `create`, `update`, `updateById`, `delete`,
* `deleteById`, `count`, `aggregate`, `execute` moved out of `engine.ts` whole
* by `scripts/codemod-split-engine.mjs` (its second pass).
*
* A MOVE, not a redesign. {@link ObjectRepositoryFacet} is a stateless facet of
* `ObjectRepository`: each static method of it is the body of the
* `ObjectRepository` method of the same name exactly as it stood: not
* re-indented, not formatted, not edited. The method stays on the class as a
* one-call stub with its signature, so the published surface, an instance spy
* and a subclass override are what they were. A public method keeps its
* docblock on the class, the contract; a private method's docblock moved here
* with its body. The state stays on `ObjectRepository`.
*
* The bodies reach the instance through {@link ObjectRepositoryHost}: exactly
* the `ObjectRepository` members they read, private ones included. Every stub
* hands its body, by a `.call`, the same host: `objectRepositoryHost` in
* `engine-scoped-hosts.ts`, built once per instance by the class's static
* block. Methods go as closures that call them on the instance at call time;
* fields and accessors go as GETTERS, read when the body reads them, and a
* field a body assigns goes with a SETTER that assigns it on the instance.
*
* Values declared in `engine.ts` are imported from it (lever (a) of
* ruling 6591753301); every use is inside a function body, so the import
* cycle is never read at evaluation time. `index.ts` re-exports nothing from
* this module.
*/

import type { ExecutionContext } from '@objectstack/spec/kernel';
import { markAppAutomationElevation } from '@objectstack/core';
import { ScopedContext } from './engine.js';
import type { ObjectRepository } from './engine.js';

/**
* The `ObjectRepository` members the bodies of {@link ObjectRepositoryFacet}
* read — every one of them, and no other. Each is typed AS the class member (an
* indexed access on the class), so a change to a member's signature on the
* class is a change here too. `readonly` members are the getters; the others
* are fields a body assigns.
*/
export interface ObjectRepositoryHost {
readonly context: ObjectRepository['context'];
readonly engine: ObjectRepository['engine'];
insert: ObjectRepository['insert'];
readonly objectName: ObjectRepository['objectName'];
}

/** The bodies of `ObjectRepository`'s methods: a stateless facet, called by the class's stubs. */
export class ObjectRepositoryFacet {
/** The body of `ObjectRepository#find`; the method's docblock there is its contract. */
static async find(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<any[]> {
return this.engine.find(this.objectName, {
...query,
context: this.context,
});
}

/** The body of `ObjectRepository#findOne`; the method's docblock there is its contract. */
static async findOne(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<Record<string, any> | null> {
return this.engine.findOne(this.objectName, {
...query,
context: this.context,
});
}

/** The body of `ObjectRepository#insert`; the method's docblock there is its contract. */
static async insert(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, data: any): Promise<any> {
return this.engine.insert(this.objectName, data, {
context: this.context,
});
}

/** The body of `ObjectRepository#create`; the method's docblock there is its contract. */
static async create(this: Pick<ObjectRepositoryHost, 'insert'>, data: any): Promise<any> {
return this.insert(data);
}

/** The body of `ObjectRepository#update`; the method's docblock there is its contract. */
static async update(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, data: any, options: any = {}): Promise<Record<string, any> | number | null> {
return this.engine.update(this.objectName, data, {
...options,
context: this.context,
});
}

/** The body of `ObjectRepository#updateById`; the method's docblock there is its contract. */
static async updateById(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, id: string | number, data: any): Promise<any> {
return this.engine.update(this.objectName, { ...data, id: id }, {
where: { id: id },
context: this.context,
});
}

/** The body of `ObjectRepository#delete`; the method's docblock there is its contract. */
static async delete(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, options: any = {}): Promise<any> {
return this.engine.delete(this.objectName, {
...options,
context: this.context,
});
}

/** The body of `ObjectRepository#deleteById`; the method's docblock there is its contract. */
static async deleteById(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, id: string | number): Promise<any> {
return this.engine.delete(this.objectName, {
where: { id: id },
context: this.context,
});
}

/** The body of `ObjectRepository#count`; the method's docblock there is its contract. */
static async count(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<number> {
return this.engine.count(this.objectName, {
...query,
context: this.context,
});
}

/** The body of `ObjectRepository#aggregate`; the method's docblock there is its contract. */
static async aggregate(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, query: any = {}): Promise<any[]> {
return this.engine.aggregate(this.objectName, {
...query,
context: this.context,
});
}

/** The body of `ObjectRepository#execute`; the method's docblock there is its contract. */
static async execute(this: Pick<ObjectRepositoryHost, 'context' | 'engine' | 'objectName'>, actionName: string, params?: any): Promise<any> {
if (this.engine.executeAction) {
// [#25653] With the `'action'` mark, as `buildActionExecutionContext`
// (runtime) gives the REST and MCP doors' action bodies: the action this
// reaches is app-authored automation running elevated, so its writes to
// the four identity-assignment objects are refused (plugin-security).
const executionContext: ExecutionContext = markAppAutomationElevation(
{ ...this.context, isSystem: true },
'action',
);
return this.engine.executeAction(this.objectName, actionName, {
...params,
userId: this.context.userId,
tenantId: this.context.tenantId,
roles: this.context.positions,
executionContext,
api: new ScopedContext(executionContext, this.engine),
});
}
throw new Error(`Actions not supported by engine`);
}
}
174 changes: 174 additions & 0 deletions packages/objectql/src/engine-scoped-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#30052 codemod] The "Scoped execution context: repositories, elevation,
* transactions" section: the bodies of `ScopedContext`'s methods `object`,
* `sudo`, `withRunAs`, `releaseJoinedHandle`, `txDriver`, `beginTransaction`,
* `commitTransaction`, `rollbackTransaction`, `serveFormulaField` moved out of
* `engine.ts` whole by `scripts/codemod-split-engine.mjs` (its second pass).
*
* A MOVE, not a redesign. {@link ScopedContextFacet} is a stateless facet of
* `ScopedContext`: each static method of it is the body of the `ScopedContext`
* method of the same name exactly as it stood: not re-indented, not formatted,
* not edited. The method stays on the class as a one-call stub with its
* signature, so the published surface, an instance spy and a subclass override
* are what they were. A public method keeps its docblock on the class, the
* contract; a private method's docblock moved here with its body. The state
* stays on `ScopedContext`.
*
* The bodies reach the instance through {@link ScopedContextHost}: exactly the
* `ScopedContext` members they read, private ones included. Every stub hands
* its body, by a `.call`, the same host: `scopedContextHost` in
* `engine-scoped-hosts.ts`, built once per instance by the class's static
* block. Methods go as closures that call them on the instance at call time;
* fields and accessors go as GETTERS, read when the body reads them, and a
* field a body assigns goes with a SETTER that assigns it on the instance.
*
* Values declared in `engine.ts` are imported from it (lever (a) of
* ruling 6591753301); every use is inside a function body, so the import
* cycle is never read at evaluation time. `index.ts` re-exports nothing from
* this module.
*/

import { driverSupportsTransactions } from '@objectstack/spec/data';
import type { IScopedContext } from '@objectstack/spec/contracts';
import { appAutomationElevationOf, markAppAutomationElevation } from '@objectstack/core';
import { UnscopedHookApi } from './hook-run-as.js';
import type { HookRunAs, HookRunAsRef } from './hook-run-as.js';
import { ObjectRepository } from './engine.js';
import type { ObjectQL, ScopedContext } from './engine.js';

/**
* The `ScopedContext` members the bodies of {@link ScopedContextFacet} read —
* every one of them, and no other. Each is typed AS the class member (an
* indexed access on the class), so a change to a member's signature on the
* class is a change here too. `readonly` members are the getters; the others
* are fields a body assigns.
*/
export interface ScopedContextHost {
readonly appAuthored: ScopedContext['appAuthored'];
derive: ScopedContext['derive'];
readonly engine: ScopedContext['engine'];
readonly executionContext: ScopedContext['executionContext'];
readonly joinedHandles: ScopedContext['joinedHandles'];
releaseJoinedHandle: ScopedContext['releaseJoinedHandle'];
txDriver: ScopedContext['txDriver'];
}

/** The bodies of `ScopedContext`'s methods: a stateless facet, called by the class's stubs. */
export class ScopedContextFacet {
/** The body of `ScopedContext#object`; the method's docblock there is its contract. */
static object(this: Pick<ScopedContextHost, 'engine' | 'executionContext'>, name: string): ObjectRepository {
return new ObjectRepository(name, this.executionContext, this.engine as any);
}

/** The body of `ScopedContext#sudo`; the method's docblock there is its contract. */
static sudo(this: Pick<ScopedContextHost, 'appAuthored' | 'derive' | 'executionContext'>): ScopedContext {
const elevated = { ...this.executionContext, isSystem: true };
return this.derive(
this.appAuthored && !appAutomationElevationOf(elevated)
? markAppAutomationElevation(elevated, 'hook')
: elevated,
);
}

/** The body of `ScopedContext#withRunAs`; the method's docblock there is its contract. */
static withRunAs(this: Pick<ScopedContextHost, 'derive' | 'executionContext'>, runAs: Exclude<HookRunAs, 'inherit'>, ref: HookRunAsRef): IScopedContext {
if (runAs === 'system') {
return this.derive(
markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'),
);
}
if (!this.executionContext.userId) return new UnscopedHookApi(ref);
return this.derive({ ...this.executionContext, isSystem: false });
}

/**
* Was `handle` JOINED by this context rather than opened by it? Consumes the
* record, so the trio's terminal call is also what forgets the handle.
*
* Two independent signals, because the trio's callers are exactly the ones
* that cannot keep a closure on the stack and may not close on the same
* object they opened on:
*
* 1. this context's own {@link joinedHandles} record, and
* 2. identity with the CURRENT ambient handle — a handle the engine is
* holding open right now is, by construction, not one the trio opened
* (the trio never publishes into `txStore`, so a trio-owned handle is
* never the ambient one).
*
* Both point the same way and both fail SAFE: the ambiguous answer is
* "abstain", never "commit a transaction we do not own".
*/
static releaseJoinedHandle(this: Pick<ScopedContextHost, 'engine' | 'joinedHandles'>, handle: unknown): boolean {
if (this.joinedHandles.delete(handle)) return true;
if (handle == null) return false;
const ambient = (this.engine as any)?.txStore?.getStore?.() as
| { transaction?: unknown }
| undefined;
return ambient?.transaction === handle;
}

/**
* Resolve the default driver, if it exposes transaction primitives.
* Shared by {@link transaction} and the discrete begin/commit/rollback trio.
*/
static txDriver(this: Pick<ScopedContextHost, 'engine'>): any | undefined {
const engine = this.engine as any;
const driver = engine.defaultDriver
? engine.drivers?.get(engine.defaultDriver)
: undefined;
// [#18063] Declaration, not bare method presence — the trio's `begin`
// returns `null` and `commit`/`rollback` abstain for a transport that
// declared it cannot honour a handle, which is the same graceful degrade a
// driver with no `beginTransaction` already gets.
return driverSupportsTransactions(driver) ? driver : undefined;
}

/** The body of `ScopedContext#beginTransaction`; the method's docblock there is its contract. */
static async beginTransaction(this: Pick<ScopedContextHost, 'derive' | 'engine' | 'executionContext' | 'joinedHandles' | 'txDriver'>): Promise<{ ctx: ScopedContext; handle: unknown; owned: boolean } | null> {
// ADR-0067 D2 — JOIN before the driver lookup, the same first move and the
// same position as both callback faces (#6168 / #6406). An ambient
// transaction IS a transaction, so there is nothing to look a driver up for.
const ambient = (this.engine as any)?.txStore?.getStore?.() as
| { transaction?: unknown }
| undefined;
if (ambient?.transaction) {
// Threaded explicitly into the child context, identity-equal to the
// store's handle — so `buildDriverOptions` binds every op on it to the
// outer connection and `transactionCoversDriverFor` still attributes the
// handle to the OUTER owner (#5351 unchanged).
const ctx = this.derive({ ...this.executionContext, transaction: ambient.transaction });
this.joinedHandles.add(ambient.transaction);
return { ctx, handle: ambient.transaction, owned: false };
}
const driver = this.txDriver();
if (!driver) return null;
const trx = await driver.beginTransaction();
const ctx = this.derive({ ...this.executionContext, transaction: trx });
return { ctx, handle: trx, owned: true };
}

/** The body of `ScopedContext#commitTransaction`; the method's docblock there is its contract. */
static async commitTransaction(this: Pick<ScopedContextHost, 'releaseJoinedHandle' | 'txDriver'>, handle: unknown): Promise<void> {
if (this.releaseJoinedHandle(handle)) return;
const driver = this.txDriver();
if (!driver) return;
if (driver.commit) await driver.commit(handle);
else if (driver.commitTransaction) await driver.commitTransaction(handle);
}

/** The body of `ScopedContext#rollbackTransaction`; the method's docblock there is its contract. */
static async rollbackTransaction(this: Pick<ScopedContextHost, 'releaseJoinedHandle' | 'txDriver'>, handle: unknown): Promise<void> {
if (this.releaseJoinedHandle(handle)) return;
const driver = this.txDriver();
if (!driver) return;
if (driver.rollback) await driver.rollback(handle);
else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle);
}

/** The body of `ScopedContext#serveFormulaField`; the method's docblock there is its contract. */
static async serveFormulaField(this: Pick<ScopedContextHost, 'engine' | 'executionContext'>, objectName: string, record: Record<string, unknown>, field: string): Promise<unknown> {
return (this.engine as unknown as ObjectQL).serveFormulaField(objectName, record, field, this.executionContext);
}
}
51 changes: 51 additions & 0 deletions packages/objectql/src/engine-scoped-hosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#30052 codemod] The host of each facet of `ObjectRepository` and `ScopedContext`,
* built ONCE per instance: what every stub of a facet hands that facet's
* bodies as `this`. Each host below is filled by the factory its class's
* static block installs, the one place that can read the class's private
* members; the host is built on the first call that needs it and kept for
* the instance's life. A method goes as a closure that calls it on the
* instance at call time, a field or accessor as a getter (and a setter when
* a body assigns it), so a body reads and writes the instance exactly as
* the method did.
*
* Written by `scripts/codemod-split-engine.mjs` (its second pass). It imports
* types only, so it forms no evaluation cycle and is evaluated before the
* classes whichever module loads first. `index.ts` re-exports nothing from
* this module.
*/

import type { ObjectRepository, ScopedContext } from './engine.js';
import type { ObjectRepositoryHost } from './engine-object-repository.js';
import type { ScopedContextHost } from './engine-scoped-context.js';

/** One facet's host for each instance of its class: built on first use, kept for the instance's life. */
export class InstanceHost<E extends object, H> {
private readonly hosts = new WeakMap<E, H>();
private build: ((instance: E) => H) | undefined;

constructor(private readonly facet: string, private readonly owner: string) {}

/** Called once, from the owning class's static block. */
install(build: (instance: E) => H): void {
this.build = build;
}

/** The host of `instance`, built on first use. */
of(instance: E): H {
let host = this.hosts.get(instance);
if (host === undefined) {
if (this.build === undefined) {
throw new Error(`${this.facet}: ${this.owner} has not installed its host factory`);
}
host = this.build(instance);
this.hosts.set(instance, host);
}
return host;
}
}

export const objectRepositoryHost = new InstanceHost<ObjectRepository, ObjectRepositoryHost>('ObjectRepositoryFacet', 'ObjectRepository');
export const scopedContextHost = new InstanceHost<ScopedContext, ScopedContextHost>('ScopedContextFacet', 'ScopedContext');
Loading
Loading