Skip to content

Commit 8eca18f

Browse files
fix(rest): POST /batch and the /meta engine reads answer from the request's environment kernel on a hosted runtime (#30218) (#30247)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> Part of #30218 · follows #30011 (PR #30013) and #30192 (PR #30195) _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HPYLaC4gZBht1y9TL9mMjrFz)_ The card carries no `Claim:` naming this branch, so this body declares `Part of`; the owning seat closes the card by hand once this lands. Nothing of the card's framework half is left open here: what remains is cloud's pin round and its hosted reading (see *Acceptance notes*). Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`objectQLProvider`(数据引擎)改成按请求所属环境取内核里的引擎,和 #30013、#30195 的其它接口同一种做法;四个用到它的地方(`POST /batch`、对象状态、已发布读取、meta 读取门的「已停用包」判断)都改为传入按请求解析出的环境 id。 - 为什么改:托管运行时上这四处一直用宿主内核的引擎。批量写会在宿主引擎上开事务、用宿主引擎做更新和删除,而新建走环境自己的协议,一个请求用了两个内核。 - 风险与回滚:只动 `packages/rest`。单内核部署(没有 `kernel-manager`)仍读插件自己的内核,测试钉住不变。回滚即撤销本 PR。 - 先看:`packages/rest/src/rest-api-plugin.ts`(`environmentScopedProvider` 加了自身内核一腿)、`packages/rest/src/batch-routes.ts`(事务入口)、`packages/rest/src/rest-environment-scoped-providers.test.ts`(最后两个 describe)。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/rest-environment-scoped-providers.test.ts src/rest-api-plugin-slot-lookups.test.ts`,预期 121 个全绿。 - 需要你做的:无,席位落地。 ## What changes **The card's fix.** `objectQLProvider` in `packages/rest/src/rest-api-plugin.ts` is now `environmentScopedProvider('objectql', objectQLOnOwnKernel)`, typed `IObjectQLEngine`, with the same legs and the same rule as the seams #30013 and #30195 scoped: a request for environment A is answered by A's engine or by nothing, never by the host's. - `environmentScopedProvider` takes an optional own-kernel leg. The environment leg is unchanged: acquire the environment's kernel through `kernelManager.getOrCreate`, read the slot with `getServiceAsync`, read a branded "never registered" as `undefined`, and re-raise anything else. Only the last leg is swapped. - `objectQLOnOwnKernel` is the old provider body, unchanged: the factory-aware `kernel.getServiceAsync('objectql')` that absorbs only the branded rejection, and the `LiteKernel` sync leg. A single kernel, the reserved `platform` id and a request in no environment still take it. **Every consumer asks for the request's environment.** Each passes the id `RestServer.resolveRequestEnvironmentId` answers (the scoped param, else the host's `kernel-resolver` for a hostname-routed request). The call sits inside the `wiredEngineOrLoud` thunk, so an unwired provider still costs nothing and a rejection still answers 503: - `batch-routes.ts`, the transactional `POST /batch`. `BatchRouteHost` gains `resolveRequestEnvironmentId`. - `meta-object-state-routes.ts`, `GET /meta/object/:name/state/:field`. `MetaObjectStateRouteHost` gains it. - `meta-item-history-routes.ts`, the disabled-owner check of `GET /meta/:type/:name/published`. `MetaItemPublishedRouteHost` gains it. - `rest-server.ts`, the meta read gate's `isItemOfDisabledPackage`. `computeExecCtx` reaches the provider only when no environment kernel was bound, and it already passed the resolved id, so it is unchanged. Changeset: `patch` for `@objectstack/rest`. `scripts/engine-double-contract.pinned.json` gains the two rows its `--write` records for the harness's new engine double. ## Why On cloud's hosted runtime one `RestApiPlugin` sits on the host kernel in front of every environment kernel. `objectQLProvider` read the host kernel's `objectql` whatever id it was handed, and the unscoped mount handed it none. `computeExecCtx` already read the environment kernel's engine, so on a hosted request the execution context and the door's engine came from different kernels. For `POST /batch` that meant three things: - the transaction opened on the host's engine; - update and delete ops went through the host's engine; - create ops went to the environment's protocol, joined to the host's transaction. The ablation readings below show exactly that. ## Tests The new cases live in #30013's harness (`rest-environment-scoped-providers.test.ts`), as two new top-level `describe`s, per the shared-harness rule. The harness uses real `ObjectKernel`s: - A host with the real plugin, `kernel-manager` and `kernel-resolver` sits in front of the environment kernels. The host now carries an engine of its own. - Every engine records which kernel's engine made each call, and which transaction a write joined. - Each registry knows one object (`acct_ENV`) and disables one package (`pkg_ENV`). A door judged by another kernel's registry therefore answers differently on the wire too. - The double's `update` and `delete` open with `assertEngineUpdateDispatch` / `assertEngineDeleteDispatch`. **Hosted, scoped and hostname mounts, env A and env B:** - `POST /batch` (one create, one update, one delete) answers 200. The calls are exactly these: 1. the transaction opens on the target's engine; 2. the create goes through the target's protocol, inside the target's transaction; 3. update and delete run on the target's engine, inside that transaction. No call of any kind reaches the other environment's engine or the host's. - `GET /meta/object/:name/state/:field` answers 200 for `acct_ENV`, judged by the target's registry only. - `GET /meta/:type/:name/published` asks the target's registry whether the owner package is disabled, and answers its 404. Any other registry would have served the document. - The meta read gate's `isItemOfDisabledPackage` asks the target's engine only. **Hosted, both mounts:** an environment whose kernel carries no engine answers `POST /batch` with 501 `NOT_IMPLEMENTED`, and the host's engine is not asked. **Single kernel (no `kernel-manager`), both mounts:** the same four doors are answered by the one kernel's engine, unchanged. **`rest-api-plugin-slot-lookups.test.ts`:** - The scope table marks `objectql` environment-scoped. - It is asked of `env_1`'s kernel for `env_1`, and of the plugin's own kernel for no id and for `platform`. - With no `kernel-manager`, every environment-scoped provider reads the plugin's own kernel for every id. ## Acceptance notes - The four doors now each resolve the request's environment once more. #30013's review noted this pattern, and #30195 also followed it. The hostname resolver is cached, and `resolveExecCtx` is memoised per request. - `isItemOfDisabledPackage` still hands the raw route id to `p.getMetaItem`. `p` is the protocol already resolved for the request, which is where its environment comes from, so this is unchanged here. - **Not proven here:** cloud's pin round, and a hosted reading of `POST /api/v1/batch` on its local stack (the card's done-when, on cloud's side). ## Verification Readings on `objectstack-ai/objectstack`, branch `claude/project-thread-k1cdp9`, branched from `f2a9007e`, in a cloud container. The head is `8bb0c277`. The two commits after `54c86871` add only the changeset (`a5cde52d`) and the ledger JSON (`8bb0c277`), with no source. | | the two harness files (121 cases) | |---|---| | **this branch at `8bb0c277`** | **121 green** (also 121 green at `54c86871`) | | **ablation 1**: `rest-api-plugin.ts`, `rest-server.ts`, `batch-routes.ts`, `meta-object-state-routes.ts`, `meta-item-history-routes.ts` restored to `f2a9007e`, tests kept | **20 red**: all 18 new hosted cases, plus two slot-table cases. The 8 single-kernel cases stayed green. | | **ablation 2**: only the four consumer sites reverted to the raw route id (`() => this.objectQLProvider!(environmentId)`), provider kept | **9 red**: every hosted case on the hostname mount. The scoped mount stayed green, because there the raw param is the environment id. | What ablation 1 showed for the batch: - the transaction opened on `host`'s engine; - the create went through env A's protocol, inside `host`'s transaction; - update and delete ran on `host`'s engine; - the engine-less environment answered 500 instead of 501. The harness imports the plugin and server from `src/` by relative path, so neither ablation goes through a built `dist/`. Ablations ran on the `54c86871` tree. Before each run I checked the mutation on disk by marker counts, and after it I restored from `HEAD` (`git diff HEAD` empty). <details><summary>Package suite, typecheck, lint (at the a5cde52 tree)</summary> - `pnpm --filter @objectstack/rest test`, under `os-verify-lock`: - Test Files 386 passed (386); - Tests 7298 passed | 332 skipped (7630); - exit 0. - `pnpm --filter @objectstack/rest typecheck`: exit 0. That covers `tsc`, plus `check:test-typecheck` OK with 0 files / 0 errors held in `test-typecheck-debt.json`. - `eslint --no-inline-config` on the seven changed source/test files: exit 0. </details> <details><summary>Gates (dispatch-gates --ran at 8bb0c27)</summary> `node scripts/pm/dispatch-gates.mjs --ran ran.list` gave: **68 derived, 66 run, 2 NOT-MEASURED, 0 UNRUN**, exit 0. All 66 that ran exited 0. The 2 NOT-MEASURED both exited 3, PREREQUISITE NOT MET in this container. CI runs both. - `node scripts/check-plugin-teardown-shape.mjs --self-test`: the shallow clone cannot read its positive-control commit. The diff-reading `node scripts/check-plugin-teardown-shape.mjs` exited 0. - `pnpm check:dual-build-cjs-loads`: no full workspace build here. `pnpm check:engine-double-contract` first exited 1, because the harness's new pinned doubles were not yet in the ledger. Its `--write` recorded the two rows in `8bb0c277`, and after that it exited 0. 16 families beyond the derivation also ran green. Among them: - `check:service-providers` - `check:startup-registry-verdict` - `check:rest-log-declared` - `check:route-ledger-census` - `check:changeset-fixed` - `check:tenant-chokepoint` - `check:facade-delegation-only` - `check:filter-alias-parity` - `check:commit-card-trailers` Not run locally (CI's own): the path-scheduled CI jobs and type-check lanes that dispatch-gates lists as having no local invocation. </details> ## 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. (`eslint --no-inline-config` on the changed files.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [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. (The `## 维护者速读` section is the required maintainer brief, in Chinese.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUs3BrY9GFj1N39DmTsqEp --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31260ca commit 8eca18f

9 files changed

Lines changed: 347 additions & 20 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): the transactional `POST /batch`, the `/meta` object-state and published reads, and the meta read gate's disabled-package check use the request's environment's data engine when one REST plugin fronts many environments
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **What was wrong.** On a host that mounts one `RestApiPlugin` in front of many environment kernels (the hosted runtime shape), the `objectql` provider read the HOST kernel's engine whatever environment it was asked for, and the unscoped (hostname-routed) mount asked it for no environment at all. `POST /batch` therefore opened its transaction on the host's engine and sent its update and delete ops through it, while its create ops went to the environment's own protocol, joined to the host's transaction. `GET /meta/object/:name/state/:field` judged the object by the host engine's registry, and `GET /meta/:type/:name/published` and the meta read gate asked the host engine's registry whether an environment's package is disabled. The request's execution context already read the environment's engine, so one request used two kernels.
11+
- **What changes.** Given an environment id and a registered `kernel-manager`, the `objectql` provider now reads `objectql` on that environment's kernel, the same environment-scoped seam the approvals, email, sharing, security and other providers use. Every route that reads it passes the id the shared request→environment resolution answered: the `/environments/:environmentId` param on the scoped mount, else the host's `kernel-resolver` (or the hostname / `X-Environment-Id` chain) on the unscoped one. The host's engine is no longer a fallback: an environment whose kernel carries no engine answers `POST /batch` with `501 NOT_IMPLEMENTED`.
12+
- **Unchanged.** A single-kernel deployment (no `kernel-manager`) reads the engine on the plugin's own kernel exactly as before, through the same factory-aware lookup: a factory that fails to build is still a loud `503`, and only an engine that was never registered reads as absent. The reserved `platform` id and a request the resolver places in no environment also read the plugin's own kernel.
13+
- **No migration.** No configuration, route, response shape or export changes.

‎packages/rest/src/batch-routes.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export interface BatchRouteHost {
8181
readonly routeManager: RestServer['routeManager'];
8282
readonly protocol: RestServer['protocol'];
8383
readonly objectQLProvider: RestServer['objectQLProvider'];
84+
resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId'];
8485
resolveProtocol: RestServer['resolveProtocol'];
8586
resolveExecCtx: RestServer['resolveExecCtx'];
8687
enforceAuth: RestServer['enforceAuth'];
@@ -233,9 +234,17 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string):
233234
// provider SYNCHRONOUSLY, so a host wiring a non-`async`
234235
// provider — which the seam's declared type cannot prevent —
235236
// reaches the same answer as one that rejects (commit add6a1b1c).
237+
//
238+
// [#30218] The engine is the REQUEST's environment's, through
239+
// the shared resolver: the scoped param, else the host's
240+
// resolver for a hostname-routed request. The raw `undefined`
241+
// of the unscoped mount, and a provider that ignored the id,
242+
// opened a hosted environment's transaction on the host
243+
// kernel's engine while the create ops went to the
244+
// environment's protocol.
236245
const ql = await wiredEngineOrLoud(
237246
Boolean(this.objectQLProvider),
238-
() => this.objectQLProvider!(environmentId),
247+
async () => this.objectQLProvider!(await this.resolveRequestEnvironmentId(environmentId, req)),
239248
);
240249
if (!ql || typeof ql.transaction !== 'function') {
241250
// Typed like every other 501 on this server (clone/search,

‎packages/rest/src/meta-item-history-routes.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@
129129
* from it. The getter reads the field the handler read, when it read it:
130130
* slice 2's batch shape, unbound, per the seat's provider ruling (no shipped
131131
* provider reads `this`).
132-
* - The masker, the protocol, the read gate, the schema mask and the metadata
133-
* service are each resolved per request by a METHOD of the class, and every
134-
* method goes as a closure.
132+
* - The masker, the protocol, the read gate, the schema mask, the metadata
133+
* service and [#30218] the request's environment are each resolved per
134+
* request by a METHOD of the class, and every method goes as a closure.
135135
* - The door names the requested type through the private static
136136
* `metaTypeSingular`, which it used to call as
137137
* `RestServer.metaTypeSingular(…)`; for the reason the section above gives,
@@ -1092,6 +1092,7 @@ export interface MetaItemPublishedRouteHost {
10921092
metaItemReadGate: RestServer['metaItemReadGate'];
10931093
maskObjectDocument: RestServer['maskObjectDocument'];
10941094
readonly objectQLProvider: RestServer['objectQLProvider'];
1095+
resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId'];
10951096
resolveMetadataService: RestServer['resolveMetadataService'];
10961097
}
10971098

@@ -1265,9 +1266,11 @@ export function registerMetaItemPublishedRoute(this: MetaItemPublishedRouteHost,
12651266
let withheld = false;
12661267
if (publishedMetaType !== 'object' && publishedMetaType !== 'package'
12671268
&& typeof publishedProtocol?.getMetaItem === 'function') {
1269+
// [#30218] The REQUEST's environment's engine,
1270+
// through the shared resolver, never the host's.
12681271
const ql = await wiredEngineOrLoud(
12691272
Boolean(this.objectQLProvider),
1270-
() => this.objectQLProvider!(environmentId),
1273+
async () => this.objectQLProvider!(await this.resolveRequestEnvironmentId(environmentId, req)),
12711274
);
12721275
const registry = (ql as any)?.registry;
12731276
if (typeof registry?.isPackageDisabled === 'function') {

‎packages/rest/src/meta-object-state-routes.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@
5151
* registration could part from it. The getter reads the field the handler
5252
* read, when it read it: slice 2's batch shape, unbound, per the seat's
5353
* provider ruling (no shipped provider reads `this`).
54-
* - `resolveProtocol` is a METHOD, handed over as a closure that calls it on
55-
* the server at call time, so a test double or spy set on the server is
56-
* still the one the body reaches.
54+
* - `resolveProtocol` and [#30218] `resolveRequestEnvironmentId` are METHODS,
55+
* each handed over as a closure that calls it on the server at call time, so
56+
* a test double or spy set on the server is still the one the body reaches.
5757
*
5858
* The inner method's head declares the bindings every `/meta` group reads and
5959
* hands them over as ONE parameter, {@link MetaRouteScope}, built once there;
@@ -94,6 +94,7 @@ import { wiredEngineOrLoud, type RestServer } from './rest-server.js';
9494
export interface MetaObjectStateRouteHost {
9595
readonly routeManager: RestServer['routeManager'];
9696
readonly objectQLProvider: RestServer['objectQLProvider'];
97+
resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId'];
9798
resolveProtocol: RestServer['resolveProtocol'];
9899
}
99100

@@ -186,9 +187,13 @@ export function registerMetaObjectStateRoute(this: MetaObjectStateRouteHost, sco
186187
// which the seam's declared type cannot prevent — reaches the
187188
// same answer as one that rejects (commit add6a1b1c) instead of
188189
// escaping past a `.catch` that never came into existence.
190+
//
191+
// [#30218] Asked for the REQUEST's environment, through the
192+
// shared resolver, so a hosted environment's object is judged
193+
// by its own engine's registry, not the host kernel's.
189194
const ql = await wiredEngineOrLoud(
190195
Boolean(this.objectQLProvider),
191-
() => this.objectQLProvider!(environmentId),
196+
async () => this.objectQLProvider!(await this.resolveRequestEnvironmentId(environmentId, req)),
192197
);
193198
// [#7557, #24210] The registry is a resolution reader and
194199
// keeps serving a disabled package's object, so this API

‎packages/rest/src/rest-api-plugin-slot-lookups.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const { createRestApiPlugin } = await import('./rest-api-plugin.js');
102102
*/
103103
const PROVIDERS = [
104104
{ index: 6, label: 'authServiceProvider', slot: 'auth', scope: 'environment' },
105-
{ index: 7, label: 'objectQLProvider', slot: 'objectql', scope: 'host' },
105+
{ index: 7, label: 'objectQLProvider', slot: 'objectql', scope: 'environment' },
106106
{ index: 8, label: 'emailServiceProvider', slot: 'email', scope: 'environment' },
107107
{ index: 9, label: 'sharingServiceProvider', slot: 'sharing', scope: 'environment' },
108108
{ index: 11, label: 'approvalsServiceProvider', slot: 'approvals', scope: 'environment' },
@@ -130,7 +130,8 @@ const PROVIDERS = [
130130
* environment id AND a `kernel-manager`, read the slot on THAT environment's
131131
* kernel instead of this plugin's own — the slots whose occupants a hosted
132132
* environment's kernel mounts ([#30192] added `auth`, `i18n`, `settings`,
133-
* `security` and `metadata` to #30011's five). `host` rows read this plugin's
133+
* `security` and `metadata` to #30011's five, and [#30218] the `objectql`
134+
* engine). `host` rows read this plugin's
134135
* kernel whatever id they are handed. The hosted behaviour itself is pinned end to end in
135136
* `rest-environment-scoped-providers.test.ts`; this table only says which
136137
* kernel each provider asks, so the slot-identity cases below can ask the
@@ -420,9 +421,10 @@ describe('[#30011] a single-kernel deployment reads the environment-scoped slots
420421
const { args, ctx } = await boot(services);
421422

422423
// [#30192] `auth`, `i18n`, `settings`, `security` and `metadata` joined
423-
// the five #30011 scoped; `objectql` and `tenancy` stay on the host.
424+
// the five #30011 scoped, and [#30218] `objectql`; `tenancy` stays on the
425+
// host.
424426
expect(ENVIRONMENT_SCOPED.map((p) => p.slot)).toEqual([
425-
'auth', 'email', 'sharing', 'approvals', 'sharingRules', 'i18n', 'analytics', 'settings', 'security', 'metadata',
427+
'auth', 'objectql', 'email', 'sharing', 'approvals', 'sharingRules', 'i18n', 'analytics', 'settings', 'security', 'metadata',
426428
]);
427429
for (const { index, label, slot } of ENVIRONMENT_SCOPED) {
428430
const provider = args[index] as (environmentId?: string) => Promise<unknown>;

‎packages/rest/src/rest-api-plugin.ts‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,18 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
344344
// ObjectQL resolver — single-kernel fallback so resolveExecCtx
345345
// can run sys_member / sys_user_permission_set lookups when
346346
// there is no kernelManager wired (e.g. `pnpm dev:crm`).
347+
//
348+
// [#30218] What is written below is the OWN-KERNEL leg only: the
349+
// provider itself is `environmentScopedProvider('objectql', …)`,
350+
// built after that seam is declared, so a request for an
351+
// environment reads THAT environment's engine and never this
352+
// plugin's. The hosted host kernel is a routing shell: the
353+
// transactional `POST /batch`, the `/meta` object-state and
354+
// published reads, and the meta read gate's disabled-package
355+
// owner step all asked this kernel's engine for a request that
356+
// resolved to an environment, while `computeExecCtx` read the
357+
// environment's — two kernels, one request. The legs, the
358+
// classification and the sync leg below are otherwise unchanged.
347359
// [#4251 B3] `IObjectQLEngine`, not `IDataEngine`: the `objectql`
348360
// slot is the SAME instance as `data` seen whole, and the consumer
349361
// (`resolveExecCtx`, and the `transaction` probe behind the batch
@@ -380,7 +392,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
380392
// its accessor has exactly ONE fault to report — not registered —
381393
// and absorbing it is the same classification, not a second
382394
// collapse.
383-
const objectQLProvider = async (_environmentId?: string): Promise<IObjectQLEngine | undefined> => {
395+
const objectQLOnOwnKernel = async (): Promise<IObjectQLEngine | undefined> => {
384396
const kernel = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined;
385397
if (kernel && typeof kernel.getServiceAsync === 'function') {
386398
try {
@@ -396,8 +408,9 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
396408
};
397409

398410
// [#30011] The ENVIRONMENT-SCOPED provider seams: email, sharing,
399-
// approvals, sharing rules and analytics — and [#30192] security,
400-
// auth, settings, i18n and metadata. Each slot is filled by a
411+
// approvals, sharing rules and analytics — [#30192] security,
412+
// auth, settings, i18n and metadata — and [#30218] the `objectql`
413+
// engine. Each slot is filled by a
401414
// plugin the ENVIRONMENT's kernel mounts. A host that fronts many
402415
// environment kernels with this one plugin (cloud's hosted runtime)
403416
// therefore holds the occupant a request needs on that request's
@@ -428,10 +441,16 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
428441
// own kernel, exactly as before.
429442
//
430443
// On the environment's kernel the classification is the registry's,
431-
// as in `objectQLProvider` above: "never registered" (branded) is
444+
// as in `objectQLOnOwnKernel` above: "never registered" (branded) is
432445
// `undefined`, and any other fault — a factory that threw — is
433446
// re-raised rather than folded into "not configured".
434-
const environmentScopedProvider = <T>(slot: string) =>
447+
//
448+
// [#30218] `onOwnKernel` replaces the last leg for a seam whose
449+
// own-kernel read is classified more strictly than `ctx.getService`'s
450+
// catch-all — the `objectql` engine, whose own leg keeps its
451+
// factory-aware async read and its `LiteKernel` sync leg. The
452+
// environment leg is the same for every seam.
453+
const environmentScopedProvider = <T>(slot: string, onOwnKernel?: () => Promise<T | undefined>) =>
435454
async (environmentId?: string): Promise<T | undefined> => {
436455
if (kernelManager && environmentId && environmentId !== 'platform') {
437456
const kernel = await kernelManager.getOrCreate(environmentId);
@@ -442,11 +461,20 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
442461
throw err;
443462
}
444463
}
464+
if (onOwnKernel) return onOwnKernel();
445465
try {
446466
return ctx.getService<T>(slot);
447467
} catch { return undefined; }
448468
};
449469

470+
// [#30218] The data engine — `resolveExecCtx`'s single-kernel
471+
// lookups, the transactional `POST /batch`, the `/meta` object-state
472+
// and published reads, and the meta read gate's disabled-package
473+
// owner step. Environment-scoped like the seams below: a request for
474+
// environment A is answered by A's engine or by nothing, so a batch
475+
// for A opens its transaction on A's store and writes nowhere else.
476+
const objectQLProvider = environmentScopedProvider<IObjectQLEngine>('objectql', objectQLOnOwnKernel);
477+
450478
// Email service resolver — used by POST /email/send.
451479
const emailServiceProvider = environmentScopedProvider<IEmailService>('email');
452480

0 commit comments

Comments
 (0)