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
24 changes: 24 additions & 0 deletions .changeset/marketplace-objects-bridge-metadata-service.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 10 additions & 3 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> } | null = null;
try {
manifestService = ctx.getService('manifest') as any;
} catch {
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> };

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<ManifestService>('manifest').register(crmManifest());
},
});
await kernel.bootstrap();

const metadata = kernel.getService('metadata') as any;
expect(await metadata.getObject('crm_contact')).toBeDefined();
});
});
});
113 changes: 113 additions & 0 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ export class ObjectQLPlugin implements Plugin {
/** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */
private reloadSchemaSync: Promise<void> = 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). */
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
if (!this.bridgeLateManifests) return;
const packageId = manifest?.id || manifest?.name;
if (!packageId || !this.ql?.registry) return;

try {
let metadataService: any;
try {
metadataService = ctx.getService<any>('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 (`<packageId>:<name>`) artifact
Expand Down