Skip to content

Commit aa31569

Browse files
fix(objectql): withdrawing an extender package restores the owner's bridged object copy, so GET /meta/object/NAME drops the extension field at once (#24221)
Fixes #24182 Clause-②: no ## What this changes After `DELETE /api/v1/marketplace/install-local/:manifestId` of a package that extends another package's object, `GET /api/v1/meta/object/NAME` now serves the owner's body without the withdrawn extension's fields at once. The object listing and the data plane already did. A restart is no longer needed. - `packages/objectql/src/plugin.ts` (the bridge's owner): - `bridgeManifestObjectsToMetadataService` now records, for each package it bridges as an EXTENDER, the names of the objects whose copy it rewrote. Those are copies stamped with another package's id (the owner's) and re-registered with the merged body. The record is the new field `extendedObjectCopies`. - New `restoreExtendedObjectCopies(ctx, packageId)`, exposed on the `manifest` service beside `register`. Once the registry no longer holds the package, it re-registers each recorded copy with the registry's current definition (`currentObjectCopyBody`, the same shape the bridge writes), through the bridge's own verb (`register(..., { notify: false })`). It adds no second merge. - Same rule as the bridge's overwrite: only a copy whose `_packageId` is the registry owner's id is written, so an authored copy stays. A name the registry no longer serves is left to the owner's own withdrawal (PR #24181's half). A package the registry still holds is not withdrawn, and nothing is done. - Never rejects. A copy that cannot be written is logged once at `error` with the consequence and the remedy (restart), the level `followRuntimeObjectCopy` uses for the same registry/copy disagreement. - `packages/cloud-connection/src/marketplace-install-local-plugin.ts`: **the claim's declared cross-lane edit, the call only.** Right after `withdrawObjectCopies`, and only when the registry withdrew, the DELETE handler calls `manifest.restoreExtendedObjectCopies(manifestId)`. The DELETE answer is unchanged. - Pins: `packages/objectql/src/plugin.integration.test.ts` (the bridge tests, two cases) and `packages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts` (beside the #24070 pin, one case through the real DELETE). That file's `install`/`uninstall` helpers now take an optional manifest and id; the existing four cases call them unchanged. - `.changeset/24182-extender-withdrawal-restores-owner.md`: `patch` for `@objectstack/objectql` and `@objectstack/cloud-connection`. ## The mechanism, measured - **H1, confirmed.** After `registry.uninstallPackage(EXTENDER)` the registry already serves the owner's body without the extension field. The objectql pin asserts it: `registry.getObject('crm_contact').fields` has no `ext_score`. The stale state is only the owner's metadata-service copy, which the extender's bridge rewrote under the owner's stamp. The counterpart re-puts the registry's current definition, with no second merge. - **H2, falsified: there is no seam.** The install-local DELETE fires no kernel event: no `ctx.trigger` in the uninstall path, and `metadata:reloaded` is announced only by the hot install. `SchemaRegistry.uninstallPackage` emits nothing. And after the withdrawal the registry no longer says which objects the package extended. So the bridge has to remember what it rewrote, and the door that withdrew the package has to call its counterpart. The call is the one cloud-connection line block named above. - **H3, read from source, not measured live.** The protocol door `DELETE /packages/:id` (`packages/runtime/src/domains/packages.ts`) also calls `registry.uninstallPackage` and does not call the counterpart. It refuses an install-local package with `422 WRITABLE_PACKAGE_REQUIRED` (measured by PR #24181). A writable package's objects reach the metadata service through `followRuntimeObjectCopy`, not through this bridge, so the record holds nothing for it. Package disable of an extender was not measured. Both are in Acceptance notes, not widened into this PR. - **H4, the live re-measure** is below: the card's setup reproduces on the base, and the head fixes it. ## Tests (head `3a452eeae`) | run | result | |:--|:--| | objectql pin (`-t extender`, 2 cases) and cloud-connection meta-reads file (5 cases) | all pass | | ablation: the counterpart's guard line replaced by an unconditional early return (marker `ABLATION_24182`) via `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `8fbce83c` to `baaba360`), `@objectstack/objectql` rebuilt, `ablation-dist-preflight` marker present (exit 0) | **red**: objectql `serves the owner's body without the withdrawn field` failed (1 failed / 1 passed); cloud-connection `owner + extender installed, DELETE the extender` failed (1 failed / 4 passed). Both controls stayed green | | restore | `ablation-replace` restored: blob equals HEAD `8fbce83c`, `git diff HEAD` empty; rebuilt, `ablation-dist-preflight --absent` exit 0 | | `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2` | 438 files / 8243 tests passed | | `pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2` | 51 files / 601 tests passed | | `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/cloud-connection typecheck` | exit 0; objectql's test layer, which compiles `plugin.integration.test.ts` under `tsconfig.test.json`, held its debt | | `pnpm exec eslint --no-inline-config` over the four changed `.ts` files | exit 0 | The full-suite runs were taken before the last commit, which only types two slot lookups in the objectql pin. After it, the objectql typecheck and the whole `plugin.integration.test.ts` (46 / 46) were re-run green. ## The public door, before and after (live) Setup: `pnpm dev:crm -- --fresh -p PORT` from this worktree with `OS_CLOUD_URL=off` (the real `MetadataPlugin` and `MarketplaceInstallLocal`), signed in as the seeded dev admin. Package A `com.example.crmlite` (objects `crmlite_account`, `crmlite_contact`) and package B `com.example.crmliteext` (only an `objectExtensions` field `ext_score` on `crmlite_account`) are installed inline through install-local. Then B is DELETEd. - **Base** (`packages/objectql/src/plugin.ts` and the install-local plugin restored in the tree to `bb7ccde66`'s blobs, both packages rebuilt, `ablation-dist-preflight --absent` on `restoreExtendedObjectCopies` exit 0 for both): - before the DELETE, `GET /meta/object/crmlite_account` is `200` with `ext_score` and `name`; - `DELETE` answers `200`, `cleanups: [security.package-permissions: success]`; - after it, `GET /meta/object/crmlite_account` is **`200` with `ext_score`**. `GET /meta/object` lists `crmlite_account` without `ext_score`, and `GET /data/crmlite_account?fields=ext_score` answers `400`. This is the card's reading. - **Head** (rebuilt, preflight marker present in both `dist/`), the same sequence: - after the DELETE, `GET /meta/object/crmlite_account` is `200` **without `ext_score`**, `name` kept, `_packageId: com.example.crmlite`; - the listing and the data plane read as on the base, and the DELETE answer is byte-identical. - One deviation from the card's setup: `os dev` (through `pnpm dev:crm`), not `os start`. Both arm the late bridge this fix lives in. - The probe script lived in the session scratchpad. Each server was stopped by its recorded process group, and the tree was restored to HEAD (`git diff HEAD` empty) before anything else. ## Gates (after the final commit, `3a452eeae`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 67 commands. All 67 exit 0, each code captured before any pipe. - On the first pass, `check:slot-lookup` was red: this PR's objectql pin added two untyped `getService` lookups (23 to 25). They are typed now (`IMetadataService`, `ObjectQL`). The ratchet holds at 101 unswept, none new. - `check-engine-split-ratio --days 90` (exit 2) and `check-plugin-teardown-shape --self-test` (exit 3) refused on the shallow clone. After `git fetch --shallow-since=2026-07-12 origin main`, both exit 0. - `dispatch-gates.mjs --ran`: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero (all 67 carry an exit code). - Not run locally and declared to CI: `test:repo`, the CLI `integration` tier (no spawn entry touched), the type-check lanes beyond the two packages' own `typecheck`, the dogfood shards and the repo-wide `pnpm lint`. ## Acceptance notes - **Read-only inference, not measured.** A hot upgrade of an extender whose new version DROPS an extension re-bridges only the objects the new version still contributes to. The owner's copy would keep the dropped field until the extender is uninstalled (the record keeps the name, so this counterpart then restores it) or the runtime restarts. That is the same family on another door. Not fixed here. - **Read-only inference, not measured (H3).** `DELETE /packages/:id` withdraws through the same registry verb without calling this counterpart. Today no bridged extender reaches it: install-local packages are refused there, and writable packages are not bridged by this path. Package disable of an extender was not measured. - The record is per process. A copy rewritten by the boot-time one-shot bridge is not recorded. Every install-local package (hot install and ledger rehydrate) registers after `start()` has armed the late bridge, so its rewrites are recorded. A boot-time code package is not withdrawn by this door. --- _Generated by [Claude Code](https://claude.ai/code/session_016fHjgVn91KVgq3oTNuGhsu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b0a1df0 commit aa31569

5 files changed

Lines changed: 215 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/cloud-connection': patch
4+
---
5+
6+
Clause-②: no
7+
8+
fix(objectql): withdrawing a package that extends another package's object puts the owner's definition back at `GET /api/v1/meta/object/NAME` at once, not at the next restart
9+
10+
- **What was wrong.** When a package whose content is an `objectExtensions` field on another package's object is registered on a started kernel, the ObjectQL bridge rewrites the OWNER's copy of that object in the `metadata` service with the merged body, extension included. `DELETE /api/v1/marketplace/install-local/:manifestId` of the extender withdrew its contribution from the registry, so the object listing and the data plane dropped the field, but nothing rewrote the owner's copy. The by-name read asks that copy first, so `GET /api/v1/meta/object/NAME` kept serving the withdrawn field until the runtime restarted.
11+
- **What changes.** The ObjectQL bridge remembers which owners' copies each package's extensions rewrote, and the `manifest` service gains `restoreExtendedObjectCopies(packageId)`: once the registry no longer holds the package, it re-registers each such copy with the registry's current definition. The install-local DELETE calls it right after the registry withdrawal. The by-name read then serves the owner's body without the extension's fields, as the listing and the data plane already did.
12+
- **Not touched.** Only a copy stamped with the registry owner's id is rewritten, the same rule the bridge overwrites by, so an authored copy stays. The DELETE answer is unchanged. A copy that cannot be restored is logged once at `error`, naming the objects and the remedy: restart the runtime.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,6 +2001,14 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
20012001
const declared = captureUninstallDeclared(registry, manifestId);
20022002
const withdrawal = this.withdrawFromRunningKernel(ctx, manifestId);
20032003
const copies = withdrawal ? undefined : await this.withdrawObjectCopies(ctx, manifestId);
2004+
// [#24182] …and the copies of OTHER packages' objects that this one's
2005+
// extensions rewrote: the bridge that wrote them restores the owner's
2006+
// definition (`manifest.restoreExtendedObjectCopies`, never rejects).
2007+
if (!withdrawal) {
2008+
let manifestService: { restoreExtendedObjectCopies?(id: string): Promise<void> } | undefined;
2009+
try { manifestService = ctx.getService('manifest') as typeof manifestService; } catch { /* no engine — nothing bridged */ }
2010+
await manifestService?.restoreExtendedObjectCopies?.(manifestId);
2011+
}
20042012
const cleanups = [
20052013
...(withdrawal ? [withdrawal] : []),
20062014
...(copies ? [copies] : []),

‎packages/cloud-connection/src/marketplace-install-local-uninstall-meta-reads.test.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,9 @@ async function bootPlugin(opts: { refuseUnregister?: string; before?: (metadata:
192192
await plugin.start(ctx as never);
193193
await hooks.get('kernel:ready')?.();
194194

195-
const install = () => routes.get(`POST ${ROUTE}`)!(makeC(`http://localhost:3000${ROUTE}`, { body: { manifest: MANIFEST } }));
196-
const uninstall = () => routes.get(`DELETE ${ROUTE}/:manifestId`)!(
197-
makeC(`http://localhost:3000${ROUTE}/${APP_ID}`, { manifestId: APP_ID }),
195+
const install = (manifest: Bag = MANIFEST) => routes.get(`POST ${ROUTE}`)!(makeC(`http://localhost:3000${ROUTE}`, { body: { manifest } }));
196+
const uninstall = (id: string = APP_ID) => routes.get(`DELETE ${ROUTE}/:manifestId`)!(
197+
makeC(`http://localhost:3000${ROUTE}/${id}`, { manifestId: id }),
198198
);
199199
/** The uninstall's own warnings: each one opens with `uninstalled <id>`. */
200200
const warnings = () => logger.warn.mock.calls
@@ -329,3 +329,44 @@ describe('#24070: an install-local uninstall withdraws the package from every me
329329
expect(warned[0]).toContain(OBJECTS[0]);
330330
});
331331
});
332+
333+
/** [#24182] A package whose only content is a field on another package's object. */
334+
const EXTENDER_ID = 'com.example.crmliteext';
335+
const EXTENDER: Bag = {
336+
id: EXTENDER_ID,
337+
version: '1.0.0',
338+
type: 'app',
339+
name: 'CRM Lite Score',
340+
objectExtensions: [{ extend: OBJECTS[0], fields: { ext_score: { type: 'number', label: 'Score' } } }],
341+
};
342+
343+
/** The field names `GET /meta/object/NAME` serves for `name`, read through the protocol the route calls. */
344+
async function servedFields(protocol: any, name: string): Promise<string[]> {
345+
const res = await protocol.getMetaItem({ type: 'object', name });
346+
return Object.keys(res?.item?.fields ?? {});
347+
}
348+
349+
describe('#24182: an install-local uninstall of an extender restores the owner\'s object at the by-name read', () => {
350+
it('owner + extender installed → DELETE the extender: the by-name read serves the owner\'s body without the field', async () => {
351+
const h = await bootPlugin();
352+
expect((await h.install()).status).toBe(200);
353+
const extended = await h.install(EXTENDER);
354+
expect(extended.status, JSON.stringify(extended.payload)).toBe(200);
355+
// Identity: the extender's bridge rewrote the OWNER's copy, which the
356+
// by-name read serves before the registry.
357+
expect((await h.copyOf(OBJECTS[0]))?._packageId).toBe(APP_ID);
358+
expect(await servedFields(h.protocol, OBJECTS[0])).toEqual(expect.arrayContaining(['name', 'ext_score']));
359+
360+
const res = await h.uninstall(EXTENDER_ID);
361+
362+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
363+
expect(res.payload.data.cleanups).toEqual([]);
364+
const fields = await servedFields(h.protocol, OBJECTS[0]);
365+
expect(fields).not.toContain('ext_score');
366+
expect(fields).toEqual(Object.keys(h.ql.registry.getObject(OBJECTS[0])!.fields ?? {}));
367+
// CONTROL: the owner's own field, its copy's stamp and its package stay.
368+
expect(fields).toContain('name');
369+
expect((await h.copyOf(OBJECTS[0]))?._packageId).toBe(APP_ID);
370+
expect(await listed(h.protocol, 'object')).toEqual(expect.arrayContaining(OBJECTS));
371+
});
372+
});

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { ObjectQLPlugin } from '../src/plugin';
66
import { ObjectSchema } from '@objectstack/spec/data';
77
import { readServiceSelfInfo } from '@objectstack/spec/api';
88
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
9+
import type { IMetadataService } from '@objectstack/spec/contracts';
10+
import type { ObjectQL } from './engine';
911

1012
describe('ObjectQLPlugin - Metadata Service Integration', () => {
1113
let kernel: ObjectKernel;
@@ -1900,6 +1902,64 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
19001902
expect(bridged.label).toBe('Contact');
19011903
});
19021904

1905+
// [#24182] An extender's bridge rewrites the OWNER's copy with the merged
1906+
// body; withdrawing the extender must put the owner's definition back, or
1907+
// `GET /meta/object/NAME` (which reads the copy first) keeps serving the
1908+
// withdrawn field until a restart.
1909+
describe('withdrawing an extender restores the owner\'s copy', () => {
1910+
type WithdrawingManifestService = ManifestService & { restoreExtendedObjectCopies(id: string): Promise<void> };
1911+
const extenderManifest = () => ({
1912+
id: 'com.acme.crmext',
1913+
name: 'acme_crmext',
1914+
version: '1.0.0',
1915+
type: 'app',
1916+
objectExtensions: [
1917+
{ extend: 'crm_contact', fields: { ext_score: { name: 'ext_score', label: 'Score', type: 'number' } } },
1918+
],
1919+
});
1920+
1921+
async function installBoth() {
1922+
await kernel.use(new ObjectQLPlugin());
1923+
await kernel.bootstrap();
1924+
const manifest = kernel.getService('manifest') as WithdrawingManifestService;
1925+
await manifest.register(crmManifest());
1926+
await manifest.register(extenderManifest());
1927+
const metadata = kernel.getService<IMetadataService>('metadata');
1928+
const registry = kernel.getService<ObjectQL>('objectql').registry;
1929+
// Identity: the extender's bridge rewrote the owner's copy.
1930+
const extended = await metadata.getObject('crm_contact') as any;
1931+
expect(extended._packageId).toBe('com.acme.crm');
1932+
expect(Object.keys(extended.fields)).toEqual(expect.arrayContaining(['name', 'ext_score']));
1933+
return { manifest, metadata, registry };
1934+
}
1935+
1936+
it('serves the owner\'s body without the withdrawn field, its own fields kept', async () => {
1937+
const { manifest, metadata, registry } = await installBoth();
1938+
1939+
registry.uninstallPackage('com.acme.crmext');
1940+
// The registry already serves the owner's body; only the copy lags.
1941+
expect(Object.keys(registry.getObject('crm_contact')!.fields ?? {})).not.toContain('ext_score');
1942+
await manifest.restoreExtendedObjectCopies('com.acme.crmext');
1943+
1944+
const restored = await metadata.getObject('crm_contact') as any;
1945+
expect(restored._packageId).toBe('com.acme.crm');
1946+
expect(restored.label).toBe('Contact');
1947+
// The registry's own body, field for field (its injected system fields
1948+
// included): the owner's `name` kept, the extension's `ext_score` gone.
1949+
expect(Object.keys(restored.fields)).toEqual(Object.keys(registry.getObject('crm_contact')!.fields ?? {}));
1950+
expect(Object.keys(restored.fields)).toContain('name');
1951+
expect(Object.keys(restored.fields)).not.toContain('ext_score');
1952+
});
1953+
1954+
it('CONTROL: a package the registry still holds is not withdrawn — the copy keeps its field', async () => {
1955+
const { manifest, metadata } = await installBoth();
1956+
1957+
await manifest.restoreExtendedObjectCopies('com.acme.crmext');
1958+
1959+
expect(Object.keys(((await metadata.getObject('crm_contact')) as any).fields)).toContain('ext_score');
1960+
});
1961+
});
1962+
19031963
it('boot-time registrations still flow through the one-shot startup bridge', async () => {
19041964
await kernel.use(new ObjectQLPlugin());
19051965
await kernel.use({

‎packages/objectql/src/plugin.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,16 @@ export class ObjectQLPlugin implements Plugin {
376376
*/
377377
private bridgeLateManifests = false;
378378

379+
/**
380+
* [#24182] For each package the late bridge bridged as an EXTENDER, the
381+
* names of the objects whose copy it rewrote: copies stamped with another
382+
* package's id (the owner's), re-registered with the merged body that
383+
* carries this package's extension. Read and cleared by
384+
* {@link restoreExtendedObjectCopies} when the package is withdrawn, the one
385+
* moment the registry no longer says which objects it extended.
386+
*/
387+
private readonly extendedObjectCopies = new Map<string, Set<string>>();
388+
379389
/**
380390
* Set at `kernel:ready`, once the boot audit has found every picklist a
381391
* field names: from then on an artifact registered through the `manifest`
@@ -766,7 +776,14 @@ export class ObjectQLPlugin implements Plugin {
766776
? [...ordered, { id: residual.ownerId }]
767777
: ordered;
768778
return this.bridgeArtifactObjectsToMetadataService(ctx, bridged);
769-
}
779+
},
780+
// [#24182] The withdrawal counterpart of the bridge's extender rewrite:
781+
// a door that withdrew a package from the registry calls this so the
782+
// copies of OTHER packages' objects that the package's extensions
783+
// rewrote serve the registry's definition again. Never rejects. See
784+
// {@link restoreExtendedObjectCopies}.
785+
restoreExtendedObjectCopies: (packageId: string): Promise<void> =>
786+
this.restoreExtendedObjectCopies(ctx, packageId),
770787
});
771788

772789
ctx.logger.info('ObjectQL engine registered', {
@@ -2792,6 +2809,14 @@ export class ObjectQLPlugin implements Plugin {
27922809
if (!existing || ownPreviousCopy) {
27932810
await metadataService.register('object', obj.name, obj, { notify: false });
27942811
bridged++;
2812+
// [#24182] Another package owns this object: the copy just written
2813+
// carries this package's extension. Remembered, so its withdrawal
2814+
// can put the owner's definition back.
2815+
if ((obj as any)._packageId !== packageId) {
2816+
let names = this.extendedObjectCopies.get(packageId);
2817+
if (!names) this.extendedObjectCopies.set(packageId, names = new Set());
2818+
names.add(obj.name);
2819+
}
27952820
}
27962821
} catch (e: unknown) {
27972822
ctx.logger.debug('Failed to bridge manifest object to metadata service', {
@@ -2816,6 +2841,71 @@ export class ObjectQLPlugin implements Plugin {
28162841
}
28172842
}
28182843

2844+
/**
2845+
* [#24182] The withdrawal counterpart of {@link bridgeManifestObjectsToMetadataService}
2846+
* for a package that EXTENDS objects other packages own: put the registry's
2847+
* current definition back into each owner's copy the package's bridge rewrote.
2848+
*
2849+
* When an extender is bridged, the objects it contributes to resolve to the
2850+
* merged body stamped with the OWNER's id, and the bridge re-registers the
2851+
* owner's copy with it (its own-previous-copy rule is keyed on that stamp).
2852+
* Withdrawing the extender (`SchemaRegistry.uninstallPackage`) drops its
2853+
* contribution from the registry, so the object listing and the data door
2854+
* stop serving the extension's fields; but the copy keeps them, and
2855+
* `GET /meta/object/NAME` reads the copy before the registry, so it kept
2856+
* serving them until a restart. A withdrawal of the package's OWN copies
2857+
* never touches these: they are stamped with another package's id.
2858+
*
2859+
* The names come from {@link extendedObjectCopies}, recorded when the bridge
2860+
* wrote each copy. Only a copy the bridge itself would overwrite is written
2861+
* (its stamp is the registry owner's), through the bridge's own verb, so an
2862+
* authored copy stays as it is; a name the registry no longer serves is left
2863+
* to the owner's own withdrawal. A package the registry still holds is not
2864+
* withdrawn, and nothing is done.
2865+
*
2866+
* Never rejects: the withdrawal has already happened. A copy that could not
2867+
* be written disagrees with what the registry serves while everything looks
2868+
* normal, so that is said once at `error`, with the remedy.
2869+
*/
2870+
private async restoreExtendedObjectCopies(ctx: PluginContext, packageId: string): Promise<void> {
2871+
const names = this.extendedObjectCopies.get(packageId);
2872+
const registry = this.ql?.registry;
2873+
if (!names || !registry || registry.getPackage(packageId) !== undefined) return;
2874+
this.extendedObjectCopies.delete(packageId);
2875+
let metadataService: IMetadataService | undefined;
2876+
try {
2877+
metadataService = ctx.getService<IMetadataService>('metadata');
2878+
} catch {
2879+
return; // no metadata service on this kernel — there is no copy to restore
2880+
}
2881+
if (!metadataService || typeof metadataService.register !== 'function') return;
2882+
2883+
const failed: string[] = [];
2884+
for (const name of names) {
2885+
try {
2886+
const served = this.currentObjectCopyBody(name);
2887+
const owner = (served as any)?._packageId;
2888+
if (!served || owner === undefined) continue;
2889+
const existing = await metadataService.getObject(name);
2890+
if (existing == null || (existing as any)._packageId !== owner) continue;
2891+
await metadataService.register('object', name, served, { notify: false });
2892+
} catch {
2893+
failed.push(name);
2894+
}
2895+
}
2896+
if (failed.length > 0) {
2897+
ctx.logger.error(
2898+
`[ObjectQLPlugin] package '${packageId}' was withdrawn, but the metadata service's copy of the object(s) it extended `
2899+
+ `${failed.map((n) => `'${n}'`).join(', ')} could not be restored: the object listing and the data door already `
2900+
+ 'serve them without its fields, while GET /api/v1/meta/object/NAME keeps serving those fields until a restart. '
2901+
+ 'Fix: restart this process (the boot bridge re-copies every object); if it recurs, check that the metadata '
2902+
+ 'service accepts registration.',
2903+
undefined,
2904+
{ package: packageId, objects: failed },
2905+
);
2906+
}
2907+
}
2908+
28192909
/**
28202910
* True when a hook of this name is shipped by an installed CODE package —
28212911
* i.e. the SchemaRegistry holds a composite (`<packageId>:<name>`) artifact

0 commit comments

Comments
 (0)