Skip to content

Commit e1fa8d5

Browse files
os-zhuangclaude
andauthored
fix(objectql): arm the late-manifest metadata bridge on project kernels too (#3436)
The per-manifest bridge added for marketplace installs (#3428) armed itself inside the same `environmentId === undefined` gate as the one-shot startup bridge - but `os dev` boots the kernel project-scoped (environmentId 'env_local'), which is marketplace install-local's primary home, so the fix was inert exactly where it matters. Caught by browser-dogfooding the install flow: the install succeeded, yet the process never logged a bridge pass and the flag could never arm. The gate is correct for the one-shot bridge (it copies the ENTIRE process-wide SchemaRegistry, which would leak sibling-project objects on multi-environment servers) but does not apply to the per-manifest bridge: it only copies the objects of the one package this kernel just registered - nothing to leak. Arming now happens unconditionally at the end of start(); boot-time behavior on every kernel shape is unchanged (the flag still flips only after the startup path has run) and the one-shot bridge keeps its gate. Regression test: a project-scoped kernel (environmentId 'env_local' - the exact `os dev` shape) now asserts a post-bootstrap manifest.register lands its objects in the metadata service; red before this change. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9981c1d commit e1fa8d5

3 files changed

Lines changed: 68 additions & 16 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): arm the late-manifest metadata bridge on project kernels too
6+
7+
The per-manifest bridge added for marketplace installs (#3428) armed itself
8+
inside the same `environmentId === undefined` gate as the one-shot startup
9+
bridge — but `os dev` boots the kernel project-scoped (environmentId
10+
'env_local'), which is marketplace install-local's primary home, so the fix
11+
was inert exactly where it matters. Caught by browser-dogfooding the install
12+
flow.
13+
14+
The gate is correct for the one-shot bridge (it copies the entire
15+
process-wide SchemaRegistry, which would leak sibling-project objects on
16+
multi-environment servers) but does not apply to the per-manifest bridge: it
17+
only copies the objects of the one package this kernel just registered.
18+
Arming now happens unconditionally at the end of `start()`; boot-time
19+
behavior on every kernel shape is unchanged (the flag still flips only after
20+
the startup path has run), and the one-shot bridge keeps its gate.

packages/objectql/src/plugin.integration.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,6 +1695,26 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
16951695
expect(kept.label).toBe('Authored Contact');
16961696
});
16971697

1698+
it('bridges late installs on project kernels too (`os dev` passes environmentId)', async () => {
1699+
// `os dev` boots the kernel with environmentId 'env_local', so the
1700+
// one-shot startup bridge is (correctly) skipped there — but the
1701+
// per-manifest bridge must still arm, or marketplace install-local
1702+
// (whose primary home IS `os dev`) never reaches the metadata
1703+
// service. Unlike the one-shot bridge, bridging one just-registered
1704+
// package cannot leak sibling-project objects, so the environmentId
1705+
// gate does not apply to it.
1706+
await kernel.use(new ObjectQLPlugin({ environmentId: 'env_local' }));
1707+
await kernel.bootstrap();
1708+
1709+
const manifest = kernel.getService('manifest') as ManifestService;
1710+
await manifest.register(crmManifest());
1711+
1712+
const metadata = kernel.getService('metadata') as any;
1713+
const bridged = await metadata.getObject('crm_contact');
1714+
expect(bridged).toBeDefined();
1715+
expect(bridged.label).toBe('Contact');
1716+
});
1717+
16981718
it('boot-time registrations still flow through the one-shot startup bridge', async () => {
16991719
await kernel.use(new ObjectQLPlugin());
17001720
await kernel.use({

packages/objectql/src/plugin.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,17 @@ export class ObjectQLPlugin implements Plugin {
135135
private reloadSchemaSync: Promise<void> = Promise.resolve();
136136
private hydrateMetadataFromDb = false;
137137
/**
138-
* Armed once `start()` has run the one-shot
139-
* {@link bridgeObjectsToMetadataService}. From that point on, every
140-
* manifest registered through the `manifest` service bridges its own
141-
* objects into the metadata service incrementally — the one-shot bridge
142-
* never runs again, so late registrations (marketplace install /
143-
* rehydrate on `kernel:ready`) would otherwise stay invisible to every
144-
* IMetadataService consumer. Stays false on project kernels
145-
* (`environmentId` set), matching the one-shot bridge's gate.
138+
* Armed at the end of `start()` (AFTER the one-shot
139+
* {@link bridgeObjectsToMetadataService}, where that runs). From that
140+
* point on, every manifest registered through the `manifest` service
141+
* bridges its own objects into the metadata service incrementally — the
142+
* one-shot bridge never runs again, so late registrations (marketplace
143+
* install / rehydrate on `kernel:ready`) would otherwise stay invisible
144+
* to every IMetadataService consumer. Armed on ALL kernels, including
145+
* project-scoped ones (`environmentId` set — `os dev` boots as
146+
* 'env_local'): unlike the one-shot registry-wide bridge, a per-manifest
147+
* bridge cannot leak sibling-project objects, and gating it would turn
148+
* the fix off in marketplace install-local's primary home.
146149
*/
147150
private bridgeLateManifests = false;
148151
/** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */
@@ -541,15 +544,24 @@ export class ObjectQLPlugin implements Plugin {
541544
// skip it in that case.
542545
if (this.environmentId === undefined) {
543546
await this.bridgeObjectsToMetadataService(ctx);
544-
// The one-shot bridge above covered everything registered so far.
545-
// Arm the incremental per-manifest bridge for everything after —
546-
// marketplace install / rehydrate register through the `manifest`
547-
// service on `kernel:ready`, long after this line, and without the
548-
// incremental bridge their objects never reach the metadata service
549-
// (AI describe_object, Studio object lists, metadata.listObjects all
550-
// miss them; only the seed loader has an engine fallback, #3422).
551-
this.bridgeLateManifests = true;
552547
}
548+
// Arm the incremental per-manifest bridge for everything after start() —
549+
// marketplace install / rehydrate register through the `manifest`
550+
// service on `kernel:ready`, long after this line, and without the
551+
// incremental bridge their objects never reach the metadata service
552+
// (AI describe_object, Studio object lists, metadata.listObjects all
553+
// miss them; only the seed loader has an engine fallback, #3422).
554+
//
555+
// Deliberately NOT inside the `environmentId === undefined` gate above:
556+
// that gate exists because the one-shot bridge copies the ENTIRE
557+
// process-wide SchemaRegistry (cross-project leakage on multi-env
558+
// servers), whereas the per-manifest bridge only copies the objects of
559+
// the one package THIS kernel just registered — nothing to leak. And
560+
// `os dev` boots project-scoped (environmentId 'env_local'), which is
561+
// marketplace install-local's primary home: gating on environmentId
562+
// would switch the fix off exactly where it matters (caught by
563+
// browser-dogfooding the install flow).
564+
this.bridgeLateManifests = true;
553565

554566
// Register built-in audit hooks
555567
this.registerAuditHooks(ctx);

0 commit comments

Comments
 (0)