diff --git a/.changeset/unregister-items-by-package.md b/.changeset/unregister-items-by-package.md new file mode 100644 index 0000000000..3415fa84e1 --- /dev/null +++ b/.changeset/unregister-items-by-package.md @@ -0,0 +1,54 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): uninstalling a package now removes the non-object metadata it shipped (#7221) + +"Unregister all metadata from a package" reached only `objectContributors`, so +every non-object item a package shipped — its `page`, `view`, `flow`, `app`, +`api` … — stayed registered and fully resolvable after the package was gone. +Not a stale-cache nuisance: an uninstall that leaves the package's UI and API +metadata installed. + +A package writes into two stores. `SchemaRegistry.unregisterObjectsByPackage` +walks the contributor list; everything else lives in the generic `metadata` map +under the composite `${packageId}:${name}` key `registerItem` builds, and no +verb removed those. Measured on the real registry: after +`uninstallPackage('crm')` the package record was gone while +`getItem('page', 'home')` kept serving the uninstalled package's page and +`metadata.get('flow')` still held `crm:onboard`, for the life of the process. +The same call through `MetadataFacade.unregisterPackage` additionally left the +generic-map half of the package's objects behind as a genuine orphan. + +`SchemaRegistry.unregisterItemsByPackage(packageId)` is the missing verb, and it +sits on the registry rather than privately on the facade because **both** callers +were measured to have the gap — `uninstallPackage` is registry-direct and shares +it exactly. A private copy in the facade would have been a second expression of +the same package-ownership rule, and would have left every registry-direct +uninstall still half-done. Membership is the exact inverse of the construction in +`registerItem`, so a discriminated type's whole i18n bundle leaves with the +package that shipped it, and a scoped package id (`@acme/crm`) is handled by the +same relation. + +**Tenant overlays are deliberately kept.** A bare-key entry is the ADR-0005 +runtime/DB overlay slot — a tenant's own customization, carrying no package +provenance and with no separate contributor list holding a durable copy. An +uninstall that deleted it would take tenant-authored data along with the package +it merely overlaid, so the sweep is scoped to composite keys only. The +consequence — an overlay that now layers over nothing — is made **loud** rather +than silently deleted or silently kept, the same house pattern as ADR-0029 +D9.5's orphan-overlay violation: the verb warns naming every orphan it left and +returns them as `orphanedOverlays` for a caller that wants to act. What nothing +yet does with that report is filed separately as #7951. + +This is deliberately **not** the object-side D9.7 rule ("an overlay layer leaves +with the base it layers over"), which is safe only because an object overlay +layer is a runtime projection of a `sys_metadata` row the removal does not touch. + +Ordering: in both callers the item sweep runs after the object verb, because that +one can refuse (ADR-0029 extenders) — a refused uninstall removes nothing at all. + +Unaffected: another package's same-named items (including a package id that is a +string prefix of another), runtime-authored items with no package, and the +persisted `sys_metadata` rows — a distinct mechanism this change does not reach +into. diff --git a/packages/objectql/src/metadata-facade.ts b/packages/objectql/src/metadata-facade.ts index 10d796c232..d6ce8037f2 100644 --- a/packages/objectql/src/metadata-facade.ts +++ b/packages/objectql/src/metadata-facade.ts @@ -258,10 +258,33 @@ export class MetadataFacade { } /** - * Unregister all metadata from a package + * Unregister all metadata from a package. + * + * [#7221] Both stores, for the same reason {@link register} and + * {@link unregister} reach both: `unregisterObjectsByPackage` walks + * `objectContributors` alone, so this verb — whose `IMetadataService` + * contract reads "Unregister all metadata items from a specific package" — + * used to leave every non-object item the package shipped (`page`, `view`, + * `flow`, `app`, `api` …) fully resolvable through this class's own `get`, + * `list`, `listNames` and `exists`, plus the generic-map half of its + * objects, which {@link registerObjectBothPlaces} writes. A half-uninstall, + * silently. + * + * `SchemaRegistry.unregisterItemsByPackage` is the registry-side verb rather + * than a scan private to this class, because `SchemaRegistry.uninstallPackage` + * was measured to have the identical gap — a second copy of the + * package-ownership rule here would be the #6808 drift, and would have left + * the registry-direct caller half-done. It deliberately keeps bare-key + * ADR-0005 runtime/DB overlays and warns about the ones it orphans; see its + * header for why that is loudness rather than a silent delete. + * + * Ordering mirrors {@link unregister}: the object verb runs first because it + * is the half that can refuse (ADR-0029 extenders), so a refusal removes + * nothing at all rather than taking the generic half with it. */ async unregisterPackage(packageName: string): Promise { this.registry.unregisterObjectsByPackage(packageName); + this.registry.unregisterItemsByPackage(packageName); } /** diff --git a/packages/objectql/src/registry-unregister-items-by-package.test.ts b/packages/objectql/src/registry-unregister-items-by-package.test.ts new file mode 100644 index 0000000000..ddc484c926 --- /dev/null +++ b/packages/objectql/src/registry-unregister-items-by-package.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { SchemaRegistry } from './registry.js'; +import { MetadataFacade } from './metadata-facade.js'; + +/** + * #7221 — `unregisterItemsByPackage`, the package-addressed removal verb for + * the GENERIC metadata map. + * + * A package writes into two stores, and only one of them had a + * package-addressed removal verb. `unregisterObjectsByPackage` walks + * `objectContributors`; every non-object item a package ships — `page`, + * `view`, `flow`, `app`, `api` … — lives in the generic `metadata` map under + * the composite `${packageId}:${name}` key `registerItem` builds, and nothing + * removed those. So "unregister all metadata from a package" left the + * package's UI and API metadata fully resolvable, and left the generic-map + * half of its objects behind as a genuine orphan. + * + * Both callers had the gap — measured, not assumed, which is why the verb sits + * on the registry rather than privately on the facade: + * + * - `MetadataFacade.unregisterPackage` (the published `IMetadataService` + * member whose contract reads "Unregister all metadata items from a + * specific package") + * - `SchemaRegistry.uninstallPackage` — registry-direct, same one-verb call + * + * The bare-key half is deliberately NOT taken: a bare key is the ADR-0005 + * runtime/DB overlay slot, a tenant's own customization with no package + * provenance, and an uninstall does not get to delete tenant data. The + * consequence — an overlay that now layers over nothing — is made LOUD, the + * same house pattern as ADR-0029 D9.5's orphan-overlay violation and as + * `unregisterObjectsByPackage`'s refusal, rather than silently deleted or + * silently kept. + */ + +const quiet = () => { + const r = new SchemaRegistry({ multiTenant: false }); + (r as any).logLevel = 'silent'; + return r; +}; + +const objectBody = (name: string, field = 'name') => + ({ + name, + label: name, + fields: { [field]: { name: field, type: 'text', label: field } }, + }) as any; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('#7221 — SchemaRegistry.unregisterItemsByPackage', () => { + it('takes every generic item the package shipped, across types', () => { + const reg = quiet(); + reg.registerItem('page', { name: 'home' }, 'name', 'crm'); + reg.registerItem('view', { name: 'list' }, 'name', 'crm'); + reg.registerItem('flow', { name: 'onboard' }, 'name', 'crm'); + reg.registerItem('api', { name: 'sync' }, 'name', 'crm'); + + const { removed, orphanedOverlays } = reg.unregisterItemsByPackage('crm'); + + expect(reg.getItem('page', 'home')).toBeUndefined(); + expect(reg.getItem('view', 'list')).toBeUndefined(); + expect(reg.getItem('flow', 'onboard')).toBeUndefined(); + expect(reg.getItem('api', 'sync')).toBeUndefined(); + expect(removed.sort()).toEqual([ + 'api/crm:sync', + 'flow/crm:onboard', + 'page/crm:home', + 'view/crm:list', + ]); + expect(orphanedOverlays).toEqual([]); + }); + + it('leaves a DIFFERENT package’s same-named items alone', () => { + const reg = quiet(); + reg.registerItem('page', { name: 'home' }, 'name', 'crm'); + reg.registerItem('page', { name: 'home' }, 'name', 'helpdesk'); + + reg.unregisterItemsByPackage('crm'); + + // The surviving package still resolves its own copy, package-scoped. + expect(reg.getItem('page', 'home', 'helpdesk')).toMatchObject({ name: 'home' }); + expect([...(reg as any).metadata.get('page').keys()]).toEqual(['helpdesk:home']); + }); + + it('is not fooled by a package id that PREFIXES another', () => { + const reg = quiet(); + reg.registerItem('page', { name: 'home' }, 'name', 'crm'); + reg.registerItem('page', { name: 'home' }, 'name', 'crm-pro'); + + reg.unregisterItemsByPackage('crm'); + + // `crm-pro:home` does not start with `crm:` — the separator is part of the + // prefix, so a package id that is a string prefix of another is unaffected. + expect([...(reg as any).metadata.get('page').keys()]).toEqual(['crm-pro:home']); + }); + + it('takes a scoped package id’s items (the id contains @ and /)', () => { + const reg = quiet(); + reg.registerItem('page', { name: 'home' }, 'name', '@acme/crm'); + + const { removed } = reg.unregisterItemsByPackage('@acme/crm'); + + expect(removed).toEqual(['page/@acme/crm:home']); + expect((reg as any).metadata.get('page').size).toBe(0); + }); + + it('takes a discriminated type’s whole bundle (#7730 i18n keys)', () => { + const reg = quiet(); + reg.registerItem('email_template', { name: 'auth.welcome', locale: 'en-US' }, 'name', 'crm'); + reg.registerItem('email_template', { name: 'auth.welcome', locale: 'zh-CN' }, 'name', 'crm'); + + const { removed } = reg.unregisterItemsByPackage('crm'); + + // The discriminator rides at the end of the composite key, so every member + // leaves with the package that shipped the bundle. + expect(removed.sort()).toEqual([ + 'email_template/crm:auth.welcome@en-US', + 'email_template/crm:auth.welcome@zh-CN', + ]); + expect((reg as any).metadata.get('email_template').size).toBe(0); + }); + + it('is idempotent and silent for a package that shipped nothing', () => { + const reg = quiet(); + reg.registerItem('page', { name: 'home' }, 'name', 'crm'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(reg.unregisterItemsByPackage('nobody')).toEqual({ + removed: [], + orphanedOverlays: [], + }); + expect(reg.getItem('page', 'home')).toMatchObject({ name: 'home' }); + expect(warn).not.toHaveBeenCalled(); + }); + + describe('the bare-key ruling — tenant overlays are kept, and said out loud', () => { + it('KEEPS the ADR-0005 bare-key overlay and reports it as orphaned', () => { + const reg = quiet(); + // The packaged item, and a tenant's runtime/DB row overlaying it. + reg.registerItem('page', { name: 'home', title: 'Packaged' }, 'name', 'crm'); + reg.registerItem('page', { name: 'home', title: 'Tenant edit' }, 'name'); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { removed, orphanedOverlays } = reg.unregisterItemsByPackage('crm'); + + // The package's own copy went. + expect(removed).toEqual(['page/crm:home']); + // The tenant's did NOT — an uninstall does not delete tenant-authored data. + expect(reg.getItem('page', 'home')).toMatchObject({ title: 'Tenant edit' }); + // …and the consequence is LOUD rather than silent, naming the offender. + expect(orphanedOverlays).toEqual(['page/home']); + expect(warn).toHaveBeenCalledTimes(1); + const message = warn.mock.calls[0][0] as string; + expect(message).toContain('page/home'); + expect(message).toContain('crm'); + expect(message).toMatch(/re-install the package that owns it, or delete the\s+sys_metadata row/); + }); + + it('says nothing when the package’s items had no overlay under them', () => { + const reg = quiet(); + reg.registerItem('page', { name: 'home' }, 'name', 'crm'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { orphanedOverlays } = reg.unregisterItemsByPackage('crm'); + + expect(orphanedOverlays).toEqual([]); + expect(warn).not.toHaveBeenCalled(); + }); + + it('matches the overlay slot by discriminator, not by bare name', () => { + const reg = quiet(); + reg.registerItem('email_template', { name: 'auth.welcome', locale: 'zh-CN' }, 'name', 'crm'); + // A tenant row for a DIFFERENT locale of the same bundle is not the slot + // the package's `zh-CN` member was layered under. + reg.registerItem('email_template', { name: 'auth.welcome', locale: 'en-US' }, 'name'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(reg.unregisterItemsByPackage('crm').orphanedOverlays).toEqual([]); + + reg.registerItem('email_template', { name: 'auth.welcome', locale: 'en-US' }, 'name', 'crm'); + expect(reg.unregisterItemsByPackage('crm').orphanedOverlays).toEqual([ + 'email_template/auth.welcome@en-US', + ]); + }); + }); +}); + +describe('#7221 — SchemaRegistry.uninstallPackage closes the same gap', () => { + it('removes the package’s generic items, not just its objects and record', () => { + const reg = quiet(); + reg.installPackage({ id: 'crm', name: 'CRM', version: '1.0.0' } as any); + reg.registerItem('page', { name: 'home' }, 'name', 'crm'); + reg.registerItem('flow', { name: 'onboard' }, 'name', 'crm'); + reg.registerObject(objectBody('contact'), 'crm'); + + expect(reg.uninstallPackage('crm')).toBe(true); + + // The measurement that placed the verb on the registry: before this, the + // package record was gone while `getItem` kept serving the package's page. + expect(reg.getItem('page', 'home')).toBeUndefined(); + expect(reg.getItem('flow', 'onboard')).toBeUndefined(); + expect(reg.getPackage('crm')).toBeUndefined(); + expect(reg.getObject('contact')).toBeUndefined(); + }); + + it('removes NOTHING when the object half refuses (ADR-0029 extenders)', () => { + const reg = quiet(); + reg.installPackage({ id: 'crm', name: 'CRM', version: '1.0.0' } as any); + reg.registerItem('page', { name: 'home' }, 'name', 'crm'); + reg.registerObject(objectBody('contact'), 'crm'); + reg.registerObject(objectBody('contact', 'extra'), 'analytics', undefined, 'extend'); + + expect(() => reg.uninstallPackage('crm')).toThrow(/extended by analytics/); + + // The item sweep runs AFTER the verb that can refuse, so a refused + // uninstall leaves the generic half intact rather than half-removing. + expect(reg.getItem('page', 'home')).toMatchObject({ name: 'home' }); + expect(reg.getPackage('crm')).toBeDefined(); + }); + + it('keeps the package’s own `package` record addressable by its bare id', () => { + const reg = quiet(); + reg.installPackage({ id: 'crm', name: 'CRM', version: '1.0.0' } as any); + // The sweep is prefix-scoped to `crm:`, so the bare-keyed package record is + // not collateral — `uninstallPackage` still owns removing it, and reports + // `true` because it found it. + expect(reg.uninstallPackage('crm')).toBe(true); + expect(reg.getPackage('crm')).toBeUndefined(); + }); +}); + +describe('#7221 — MetadataFacade.unregisterPackage', () => { + const facadeOf = () => { + const reg = quiet(); + return { reg, facade: new MetadataFacade(reg) }; + }; + + it('a package’s page/view/flow no longer resolve through the facade', async () => { + const { facade } = facadeOf(); + await facade.register('page', 'home', { name: 'home', _packageId: 'crm' }); + await facade.register('view', 'list', { name: 'list', _packageId: 'crm' }); + await facade.register('flow', 'onboard', { name: 'onboard', _packageId: 'crm' }); + + await facade.unregisterPackage('crm'); + + // Every read surface the contract names — this is the load-bearing half of + // the defect: uninstall leaving the package's UI metadata installed. + expect(await facade.get('page', 'home')).toBeUndefined(); + expect(await facade.get('view', 'list')).toBeUndefined(); + expect(await facade.get('flow', 'onboard')).toBeUndefined(); + expect(await facade.exists('page', 'home')).toBe(false); + expect(await facade.listNames('page')).toEqual([]); + expect(await facade.list('view')).toEqual([]); + }); + + it('takes BOTH halves of a facade-registered object', async () => { + const { reg, facade } = facadeOf(); + await facade.register('object', 'contact', { + ...objectBody('contact'), + _packageId: 'crm', + }); + + await facade.unregisterPackage('crm'); + + // The contributor half (what every object read resolves)… + expect(await facade.getObject('contact')).toBeUndefined(); + expect(await facade.exists('object', 'contact')).toBe(false); + // …and the generic-map half `registerObjectBothPlaces` wrote, which the + // object verb alone never reached. + expect([...(reg as any).metadata.get('object').keys()]).toEqual([]); + }); + + it('leaves another package’s items registered', async () => { + const { facade } = facadeOf(); + await facade.register('page', 'home', { name: 'home', _packageId: 'crm' }); + await facade.register('page', 'dash', { name: 'dash', _packageId: 'helpdesk' }); + + await facade.unregisterPackage('crm'); + + expect(await facade.get('page', 'home')).toBeUndefined(); + expect(await facade.get('page', 'dash')).toMatchObject({ name: 'dash' }); + }); + + it('keeps a tenant’s runtime-authored item, which has no package to leave with', async () => { + const { facade } = facadeOf(); + await facade.register('page', 'home', { name: 'home', _packageId: 'crm' }); + // No `_packageId` — runtime-authored, stored under the bare key. + await facade.register('page', 'custom', { name: 'custom' }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await facade.unregisterPackage('crm'); + + expect(await facade.get('page', 'home')).toBeUndefined(); + expect(await facade.get('page', 'custom')).toMatchObject({ name: 'custom' }); + }); + + it('keeps a tenant’s OVERLAY of a packaged item — the bare-key ruling, at this seam', async () => { + const { facade } = facadeOf(); + await facade.register('page', 'home', { name: 'home', title: 'Packaged', _packageId: 'crm' }); + // Same name, no `_packageId`: the ADR-0005 overlay slot, not an unrelated + // item. This is the case a destructive sweep would silently take, so the + // facade pins it too and not only the registry verb. + await facade.register('page', 'home', { name: 'home', title: 'Tenant edit' }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await facade.unregisterPackage('crm'); + + expect(await facade.get('page', 'home')).toMatchObject({ title: 'Tenant edit' }); + expect(warn.mock.calls.some(([m]) => String(m).includes('page/home'))).toBe(true); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index b2fb0cce3c..88df81cb53 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -2067,6 +2067,119 @@ export class SchemaRegistry { console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`); } + /** + * [#7221] Unregister every GENERIC metadata item a package shipped — the + * counterpart of {@link unregisterObjectsByPackage}, which is addressed by + * package too but reaches only `objectContributors`. + * + * ## Why this exists at all + * + * A package writes into TWO stores, and until this method only one of them + * had a package-addressed removal verb. `unregisterObjectsByPackage` walks + * `objectContributors`; every non-object item a package ships — its `page`, + * `view`, `flow`, `app`, `api` … — lives in the generic `metadata` map under + * the composite key {@link registerItem} builds, and nothing removed those. + * Measured on the real registry before this method existed: after + * `uninstallPackage('crm')` the package record was gone while + * `getItem('page', 'home')` kept serving the uninstalled package's page, and + * `metadata.get('flow')` still held `crm:onboard`, for the life of the + * process. That is not a stale cache — it is an uninstall that leaves the + * package's UI and API metadata installed. + * + * It is placed HERE, on the registry, rather than privately on + * `MetadataFacade`, because the measurement above says both callers have the + * gap: {@link uninstallPackage} is registry-direct and shares it exactly. A + * private copy in the facade would have been a second expression of the + * "which key belongs to which package" rule (#6808's drift), and would have + * left every registry-direct uninstall still half-done. + * + * ## Identity, expressed once + * + * Membership is the exact inverse of the construction in `registerItem`: + * that method stores under `${packageId}:${baseName}` (plus the `@` + * suffix for a discriminated type), so a key belongs to this package iff it + * starts with `${packageId}:`. The discriminator rides along at the end and + * needs no special handling here — the whole bundle leaves with the package + * that shipped it, which is the same "a name addresses the BUNDLE" rule + * `unregisterItem` applies. The prefix relation assumes a package id + * contains no `:`, the same assumption `getItem`'s composite scan already + * makes with `key.endsWith(':' + name)`. + * + * ## ⛔ What this deliberately does NOT take, and why it says so out loud + * + * BARE-key entries are left untouched. A bare key is the ADR-0005 + * runtime/DB overlay slot — a tenant's own customization, rehydrated from + * `sys_metadata` — and it carries no package provenance, so an uninstall + * that deleted it would take a tenant's authored data along with the package + * it merely overlaid. Nobody authorised that delete, and this verb does not + * invent the authorisation. + * + * That leaves a real consequence: an overlay whose base has just been + * uninstalled now layers over nothing. This registry's house pattern for + * exactly that state is to make it LOUD rather than to silently delete it or + * silently keep it — {@link assertSingleOwnerPerObject}'s ADR-0029 D9.5 + * orphan-overlay violation names the offender and tells the operator to + * "re-install the package that owns it, or delete the sys_metadata row", and + * {@link unregisterObjectsByPackage} refuses loudly rather than quietly + * tearing down an object other packages extend. So this method warns, naming + * every orphaned overlay it left behind, and returns them so a caller that + * wants to act on them can. + * + * ⚠️ Deliberately NOT the object-side D9.7 rule ("an overlay layer leaves + * with the base it layers over"). That rule is safe precisely because an + * object overlay LAYER is a runtime projection of a `sys_metadata` row the + * removal does not touch, so a re-install re-hydrates it and nothing durable + * is lost. A bare-key generic entry is the other way round: it IS the + * runtime face of that row and there is no separate contributor list holding + * the durable copy, so dropping it here would lose the tenant's edit. + * + * @param packageId The package being uninstalled. + * @returns `removed` — the storage keys taken, as `type/key`; and + * `orphanedOverlays` — the bare-key overlays deliberately left behind that + * now have no base, as `type/name`. + */ + unregisterItemsByPackage(packageId: string): { removed: string[]; orphanedOverlays: string[] } { + const prefix = `${packageId}:`; + const removed: string[] = []; + const orphanedOverlays: string[] = []; + + for (const [type, collection] of this.metadata.entries()) { + // Collect before deleting — mutating a Map while iterating its own keys + // is legal but reads as a trap, and the bare-slot probe below wants the + // collection in its post-removal state to be truthful. + const owned = [...collection.keys()].filter(key => key.startsWith(prefix)); + for (const key of owned) { + collection.delete(key); + removed.push(`${type}/${key}`); + this.log(`[Registry] Unregistered ${type}: ${key} (package ${packageId} uninstalled)`); + } + for (const key of owned) { + // The bare (overlay) slot for this item is its key without the package + // prefix — the same `bareKey` the artifact-vs-DB collision warning in + // `registerItem` compares against, discriminator included. + const bareSlot = key.slice(prefix.length); + const label = `${type}/${bareSlot}`; + if (collection.has(bareSlot) && !orphanedOverlays.includes(label)) { + orphanedOverlays.push(label); + } + } + } + + if (orphanedOverlays.length > 0) { + console.warn( + `[Registry] Package "${packageId}" was uninstalled, but ${orphanedOverlays.length} ` + + `runtime/DB overlay row(s) layered over its items were KEPT — they are tenant-authored ` + + `(ADR-0005) and an uninstall does not delete them: ${orphanedOverlays.join(', ')}. ` + + `Each now overlays nothing — re-install the package that owns it, or delete the ` + + `sys_metadata row.`, + ); + } + if (removed.length > 0) { + this.log(`[Registry] Unregistered ${removed.length} item(s) from package: ${packageId}`); + } + return { removed, orphanedOverlays }; + } + /** * Universal Get Method. * @@ -2485,6 +2598,14 @@ export class SchemaRegistry { // Unregister objects (will throw if extenders exist) this.unregisterObjectsByPackage(id); + // [#7221] …and everything else the package shipped. The object verb above + // reaches `objectContributors` only, so without this an uninstall dropped + // the package record while its `page`/`view`/`flow`/`app`/`api` entries + // stayed resolvable through `getItem`/`listItems` for the life of the + // process. Runs AFTER the object verb because that one can refuse + // (ADR-0029 extenders): a refused uninstall must remove nothing at all. + this.unregisterItemsByPackage(id); + // Remove package record const collection = this.metadata.get('package'); if (collection) {