diff --git a/.changeset/manifest-bridge-arm-on-project-kernels.md b/.changeset/manifest-bridge-arm-on-project-kernels.md new file mode 100644 index 000000000..b301c4935 --- /dev/null +++ b/.changeset/manifest-bridge-arm-on-project-kernels.md @@ -0,0 +1,20 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): arm the late-manifest metadata bridge on project kernels too + +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 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. +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. diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 1ddf59d6d..2a98d6e2e 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -1695,6 +1695,26 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { expect(kept.label).toBe('Authored Contact'); }); + it('bridges late installs on project kernels too (`os dev` passes environmentId)', async () => { + // `os dev` boots the kernel with environmentId 'env_local', so the + // one-shot startup bridge is (correctly) skipped there — but the + // per-manifest bridge must still arm, or marketplace install-local + // (whose primary home IS `os dev`) never reaches the metadata + // service. Unlike the one-shot bridge, bridging one just-registered + // package cannot leak sibling-project objects, so the environmentId + // gate does not apply to it. + await kernel.use(new ObjectQLPlugin({ environmentId: 'env_local' })); + await kernel.bootstrap(); + + const manifest = kernel.getService('manifest') as ManifestService; + await manifest.register(crmManifest()); + + const metadata = kernel.getService('metadata') as any; + const bridged = await metadata.getObject('crm_contact'); + expect(bridged).toBeDefined(); + expect(bridged.label).toBe('Contact'); + }); + it('boot-time registrations still flow through the one-shot startup bridge', async () => { await kernel.use(new ObjectQLPlugin()); await kernel.use({ diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index bb52949f7..1232b5a70 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -135,14 +135,17 @@ export class ObjectQLPlugin implements Plugin { private reloadSchemaSync: Promise = Promise.resolve(); private hydrateMetadataFromDb = false; /** - * Armed once `start()` has run the one-shot - * {@link bridgeObjectsToMetadataService}. From that point on, every - * manifest registered through the `manifest` service bridges its own - * objects into the metadata service incrementally — the one-shot bridge - * never runs again, so late registrations (marketplace install / - * rehydrate on `kernel:ready`) would otherwise stay invisible to every - * IMetadataService consumer. Stays false on project kernels - * (`environmentId` set), matching the one-shot bridge's gate. + * Armed at the end of `start()` (AFTER the one-shot + * {@link bridgeObjectsToMetadataService}, where that runs). From that + * point on, every manifest registered through the `manifest` service + * bridges its own objects into the metadata service incrementally — the + * one-shot bridge never runs again, so late registrations (marketplace + * install / rehydrate on `kernel:ready`) would otherwise stay invisible + * to every IMetadataService consumer. Armed on ALL kernels, including + * project-scoped ones (`environmentId` set — `os dev` boots as + * 'env_local'): unlike the one-shot registry-wide bridge, a per-manifest + * bridge cannot leak sibling-project objects, and gating it would turn + * the fix off in marketplace install-local's primary home. */ private bridgeLateManifests = false; /** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */ @@ -541,15 +544,24 @@ export class ObjectQLPlugin implements Plugin { // skip it in that case. if (this.environmentId === undefined) { await this.bridgeObjectsToMetadataService(ctx); - // The one-shot bridge above covered everything registered so far. - // Arm the incremental per-manifest bridge for everything after — - // marketplace install / rehydrate register through the `manifest` - // service on `kernel:ready`, long after this line, and without the - // incremental bridge their objects never reach the metadata service - // (AI describe_object, Studio object lists, metadata.listObjects all - // miss them; only the seed loader has an engine fallback, #3422). - this.bridgeLateManifests = true; } + // Arm the incremental per-manifest bridge for everything after start() — + // marketplace install / rehydrate register through the `manifest` + // service on `kernel:ready`, long after this line, and without the + // incremental bridge their objects never reach the metadata service + // (AI describe_object, Studio object lists, metadata.listObjects all + // miss them; only the seed loader has an engine fallback, #3422). + // + // Deliberately NOT inside the `environmentId === undefined` gate above: + // that gate exists because the one-shot bridge copies the ENTIRE + // process-wide SchemaRegistry (cross-project leakage on multi-env + // servers), whereas the per-manifest bridge only copies the objects of + // the one package THIS kernel just registered — nothing to leak. And + // `os dev` boots project-scoped (environmentId 'env_local'), which is + // marketplace install-local's primary home: gating on environmentId + // would switch the fix off exactly where it matters (caught by + // browser-dogfooding the install flow). + this.bridgeLateManifests = true; // Register built-in audit hooks this.registerAuditHooks(ctx);