From c10fd4ae52659e786ce7ed3241277f54c680d265 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 23:19:18 +0000 Subject: [PATCH] fix(objectql): a refused first install leaves nothing of itself, even over a tenant's object Every registerApp body now runs through the registry's restore on refusal, first installs included, so a first install refused part-way leaves no record, object, item or namespace of itself, and a tenant's object, its overlay or another package's extension it layered over reads as before. The separate uninstall-based path for first installs is gone; the boot seed is untouched. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5oL2i4xH7Mu7va3kxr --- ...28697-refused-first-install-over-tenant.md | 12 ++ packages/objectql/src/engine.ts | 134 ++----------- ...-refused-first-install-over-tenant.test.ts | 186 ++++++++++++++++++ ...age-refused-registration-withdrawn.test.ts | 19 +- packages/objectql/src/registry-module-mock.ts | 18 +- packages/objectql/src/registry.ts | 11 +- 6 files changed, 233 insertions(+), 147 deletions(-) create mode 100644 .changeset/28697-refused-first-install-over-tenant.md create mode 100644 packages/objectql/src/package-refused-first-install-over-tenant.test.ts diff --git a/.changeset/28697-refused-first-install-over-tenant.md b/.changeset/28697-refused-first-install-over-tenant.md new file mode 100644 index 00000000000..e3642cf7e04 --- /dev/null +++ b/.changeset/28697-refused-first-install-over-tenant.md @@ -0,0 +1,12 @@ +--- +"@objectstack/objectql": patch +--- + +Clause-②: no +Metadata-protocol: none + +fix(objectql): a refused first install leaves nothing of itself, even over a tenant's object + +A first install of a package that `ObjectQL.registerApp` refuses part-way now leaves nothing of itself registered in every case, including when the package layers over an object a tenant already holds (with or without the tenant's own overlay), over an overlay with no owner, or over another package's extension of one of its objects. Afterwards there is no package record, object, extension, app, item, picklist extension, namespace, navigation contribution, contributed kind or datasource definition of the package, and the tenant's object, its overlay and the other package's extension read exactly as before the attempt. A persisted "disabled" state for that id still applies to its next install. What is refused, and every refusal's code, status and words, is unchanged. + +Every registration is now put back by the registry itself when it is refused, the way a refused upgrade already was. This supersedes the line of the pending `28697-refused-registration-leaves-nothing` changeset saying the engine passes `{ keepBootSeed: true }` to `SchemaRegistry.uninstallPackage`: the engine no longer uninstalls a refused first install. The option itself stays available and unchanged. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 581f6ea63b2..d80e4629227 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -331,10 +331,7 @@ import { } from './home-amount-projection.js'; import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js'; import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; -import { SchemaRegistry, computeFQN, type ArtifactInstallScope } from './registry.js'; -// [#28697] Whether an object's sitting owner is a tenant's own (ADR-0029 D9.6), -// asked by `refusedRegistrationWithdrawable` before a refused body is withdrawn. -import { isTenantAuthored } from '@objectstack/metadata-core'; +import { SchemaRegistry, type ArtifactInstallScope } from './registry.js'; import { expandSearchToFilter, normalizeSearch, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES } from './search-filter.js'; import { stripSearchCompanion } from './search-companion.js'; import { ExpressionEngine, analyzeRelationshipTraversals, isUnparseableDate, type EvalError, type EvalOptionLabels, type EvalPermissions } from '@objectstack/formula'; @@ -5176,65 +5173,6 @@ export function assembledViewItemRefusal( return err; } -/** - * [#28697] Whether a refused {@link ObjectQL.registerApp} of package `id` may - * be withdrawn through the registry's uninstall, asked BEFORE anything of the - * body is written. True only when withdrawing takes back exactly what the - * refused call wrote and nothing else: - * - * - no package record answers `id` yet. A refused re-register of an - * installed id is NOT withdrawn: the uninstall would take the installed - * version with it; - * - `id` is not the owner a stack's residual registers its items under - * (`recordResidualOwner`), whose items no package record covers; - * - no object the body or a nested plugin owns, nor any object it extends, - * already holds a contribution under `id`; - * - every object the body or a nested plugin owns is either unheld or owned - * by another code package (which `registerObject` refuses before it adds - * anything). A tenant-authored owner or an overlay or extension with no - * owner is left alone: `registerObject` re-classifies the tenant - * contribution as the package's overlay, and the uninstall drops an - * object's overlay with its owner (ADR-0029 D9.7) and refuses an object - * another package extends, so withdrawing would lose the tenant's layer in - * this process or be refused. - */ -export function refusedRegistrationWithdrawable( - registry: SchemaRegistry, - id: string | undefined, - manifest: any, - residualOwners: ReadonlyMap, -): boolean { - if (!id || registry.getPackage(id) !== undefined || residualOwners.has(id)) return false; - // The object names as the registry files them (`computeFQN`, the call - // `registerObject` makes with the namespace each one registers under). - const owned: string[] = []; - const ownedNames = (body: any, namespace: string | undefined) => { - const objects = body?.objects; - if (!objects || typeof objects !== 'object') return; - const names = Array.isArray(objects) ? objects.map((def: any) => def?.name) : Object.keys(objects); - for (const name of names) if (typeof name === 'string' && name !== '') owned.push(computeFQN(namespace, name)); - }; - ownedNames(manifest, manifest?.namespace); - if (Array.isArray(manifest?.plugins)) { - for (const plugin of manifest.plugins) { - if (plugin && typeof plugin === 'object') ownedNames(plugin, plugin.namespace || manifest?.namespace); - } - } - const extended: string[] = Array.isArray(manifest?.objectExtensions) - ? manifest.objectExtensions.map((ext: any) => ext?.extend).filter((name: unknown): name is string => typeof name === 'string') - : []; - for (const fqn of [...owned, ...extended]) { - if (registry.getObjectContributors(fqn).some((c) => c.packageId === id)) return false; - } - for (const fqn of owned) { - const contributors = registry.getObjectContributors(fqn); - if (contributors.length === 0) continue; - const ownedByAnotherCodePackage = contributors.some((c) => c.ownership === 'own' && !isTenantAuthored(c.definition)); - if (!ownedByAnotherCodePackage) return false; - } - return true; -} - /** * [#28697] Stamp an app object ({@link stampPackage}) and register it through * the registry's app verb, recording the stamp the registration writes onto @@ -9324,28 +9262,16 @@ export class ObjectQL implements IObjectQLEngine { // [#28697] What remains can only be refused part-way, by the registry's // own verbs (an object another package owns, a field with no `type`, a - // picklist value already added, …). A first registration of this id is - // withdrawn when one throws, so the caller's refusal leaves no package - // record, object, app or item behind; asked here, before anything is - // written. See {@link refusedRegistrationWithdrawable}. - const withdrawable = refusedRegistrationWithdrawable(this._registry, id, manifest, this.residualOwnerSources); - // [#28697] A re-register of an installed id is an upgrade: refused - // part-way, the registry puts back exactly the entries it wrote, so the - // installed version keeps serving whole, with other packages' - // extensions of its objects and tenant overlays as they were. Never - // through uninstall, which refuses an extended object and drops a - // tenant's overlay. See {@link SchemaRegistry.restoringOnRefusal}. - const upgrade = Boolean(id) && this._registry.getPackage(id) !== undefined; - try { - if (upgrade) { - this._registry.restoringOnRefusal(() => this.registerPackageBody(manifest, id, namespace, version, source, scope)); - } else { - this.registerPackageBody(manifest, id, namespace, version, source, scope); - } - } catch (err) { - if (withdrawable) this.withdrawRefusedRegistration(id, manifest, err); - throw err; - } + // picklist value already added, …). Refused, the registry puts back + // exactly the entries this registration wrote: a first install leaves no + // package record, object, app, item or namespace of itself, and a + // re-register of an installed id (an upgrade) leaves the installed + // version serving whole. Whatever sat there before — a tenant's object + // or overlay this package layers over (ADR-0029 D9 §6.1), another + // package's extension of its objects — reads as it did. Never through + // uninstall, which refuses an extended object and drops a tenant's + // overlay with its owner (D9.7). See {@link SchemaRegistry.restoringOnRefusal}. + this._registry.restoringOnRefusal(() => this.registerPackageBody(manifest, id, namespace, version, source, scope)); // [#28697] Written only once nothing above threw, so a refused body // leaves none of them. Nothing above reads any of them. @@ -9524,7 +9450,7 @@ export class ObjectQL implements IObjectQLEngine { } // (Contributed kinds register in `registerApp` once this body has - // registered whole: they belong to no package, so no withdrawal of a + // registered whole: they belong to no package, so no restore of a // refused body could take them back.) // 7. Recursively register nested plugins @@ -9540,42 +9466,6 @@ export class ObjectQL implements IObjectQLEngine { } } - /** - * [#28697] Take back what a refused first registration of package `id` - * wrote, through the registry's own uninstall ({@link SchemaRegistry.uninstallPackage}): - * the package record and its namespace, its objects and extensions, its apps - * and items, its picklist extensions and catalog claims. Two things that verb - * does not reach are handled here: a nested plugin's own namespace (claimed - * for `id` by `registerObject`), and the boot seed, which an operator - * uninstall forgets with the row but a refused registration keeps, so a later - * install of the id still lands disabled when the persisted state says so. - * - * Nothing to do when the registry refused the package record itself: its - * refusals all precede its first write. A withdrawal that is itself refused - * is logged and the caller's refusal still propagates unchanged. - */ - private withdrawRefusedRegistration(id: string, manifest: any, refusal: unknown): void { - if (this._registry.getPackage(id) === undefined) return; - try { - this._registry.uninstallPackage(id, { keepBootSeed: true }); - if (Array.isArray(manifest.plugins)) { - for (const plugin of manifest.plugins) { - const ns = plugin && typeof plugin === 'object' ? plugin.namespace : undefined; - if (typeof ns === 'string' && ns !== '' && ns !== manifest.namespace) this._registry.unregisterNamespace(ns, id); - } - } - this.invalidateSummaryIndex(); - this.logger.debug('Withdrew a refused package registration', { id }); - } catch (withdrawal) { - this.logger.error( - `Package '${id}' was refused part-way through registering, and what it had registered could not be ` - + 'withdrawn; it stays in this process until restart.', - withdrawal instanceof Error ? withdrawal : undefined, - { id, refusal: String((refusal as Error)?.message ?? refusal), withdrawal: String((withdrawal as Error)?.message ?? withdrawal) }, - ); - } - } - /** * Deep-clone an app definition, resolving objectName references in navigation * items via the registry. Object names are canonical identifiers — no FQN diff --git a/packages/objectql/src/package-refused-first-install-over-tenant.test.ts b/packages/objectql/src/package-refused-first-install-over-tenant.test.ts new file mode 100644 index 00000000000..4f2601ff6cc --- /dev/null +++ b/packages/objectql/src/package-refused-first-install-over-tenant.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28697] A first install that layers over what a tenant or another package + * already holds, refused part-way by the registry, leaves nothing of itself + * registered, and what was there before reads exactly as before. + * + * ## What this file pins + * + * For a first install over an object a tenant owns (with and without its + * overlay layer), over an overlay with no owner, and over another package's + * extension of an object nobody owns yet: + * + * - the refusal keeps its code and status; + * - no package record, object, app, item, navigation contribution, kind, + * datasource definition or namespace of the package remains; + * - the object's layers (who holds each, how it is classified, at what + * priority, with which body) and the object as served read as before; + * - the boot seed of disabled packages is kept. + * + * CONTROLS: a clean first install over a tenant's object takes ownership with + * the tenant's layer as its overlay, as before, and an operator's uninstall of + * it still removes the package. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from './engine.js'; + +const PKG = 'com.probe.lateinstall'; +const HOST = 'com.probe.extender'; +const TENANT = 'sys_metadata'; + +const object = (name: string, extra: Record = {}) => ({ + name, + label: name, + fields: { title: { type: 'text', label: 'Title' } }, + ...extra, +}); + +/** A body that layers over `probe_a` and writes into every registry `registerApp` reaches. */ +const body = (extra: Record = {}) => ({ + id: PKG, + name: PKG, + namespace: 'probelate', + objects: [object('probe_a')], + apps: [{ name: 'probe_app', label: 'App', navigation: [{ id: 'nav_a', type: 'object', label: 'A', objectName: 'probe_a' }] }], + pages: [{ name: 'probe_page', type: 'app' }], + picklists: [{ name: 'probe_list', label: 'L', options: [{ label: 'X', value: 'x' }] }], + datasources: [{ name: 'probe_ds', driver: 'memory' }], + navigationContributions: [{ app: 'host_app', items: [{ id: 'probe_nav', type: 'url', label: 'Probe', url: '/probe' }] }], + contributes: { kinds: [{ id: 'probe.kind' }] }, + plugins: [{ name: 'probe_inner', namespace: 'probeinner', objects: [object('probe_inner_thing')] }], + ...extra, +}); + +/** The same body, refused by the registry on a later object (a field with no `type`). */ +const refusedBody = () => body({ objects: [object('probe_a'), { name: 'probe_b', fields: { t: { label: 'No type' } } }] }); + +/** What the engine holds of PKG, read through its public verbs. */ +function traceOfPackage(engine: ObjectQL) { + const registry = engine.registry; + return { + pkg: registry.getPackage(PKG), + ownedObjects: registry.getAllObjects().map((o: any) => o.name).filter((n: string) => n === 'probe_b' || n === 'probe_inner_thing'), + apps: registry.listItems('app').map((a: any) => a.name), + items: (['page', 'picklist'] as const) + .flatMap((type) => registry.listItems(type).filter((i: any) => i?._packageId === PKG).map((i: any) => `${type}/${i.name}`)), + picklist: registry.resolvePicklistOptions('probe_list'), + nav: registry.getAppNavContributions('host_app').map((c) => c.packageId), + kinds: registry.getAllKinds().map((k) => k.id).filter((id) => id === 'probe.kind'), + datasources: engine.listDatasourceDefs().map((d) => d.name).filter((n) => n === 'probe_ds'), + namespaces: [registry.getNamespaceOwner('probelate'), registry.getNamespaceOwner('probeinner')], + contributions: registry.getObjectContributors('probe_a').filter((c) => c.packageId === PKG).length, + }; +} + +const NOTHING = { + pkg: undefined, ownedObjects: [], apps: [], items: [], picklist: undefined, nav: [], kinds: [], datasources: [], + namespaces: [undefined, undefined], contributions: 0, +}; + +/** `probe_a`'s layers as the registry holds them, and the object as served. */ +function layersOf(engine: ObjectQL) { + return { + layers: engine.registry.getObjectContributors('probe_a').map((c) => ({ + packageId: c.packageId, ownership: c.ownership, priority: c.priority, definition: c.definition, + })), + served: engine.registry.getObject('probe_a'), + }; +} + +function refusal(fn: () => void): any { + try { + fn(); + } catch (e) { + return e; + } + throw new Error('expected the registration to be refused'); +} + +const tenantObject = (engine: ObjectQL) => + engine.registry.registerObject({ ...object('probe_a', { label: 'Tenant label' }), _provenance: 'org' } as any, TENANT, undefined, 'own'); + +describe('[#28697] a first install over a tenant\'s or another package\'s layer, refused part-way, leaves nothing of itself', () => { + it('over an object a tenant owns', () => { + const engine = new ObjectQL(); + tenantObject(engine); + const before = layersOf(engine); + const err = refusal(() => engine.registerApp(refusedBody() as any)); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(traceOfPackage(engine)).toEqual(NOTHING); + expect(layersOf(engine)).toEqual(before); + expect(engine.registry.getObjectOwner('probe_a')?.packageId).toBe(TENANT); + expect((engine.registry.getObject('probe_a') as any)?.label).toBe('Tenant label'); + }); + + it('over an object a tenant owns with its overlay layer', () => { + const engine = new ObjectQL(); + tenantObject(engine); + engine.registry.registerObject({ name: 'probe_a', label: 'Tenant overlay', _provenance: 'org' } as any, TENANT, undefined, 'overlay'); + const before = layersOf(engine); + const err = refusal(() => engine.registerApp(refusedBody() as any)); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(traceOfPackage(engine)).toEqual(NOTHING); + expect(layersOf(engine)).toEqual(before); + expect(engine.registry.getObjectContributors('probe_a').map((c) => c.ownership).sort()).toEqual(['overlay', 'own']); + }); + + it('over an overlay with no owner', () => { + const engine = new ObjectQL(); + engine.registry.registerObject({ ...object('probe_a', { label: 'Orphan overlay' }), _provenance: 'org' } as any, TENANT, undefined, 'overlay'); + const before = layersOf(engine); + const err = refusal(() => engine.registerApp(refusedBody() as any)); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(traceOfPackage(engine)).toEqual(NOTHING); + expect(layersOf(engine)).toEqual(before); + expect(engine.registry.getObjectOwner('probe_a')).toBeUndefined(); + }); + + it('over another package\'s extension of an object nobody owns yet', () => { + const engine = new ObjectQL(); + engine.registerApp({ id: HOST, name: HOST, objectExtensions: [{ extend: 'probe_a', fields: { extra: { type: 'text', label: 'Extra' } } }] } as any); + const before = layersOf(engine); + const err = refusal(() => engine.registerApp(refusedBody() as any)); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(traceOfPackage(engine)).toEqual(NOTHING); + expect(layersOf(engine)).toEqual(before); + expect(engine.registry.getObjectContributors('probe_a').map((c) => `${c.ownership}:${c.packageId}`)).toEqual([`extend:${HOST}`]); + expect(engine.registry.getPackage(HOST)).toBeDefined(); + }); + + it('keeps the boot seed of disabled packages, so the next install still lands disabled', () => { + const engine = new ObjectQL(); + engine.registry.setInitialDisabledPackageIds([PKG]); + tenantObject(engine); + refusal(() => engine.registerApp(refusedBody() as any)); + expect(traceOfPackage(engine)).toEqual(NOTHING); + engine.registerApp(body() as any); + expect(engine.registry.getPackage(PKG)).toMatchObject({ enabled: false, status: 'disabled' }); + }); +}); + +describe('[#28697] controls', () => { + it('a clean first install over a tenant\'s object takes ownership, the tenant\'s layer its overlay', () => { + const engine = new ObjectQL(); + tenantObject(engine); + engine.registerApp(body() as any); + expect(engine.registry.getPackage(PKG)).toBeDefined(); + expect(engine.registry.getObjectContributors('probe_a').map((c) => `${c.ownership}:${c.packageId}`).sort()) + .toEqual([`overlay:${TENANT}`, `own:${PKG}`]); + expect((engine.registry.getObject('probe_a') as any)?.label).toBe('Tenant label'); + expect(traceOfPackage(engine)).toMatchObject({ + apps: ['probe_app'], items: ['page/probe_page', 'picklist/probe_list'], nav: [PKG], kinds: ['probe.kind'], + datasources: ['probe_ds'], namespaces: [PKG, PKG], + }); + }); + + it('an operator\'s uninstall of that install still removes the package', () => { + const engine = new ObjectQL(); + tenantObject(engine); + engine.registerApp(body() as any); + expect(engine.registry.uninstallPackage(PKG)).toBe(true); + expect(engine.registry.getPackage(PKG)).toBeUndefined(); + expect(engine.registry.getObjectContributors('probe_a').some((c) => c.packageId === PKG)).toBe(false); + }); +}); diff --git a/packages/objectql/src/package-refused-registration-withdrawn.test.ts b/packages/objectql/src/package-refused-registration-withdrawn.test.ts index e32178dbdcd..de597ccd423 100644 --- a/packages/objectql/src/package-refused-registration-withdrawn.test.ts +++ b/packages/objectql/src/package-refused-registration-withdrawn.test.ts @@ -21,11 +21,10 @@ * leaves the other package's object and list as they were, and keeps the * boot seed of disabled packages; * - CONTROLS: a clean body registers everything as before; a body that took - * over a tenant's object is not withdrawn (the withdrawal would drop the - * tenant's layer); nor is an installed id refused part-way by the registry - * (the withdrawal would take the installed version with it), which keeps - * serving its installed version instead - * (`package-refused-upgrade-keeps-installed.test.ts`). + * over a tenant's object leaves nothing of itself and the tenant's object + * serves as before (`package-refused-first-install-over-tenant.test.ts`); + * an installed id refused part-way by the registry keeps serving its + * installed version (`package-refused-upgrade-keeps-installed.test.ts`). */ import { describe, it, expect } from 'vitest'; @@ -216,15 +215,17 @@ describe('[#28697] controls', () => { expect(t.innerNamespace).toBe(PKG); }); - it('a body that took over a tenant\'s object is not withdrawn: the tenant\'s layer keeps serving', () => { + it('a body that took over a tenant\'s object leaves nothing of itself: the tenant\'s object serves as before', () => { const engine = new ObjectQL(); engine.registry.registerObject({ ...object('probe_a', { label: 'Tenant label' }), _provenance: 'org' } as any, 'sys_metadata', undefined, 'own'); const err = refusal(() => engine.registerApp(body({ objects: [object('probe_a'), { name: 'probe_b', fields: { t: { label: 'No type' } } }] }) as any)); expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); - expect(engine.registry.getObjectContributors('probe_a').map((c) => `${c.ownership}:${c.packageId}`).sort()) - .toEqual(['overlay:sys_metadata', `own:${PKG}`]); + expect(engine.registry.getObjectContributors('probe_a').map((c) => `${c.ownership}:${c.packageId}`)) + .toEqual(['own:sys_metadata']); expect((engine.registry.getObject('probe_a') as any)?.label).toBe('Tenant label'); - expect(engine.registry.getPackage(PKG), 'not withdrawn').toBeDefined(); + expect({ ...trace(engine), objects: trace(engine).objects.filter((n) => n !== 'probe_a') }).toEqual({ + pkg: undefined, objects: [], apps: [], items: [], nav: [], kinds: [], datasources: [], innerNamespace: undefined, + }); }); it('an installed id refused part-way by the registry is not withdrawn: the installed version keeps serving', () => { diff --git a/packages/objectql/src/registry-module-mock.ts b/packages/objectql/src/registry-module-mock.ts index 88d4a40e309..9acf5f775ac 100644 --- a/packages/objectql/src/registry-module-mock.ts +++ b/packages/objectql/src/registry-module-mock.ts @@ -98,7 +98,6 @@ export interface RegistryDoubleInstance { registerApp: any; installPackage: any; getPackage: any; - getObjectContributors: any; reset: any; metadata: any; [member: string]: any; @@ -175,17 +174,14 @@ export function createRegistryModuleMock( registerItem: vi.fn(), registerApp: vi.fn(), installPackage: vi.fn(), - // [#28697] `registerApp` asks, before it writes anything, whether a refused - // first registration of the package could be withdrawn: no package record - // under its id, and who already contributes to each object it owns. The - // same lesson as `getAllObjects` above: empty is the truthful body for a - // suite that installs nothing for real, and an omitted member is a hard - // `TypeError` at every `registerApp`. + // [#28697] A package's installed record, read by the plugin's install + // paths. The same lesson as `getAllObjects` above: empty is the truthful + // body for a suite that installs nothing for real, and an omitted member + // is a hard `TypeError` at its first read. getPackage: vi.fn(() => undefined), - getObjectContributors: vi.fn(() => []), - // [#28697] …and an upgrade (a `getPackage` hit) runs its body through this - // member, so a refused upgrade is put back. The double runs the body as - // given: it records nothing, so it has nothing to put back. + // [#28697] …and every `registerApp` runs its body through this member, so + // a refused registration is put back. The double runs the body as given: + // it records nothing, so it has nothing to put back. restoringOnRefusal: vi.fn((register: () => unknown) => register()), reset: vi.fn(), metadata: { get: vi.fn(() => new Map()) }, diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 6ad5f5113e2..265b515e3fb 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -7558,11 +7558,12 @@ export class SchemaRegistry { * claims and record (each step's reason is on it below). * * @param options.keepBootSeed [#28697] Keep `id` in the boot seed of - * disabled packages ({@link setInitialDisabledPackageIds}). The engine's - * withdrawal of a package refused part-way through its first registration - * passes it: that package never existed for an operator, so the persisted - * disable state its next install must honour is not forgotten with the - * row. An operator's uninstall omits it. + * disabled packages ({@link setInitialDisabledPackageIds}), for a caller + * taking back a package that never existed for an operator, so the + * persisted disable state its next install must honour is not forgotten + * with the row. An operator's uninstall omits it. (A registration the + * engine refuses part-way is put back by {@link restoringOnRefusal} + * instead, which never touches the boot seed.) */ uninstallPackage(id: string, options?: { keepBootSeed?: boolean }): boolean { const pkg = this.getPackage(id);