Skip to content

Commit 3f894e8

Browse files
os-zhuangclaude
andcommitted
fix(objectql): bridge late-registered manifest objects into the metadata service
Marketplace-installed template packages register through the manifest service on kernel:ready (rehydrate) or an HTTP request (install), but the one-shot SchemaRegistry-to-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 (registry-resolved, so both objects forms and extension merges come out canonical with _packageId stamped) into the metadata service: - resolved at call time: at objectql init the metadata service may not be registered yet - register('object', name, obj, { notify: false }) - same #3112 rationale as the startup bridge: announcing would loop the definitions back through our own subscribe('object') handler and overwrite provenance with 'metadata-service' - entries it did not bridge itself are never clobbered; its own copy is refreshed on same-package re-install so a hot marketplace upgrade stays fresh - armed only after start() ran the one-shot bridge, and never on project kernels (same environmentId gate) - boot-time behavior is unchanged register returns the bridge promise; marketplace install/rehydrate await it so metadata reads right after a 200 are deterministic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0c354dd commit 3f894e8

4 files changed

Lines changed: 245 additions & 3 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/cloud-connection": patch
4+
---
5+
6+
fix(objectql): bridge late-registered manifest objects into the metadata service
7+
8+
Marketplace-installed template packages register through the `manifest`
9+
service on `kernel:ready` (install) or later (HTTP install), but the one-shot
10+
SchemaRegistry→metadata bridge runs once during `ObjectQLPlugin.start()`
11+
so their objects only ever reached the ObjectQL registry. Every
12+
IMetadataService consumer (AI `describe_object`, Studio object lists,
13+
`metadata.listObjects`) missed them; only the seed loader had grown an
14+
engine-side fallback (#3422).
15+
16+
The manifest service's `register` now bridges the manifest's own objects into
17+
the metadata service after registering them with the engine, resolving the
18+
service at call time and mirroring the startup bridge's contract:
19+
`register('object', name, obj, { notify: false })` (#3112), skip entries it
20+
did not bridge itself, refresh its own copy on same-package re-install (hot
21+
upgrade). Armed only after `start()` has run the one-shot bridge, and never
22+
on project kernels — boot-time behavior is unchanged. `register` now returns
23+
a promise; the marketplace install/rehydrate paths await it so metadata reads
24+
right after an install are deterministic.

packages/cloud-connection/src/marketplace-install-local-plugin.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
163163
const entries = this.readAll();
164164
if (entries.length === 0) return;
165165

166-
let manifestService: { register(m: any): void } | null = null;
166+
let manifestService: { register(m: any): void | Promise<void> } | null = null;
167167
try {
168168
manifestService = ctx.getService('manifest') as any;
169169
} catch {
@@ -173,7 +173,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
173173

174174
for (const entry of entries) {
175175
try {
176-
manifestService!.register(entry.manifest);
176+
// Awaited: register also bridges the manifest's objects into
177+
// the metadata service (late-registration bridge in
178+
// ObjectQLPlugin) — wait for that so metadata consumers see
179+
// the package as soon as rehydrate reports success.
180+
await manifestService!.register(entry.manifest);
177181
// Sync schemas so the driver creates tables for the newly-
178182
// registered objects (idempotent — already-synced tables
179183
// are no-ops).
@@ -339,7 +343,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
339343
// would also fail on every subsequent rehydrate.
340344
try {
341345
const manifestService = ctx.getService('manifest') as any;
342-
manifestService.register(manifest);
346+
// Awaited: register also bridges the manifest's objects into the
347+
// metadata service — a caller that reads metadata right after a
348+
// 200 (AI describe_object, Studio object list) must see them.
349+
await manifestService.register(manifest);
343350
} catch (err: any) {
344351
// For offline file imports we treat a register failure as a hard
345352
// failure (don't persist). Cloud installs historically tolerated

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,4 +1614,102 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
16141614
expect(inserts.map((r) => r.approval_status)).toEqual(['draft', 'draft']);
16151615
});
16161616
});
1617+
1618+
// Marketplace-installed template packages register through the `manifest`
1619+
// service AFTER `start()` ran the one-shot SchemaRegistry→metadata bridge
1620+
// (install and ledger rehydrate both happen on `kernel:ready` or an HTTP
1621+
// request), so their objects used to land in the ObjectQL registry only —
1622+
// invisible to every IMetadataService consumer (AI describe_object, Studio
1623+
// object lists, metadata.listObjects; only the seed loader grew an engine
1624+
// fallback, #3422). The manifest service now bridges each late manifest's
1625+
// own objects incrementally.
1626+
describe('late manifest.register bridges objects to the metadata service', () => {
1627+
type ManifestService = { register(m: any): void | Promise<void> };
1628+
1629+
const crmManifest = (label = 'Contact', version = '1.0.0') => ({
1630+
id: 'com.acme.crm',
1631+
name: 'acme_crm',
1632+
version,
1633+
type: 'app',
1634+
objects: [
1635+
{
1636+
name: 'crm_contact',
1637+
label,
1638+
fields: {
1639+
name: { name: 'name', label: 'Name', type: 'text' },
1640+
},
1641+
},
1642+
],
1643+
});
1644+
1645+
it('installs after bootstrap land the object in the metadata service', async () => {
1646+
await kernel.use(new ObjectQLPlugin());
1647+
await kernel.bootstrap();
1648+
1649+
// Simulates marketplace install-local: register via the manifest
1650+
// service on a fully started kernel, then read as an
1651+
// IMetadataService consumer would.
1652+
const manifest = kernel.getService('manifest') as ManifestService;
1653+
await manifest.register(crmManifest());
1654+
1655+
const metadata = kernel.getService('metadata') as any;
1656+
const bridged = await metadata.getObject('crm_contact');
1657+
expect(bridged).toBeDefined();
1658+
expect(bridged.label).toBe('Contact');
1659+
// The bridged body is the registry-resolved shape, not the raw
1660+
// manifest fragment — provenance stamped, author fields intact.
1661+
expect(bridged._packageId).toBe('com.acme.crm');
1662+
expect(bridged.fields?.name?.type).toBe('text');
1663+
const listed = await metadata.listObjects();
1664+
expect(listed.some((o: any) => o?.name === 'crm_contact')).toBe(true);
1665+
});
1666+
1667+
it('re-installing the same package refreshes its bridged copy (hot upgrade)', async () => {
1668+
await kernel.use(new ObjectQLPlugin());
1669+
await kernel.bootstrap();
1670+
const manifest = kernel.getService('manifest') as ManifestService;
1671+
1672+
await manifest.register(crmManifest('Contact', '1.0.0'));
1673+
await manifest.register(crmManifest('Contact v2', '2.0.0'));
1674+
1675+
const metadata = kernel.getService('metadata') as any;
1676+
const bridged = await metadata.getObject('crm_contact');
1677+
expect(bridged.label).toBe('Contact v2');
1678+
});
1679+
1680+
it('never clobbers a same-name entry it did not bridge itself', async () => {
1681+
await kernel.use(new ObjectQLPlugin());
1682+
await kernel.bootstrap();
1683+
1684+
// An authored / artifact-parsed copy: no registry `_packageId` stamp.
1685+
const metadata = kernel.getService('metadata') as any;
1686+
await metadata.register('object', 'crm_contact', {
1687+
name: 'crm_contact',
1688+
label: 'Authored Contact',
1689+
});
1690+
1691+
const manifest = kernel.getService('manifest') as ManifestService;
1692+
await manifest.register(crmManifest('Marketplace Contact'));
1693+
1694+
const kept = await metadata.getObject('crm_contact');
1695+
expect(kept.label).toBe('Authored Contact');
1696+
});
1697+
1698+
it('boot-time registrations still flow through the one-shot startup bridge', async () => {
1699+
await kernel.use(new ObjectQLPlugin());
1700+
await kernel.use({
1701+
name: 'boot-app',
1702+
type: 'app',
1703+
version: '1.0.0',
1704+
dependencies: ['com.objectstack.engine.objectql'],
1705+
init: async (ctx) => {
1706+
ctx.getService<ManifestService>('manifest').register(crmManifest());
1707+
},
1708+
});
1709+
await kernel.bootstrap();
1710+
1711+
const metadata = kernel.getService('metadata') as any;
1712+
expect(await metadata.getObject('crm_contact')).toBeDefined();
1713+
});
1714+
});
16171715
});

packages/objectql/src/plugin.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ export class ObjectQLPlugin implements Plugin {
134134
/** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */
135135
private reloadSchemaSync: Promise<void> = Promise.resolve();
136136
private hydrateMetadataFromDb = false;
137+
/**
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.
146+
*/
147+
private bridgeLateManifests = false;
137148
/** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */
138149
private metadataUnsubscribes: Array<() => void> = [];
139150
/** ADR-0057 lifecycle enforcement (Reaper/Rotator/Archiver). */
@@ -187,6 +198,15 @@ export class ObjectQLPlugin implements Plugin {
187198
ctx.logger.debug('Manifest registered via manifest service', {
188199
id: manifest.id || manifest.name
189200
});
201+
// Manifests registered AFTER start() (marketplace install / ledger
202+
// rehydrate arrive on `kernel:ready` or an HTTP request) land in the
203+
// SchemaRegistry only — the one-shot startup bridge already ran — so
204+
// bridge this manifest's objects into the metadata service now.
205+
// No-op until start() arms it, so boot-time registrations keep the
206+
// single startup bridge. The promise never rejects; async callers
207+
// (marketplace install) await it so metadata reads right after
208+
// install are deterministic, sync callers may ignore it.
209+
return this.bridgeManifestObjectsToMetadataService(ctx, manifest);
190210
}
191211
});
192212

@@ -521,6 +541,14 @@ export class ObjectQLPlugin implements Plugin {
521541
// skip it in that case.
522542
if (this.environmentId === undefined) {
523543
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;
524552
}
525553

526554
// Register built-in audit hooks
@@ -1118,6 +1146,91 @@ export class ObjectQLPlugin implements Plugin {
11181146
}
11191147
}
11201148

1149+
/**
1150+
* Bridge ONE manifest's objects into the metadata service — the
1151+
* late-registration companion to {@link bridgeObjectsToMetadataService}.
1152+
*
1153+
* The one-shot startup bridge runs exactly once during `start()`, but
1154+
* manifests keep arriving after that: marketplace install and ledger
1155+
* rehydrate register through the `manifest` service on `kernel:ready` (or
1156+
* an HTTP request), so their objects landed in the SchemaRegistry only and
1157+
* every IMetadataService consumer (AI describe_object, Studio object
1158+
* lists, `metadata.listObjects`) missed them. This bridges exactly the
1159+
* objects the given manifest contributes, resolved through the registry so
1160+
* both `objects` forms (array / name-keyed map) and extension merges come
1161+
* out canonical, with `_packageId` stamped.
1162+
*
1163+
* The metadata service is resolved at CALL time, never captured at init:
1164+
* when this plugin inits, MetadataPlugin may not have registered it yet.
1165+
*
1166+
* Existing same-name entries are left alone UNLESS they carry the same
1167+
* `_packageId` — i.e. they are this bridge's own copy from a previous
1168+
* version of the same package. That keeps a hot marketplace upgrade fresh
1169+
* while never clobbering an authored / artifact-parsed definition.
1170+
*
1171+
* Registers `{ notify: false }` for the same reason as the startup bridge
1172+
* (#3112 notify contract): these definitions come OUT of the
1173+
* SchemaRegistry, so announcing would feed our own `subscribe('object')`
1174+
* handler right back into the registry and overwrite the objects' true
1175+
* package provenance with 'metadata-service'.
1176+
*
1177+
* Never throws — a bridge failure must not fail the install that
1178+
* triggered it.
1179+
*/
1180+
private async bridgeManifestObjectsToMetadataService(
1181+
ctx: PluginContext,
1182+
manifest: any,
1183+
): Promise<void> {
1184+
if (!this.bridgeLateManifests) return;
1185+
const packageId = manifest?.id || manifest?.name;
1186+
if (!packageId || !this.ql?.registry) return;
1187+
1188+
try {
1189+
let metadataService: any;
1190+
try {
1191+
metadataService = ctx.getService<any>('metadata');
1192+
} catch {
1193+
return; // no metadata service on this kernel — nothing to bridge into
1194+
}
1195+
if (!metadataService || typeof metadataService.register !== 'function') return;
1196+
1197+
const objects = this.ql.registry.getAllObjects(packageId);
1198+
let bridged = 0;
1199+
1200+
for (const obj of objects) {
1201+
try {
1202+
const existing = await metadataService.getObject(obj.name);
1203+
const ownPreviousCopy =
1204+
existing != null &&
1205+
(obj as any)._packageId !== undefined &&
1206+
(existing as any)._packageId === (obj as any)._packageId;
1207+
if (!existing || ownPreviousCopy) {
1208+
await metadataService.register('object', obj.name, obj, { notify: false });
1209+
bridged++;
1210+
}
1211+
} catch (e: unknown) {
1212+
ctx.logger.debug('Failed to bridge manifest object to metadata service', {
1213+
package: packageId,
1214+
object: obj.name,
1215+
error: e instanceof Error ? e.message : String(e),
1216+
});
1217+
}
1218+
}
1219+
1220+
if (bridged > 0) {
1221+
ctx.logger.info('Bridged late-registered manifest objects to metadata service', {
1222+
package: packageId,
1223+
count: bridged,
1224+
});
1225+
}
1226+
} catch (e: unknown) {
1227+
ctx.logger.debug('Failed to bridge manifest objects to metadata service', {
1228+
package: packageId,
1229+
error: e instanceof Error ? e.message : String(e),
1230+
});
1231+
}
1232+
}
1233+
11211234
/**
11221235
* True when a hook of this name is shipped by an installed CODE package —
11231236
* i.e. the SchemaRegistry holds a composite (`<packageId>:<name>`) artifact

0 commit comments

Comments
 (0)