diff --git a/.changeset/marketplace-objects-bridge-metadata-service.md b/.changeset/marketplace-objects-bridge-metadata-service.md new file mode 100644 index 0000000000..068aef82c6 --- /dev/null +++ b/.changeset/marketplace-objects-bridge-metadata-service.md @@ -0,0 +1,24 @@ +--- +"@objectstack/objectql": patch +"@objectstack/cloud-connection": patch +--- + +fix(objectql): bridge late-registered manifest objects into the metadata service + +Marketplace-installed template packages register through the `manifest` +service on `kernel:ready` (install) or later (HTTP install), but the one-shot +SchemaRegistry→metadata bridge runs once during `ObjectQLPlugin.start()` — +so their objects only ever reached the ObjectQL registry. Every +IMetadataService consumer (AI `describe_object`, Studio object lists, +`metadata.listObjects`) missed them; only the seed loader had grown an +engine-side fallback (#3422). + +The manifest service's `register` now bridges the manifest's own objects into +the metadata service after registering them with the engine, resolving the +service at call time and mirroring the startup bridge's contract: +`register('object', name, obj, { notify: false })` (#3112), skip entries it +did not bridge itself, refresh its own copy on same-package re-install (hot +upgrade). Armed only after `start()` has run the one-shot bridge, and never +on project kernels — boot-time behavior is unchanged. `register` now returns +a promise; the marketplace install/rehydrate paths await it so metadata reads +right after an install are deterministic. diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index bf53f95085..fc969d5a90 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -163,7 +163,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const entries = this.readAll(); if (entries.length === 0) return; - let manifestService: { register(m: any): void } | null = null; + let manifestService: { register(m: any): void | Promise } | null = null; try { manifestService = ctx.getService('manifest') as any; } catch { @@ -173,7 +173,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { for (const entry of entries) { try { - manifestService!.register(entry.manifest); + // Awaited: register also bridges the manifest's objects into + // the metadata service (late-registration bridge in + // ObjectQLPlugin) — wait for that so metadata consumers see + // the package as soon as rehydrate reports success. + await manifestService!.register(entry.manifest); // Sync schemas so the driver creates tables for the newly- // registered objects (idempotent — already-synced tables // are no-ops). @@ -339,7 +343,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // would also fail on every subsequent rehydrate. try { const manifestService = ctx.getService('manifest') as any; - manifestService.register(manifest); + // Awaited: register also bridges the manifest's objects into the + // metadata service — a caller that reads metadata right after a + // 200 (AI describe_object, Studio object list) must see them. + await manifestService.register(manifest); } catch (err: any) { // For offline file imports we treat a register failure as a hard // failure (don't persist). Cloud installs historically tolerated diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 4032e20560..1ddf59d6d2 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -1614,4 +1614,102 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { expect(inserts.map((r) => r.approval_status)).toEqual(['draft', 'draft']); }); }); + + // Marketplace-installed template packages register through the `manifest` + // service AFTER `start()` ran the one-shot SchemaRegistry→metadata bridge + // (install and ledger rehydrate both happen on `kernel:ready` or an HTTP + // request), so their objects used to land in the ObjectQL registry only — + // invisible to every IMetadataService consumer (AI describe_object, Studio + // object lists, metadata.listObjects; only the seed loader grew an engine + // fallback, #3422). The manifest service now bridges each late manifest's + // own objects incrementally. + describe('late manifest.register bridges objects to the metadata service', () => { + type ManifestService = { register(m: any): void | Promise }; + + const crmManifest = (label = 'Contact', version = '1.0.0') => ({ + id: 'com.acme.crm', + name: 'acme_crm', + version, + type: 'app', + objects: [ + { + name: 'crm_contact', + label, + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + }, + }, + ], + }); + + it('installs after bootstrap land the object in the metadata service', async () => { + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + + // Simulates marketplace install-local: register via the manifest + // service on a fully started kernel, then read as an + // IMetadataService consumer would. + 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'); + // The bridged body is the registry-resolved shape, not the raw + // manifest fragment — provenance stamped, author fields intact. + expect(bridged._packageId).toBe('com.acme.crm'); + expect(bridged.fields?.name?.type).toBe('text'); + const listed = await metadata.listObjects(); + expect(listed.some((o: any) => o?.name === 'crm_contact')).toBe(true); + }); + + it('re-installing the same package refreshes its bridged copy (hot upgrade)', async () => { + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + const manifest = kernel.getService('manifest') as ManifestService; + + await manifest.register(crmManifest('Contact', '1.0.0')); + await manifest.register(crmManifest('Contact v2', '2.0.0')); + + const metadata = kernel.getService('metadata') as any; + const bridged = await metadata.getObject('crm_contact'); + expect(bridged.label).toBe('Contact v2'); + }); + + it('never clobbers a same-name entry it did not bridge itself', async () => { + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + + // An authored / artifact-parsed copy: no registry `_packageId` stamp. + const metadata = kernel.getService('metadata') as any; + await metadata.register('object', 'crm_contact', { + name: 'crm_contact', + label: 'Authored Contact', + }); + + const manifest = kernel.getService('manifest') as ManifestService; + await manifest.register(crmManifest('Marketplace Contact')); + + const kept = await metadata.getObject('crm_contact'); + expect(kept.label).toBe('Authored Contact'); + }); + + it('boot-time registrations still flow through the one-shot startup bridge', async () => { + await kernel.use(new ObjectQLPlugin()); + await kernel.use({ + name: 'boot-app', + type: 'app', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx) => { + ctx.getService('manifest').register(crmManifest()); + }, + }); + await kernel.bootstrap(); + + const metadata = kernel.getService('metadata') as any; + expect(await metadata.getObject('crm_contact')).toBeDefined(); + }); + }); }); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index be8d128652..bb52949f7f 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -134,6 +134,17 @@ export class ObjectQLPlugin implements Plugin { /** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */ 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. + */ + private bridgeLateManifests = false; /** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */ private metadataUnsubscribes: Array<() => void> = []; /** ADR-0057 lifecycle enforcement (Reaper/Rotator/Archiver). */ @@ -187,6 +198,15 @@ export class ObjectQLPlugin implements Plugin { ctx.logger.debug('Manifest registered via manifest service', { id: manifest.id || manifest.name }); + // Manifests registered AFTER start() (marketplace install / ledger + // rehydrate arrive on `kernel:ready` or an HTTP request) land in the + // SchemaRegistry only — the one-shot startup bridge already ran — so + // bridge this manifest's objects into the metadata service now. + // No-op until start() arms it, so boot-time registrations keep the + // single startup bridge. The promise never rejects; async callers + // (marketplace install) await it so metadata reads right after + // install are deterministic, sync callers may ignore it. + return this.bridgeManifestObjectsToMetadataService(ctx, manifest); } }); @@ -521,6 +541,14 @@ 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; } // Register built-in audit hooks @@ -1118,6 +1146,91 @@ export class ObjectQLPlugin implements Plugin { } } + /** + * Bridge ONE manifest's objects into the metadata service — the + * late-registration companion to {@link bridgeObjectsToMetadataService}. + * + * The one-shot startup bridge runs exactly once during `start()`, but + * manifests keep arriving after that: marketplace install and ledger + * rehydrate register through the `manifest` service on `kernel:ready` (or + * an HTTP request), so their objects landed in the SchemaRegistry only and + * every IMetadataService consumer (AI describe_object, Studio object + * lists, `metadata.listObjects`) missed them. This bridges exactly the + * objects the given manifest contributes, resolved through the registry so + * both `objects` forms (array / name-keyed map) and extension merges come + * out canonical, with `_packageId` stamped. + * + * The metadata service is resolved at CALL time, never captured at init: + * when this plugin inits, MetadataPlugin may not have registered it yet. + * + * Existing same-name entries are left alone UNLESS they carry the same + * `_packageId` — i.e. they are this bridge's own copy from a previous + * version of the same package. That keeps a hot marketplace upgrade fresh + * while never clobbering an authored / artifact-parsed definition. + * + * Registers `{ notify: false }` for the same reason as the startup bridge + * (#3112 notify contract): these definitions come OUT of the + * SchemaRegistry, so announcing would feed our own `subscribe('object')` + * handler right back into the registry and overwrite the objects' true + * package provenance with 'metadata-service'. + * + * Never throws — a bridge failure must not fail the install that + * triggered it. + */ + private async bridgeManifestObjectsToMetadataService( + ctx: PluginContext, + manifest: any, + ): Promise { + if (!this.bridgeLateManifests) return; + const packageId = manifest?.id || manifest?.name; + if (!packageId || !this.ql?.registry) return; + + try { + let metadataService: any; + try { + metadataService = ctx.getService('metadata'); + } catch { + return; // no metadata service on this kernel — nothing to bridge into + } + if (!metadataService || typeof metadataService.register !== 'function') return; + + const objects = this.ql.registry.getAllObjects(packageId); + let bridged = 0; + + for (const obj of objects) { + try { + const existing = await metadataService.getObject(obj.name); + const ownPreviousCopy = + existing != null && + (obj as any)._packageId !== undefined && + (existing as any)._packageId === (obj as any)._packageId; + if (!existing || ownPreviousCopy) { + await metadataService.register('object', obj.name, obj, { notify: false }); + bridged++; + } + } catch (e: unknown) { + ctx.logger.debug('Failed to bridge manifest object to metadata service', { + package: packageId, + object: obj.name, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + if (bridged > 0) { + ctx.logger.info('Bridged late-registered manifest objects to metadata service', { + package: packageId, + count: bridged, + }); + } + } catch (e: unknown) { + ctx.logger.debug('Failed to bridge manifest objects to metadata service', { + package: packageId, + error: e instanceof Error ? e.message : String(e), + }); + } + } + /** * True when a hook of this name is shipped by an installed CODE package — * i.e. the SchemaRegistry holds a composite (`:`) artifact