Skip to content

Commit 1e346e9

Browse files
fix(objectql)!: uninstalling a base package is refused while its paired .custom package is installed, naming the pair (409) (#28506)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01LjZwNRFiG2fkp8SNf6rHNx?thread=cmsg_01LjZwNRFiG2fkp8SNf6rHNxBfysW9RL3L2nLXms3Co9hA)_ Fixes #28408 Clause-②: no (narrowing) — `SchemaRegistry.assertPackageUninstallable`, and so `uninstallPackage`, `unregisterObjectsByPackage` without `force`, the protocol's `deletePackage` and `DELETE /api/v1/packages/:id`, now refuse the uninstall of a base while the registry holds its paired customization package (`BASE.custom`), an uninstall that succeeded before; nothing is widened. Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches uninstalling a package, so every taught use stays true. ## 维护者速读 - 改了什么:卸载一个基础包(如 HotCRM)时,只要它的配对定制包 `hotcrm.custom` 还装着,卸载就被拒绝,返回 409 `RESOURCE_CONFLICT`,消息点名 `hotcrm.custom`,并先给出路:先删除它(要保留定制,先用 `GET /api/v1/packages/hotcrm.custom/export` 导出)。基础包同时被别的包扩展时,一个拒绝同时列出两种原因。 - 为什么改:不拒的话,卸载会把 `hotcrm.custom` 里的全部定制变成孤儿,没人提供、也没人说明原因(ADR-0146 D3,史诗的两分钟验证「卸载 HotCRM 被拒并点名 `hotcrm.custom`」)。 - 风险与回滚:只是给已有的卸载拒绝(ADR-0029 被扩展对象)加一个原因,同一个错误类、同一个 409 信封,没有新错误码;`deletePackage` 在删存储行之前就问,拒绝时什么都不动。卸载配对包本身、`force`、没有配对包的基础包都不变。回滚即 revert 本 PR。另:`DELETE /api/v1/marketplace/install-local/:id` 这扇门会先删账本再撤注册,配对包在时它仍回 200 并留下半卸载的基础包;它不在本卡文件面内,已作为发现交给席位。 - 先看:`packages/objectql/src/registry.ts`(`assertPackageUninstallable` 末尾的配对检查,和 `PackageUninstallExtendedError` 构造函数里的三种消息分支)、`packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts`(新的 describe 块)、`packages/objectql/src/registry-assert-package-uninstallable.test.ts`(新的 describe 块)。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/registry-assert-package-uninstallable.test.ts`(需先构建 spec),预期 9 个用例全绿,其中 "the pair present" 用例断言 409 并点名 `hotcrm.custom`。 - 需要你做的:无,席位落地。 ## What changes - **`SchemaRegistry.assertPackageUninstallable(id)`** (`packages/objectql/src/registry.ts`) also refuses while the registry holds the pair id of `id`, which it reads from N-08's `customizationPackageId` (`@objectstack/spec/kernel`). There is no local copy of the suffix. The pair is found by id alone, so a pair built by the ensure door (N-13) and an installed exported pair are treated the same. Uninstalling the pair itself is unaffected, because a `.custom` id derives no pair. `force` still means not asking. - **The same refusal, no new code.** `PackageUninstallExtendedError` (`RESOURCE_CONFLICT` / 409, the ADR-0112 envelope) gains `customizationPackageId`. Its message opens with the way out: delete the pair first, exporting it before that to keep its customizations (`GET /api/v1/packages/PAIR/export`). It then names the pair. When the base is also extended (ADR-0029), ONE refusal names the extenders and the pair, and its first sentence is the whole way out. The extender-only message is byte-identical to before. The class keeps its name because ADR-0141 and ADR-0146 cite it as this family's anchor ("the `PackageUninstallExtendedError` shape"). - **Every door that asks it refuses before mutating.** These are `deletePackage` (before its `sys_packages` delete), `uninstallPackage` and `unregisterObjectsByPackage`, and so `DELETE /api/v1/packages/:id`. `protocol-package-uninstall.ts` changes only in comments: they now name the pair cause, and they correct the door's answer from `500` to the refusal's own `409`. - **Wanted 3: gate order unchanged.** On `DELETE /api/v1/packages/:id` the read-only `422 WRITABLE_PACKAGE_REQUIRED` still answers first. A new test pins it for a booted code package with its pair installed. ## Tests - `packages/objectql/src/registry-assert-package-uninstallable.test.ts`, new block (base `hotcrm`, pair `hotcrm.custom`): - the pair present: `RESOURCE_CONFLICT` / 409 / `httpStatus` 409, `customizationPackageId: 'hotcrm.custom'`, the way out leading, nothing mutated; `uninstallPackage` throws the same refusal and removes nothing; - the pair absent answers as before. So does a `.custom` package that is not this base's pair (`hotcrmx.custom`), and a dependent that is not the pair (`hotcrm.extras`, which ADR-0141 D3 still defers). Both declare the base; - both causes: one refusal carrying `extendedObjects`, `extenders` and `customizationPackageId`; - the pair itself uninstalls, then the base does; `unregisterObjectsByPackage(base, true)` does not ask. - `packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts`, new block (real `SchemaRegistry`, real `HttpDispatcher`): - pair installed, base disabled first through `PATCH /packages/:id/disable`: the operator's `DELETE` answers 409 `RESOURCE_CONFLICT` naming the pair. The stored rows, the registry entry (still disabled), its object, the disable record on disk and the pair are all intact; - a host with no persisted half: the registry's own uninstall refuses the same way, nothing moves; - control: the pair deleted first through the same door (200), then the base uninstalls (200); - a booted code package with its pair: `422 WRITABLE_PACKAGE_REQUIRED` first, `deletePackage` never asked. - The rig's `protocol` double now asks `registry.assertPackageUninstallable` before its store delete, as `deletePackage` does, and deletes only the base's rows. The existing arms are unchanged and still green. ## Acceptance notes - **Wanted 3, measured: one door does uninstall a managed base.** `DELETE /api/v1/marketplace/install-local/:manifestId` (`packages/cloud-connection/src/marketplace-install-local-plugin.ts`, `handleUninstall`) uninstalls a marketplace-installed base. It removes the ledger entry first and asks no refusal before that. The registry's refusal reaches it only at the withdrawal, as a failed `registry.uninstallPackage` cleanup inside a 200. Measured at this head with a real `SchemaRegistry` (a scratch test, not committed): - pair installed: `200`, `success: true`, ledger entry gone, base still registered until restart, `cleanups: [{ name: 'registry.uninstallPackage', success: false }]`, and no response field names the pair. Only the operator log does. - no pair (control): `200` and the base is withdrawn. So on that door the two-minute check does not hold. After a restart the base is gone and its pair is stranded: the half-removed base ADR-0146 D3 rules out. The ADR-0029 extender refusal has the same gap there today, and `marketplace-install-local-uninstall-withdrawal.test.ts` pins it as declared ("a refused withdrawal is one failed outcome on the response"). The fix is to ask `assertPackageUninstallable` before the ledger removal and answer its 409. That is outside this card's file surface (`packages/cloud-connection`), so it is not changed here. It is reported to the seat for a card. The gates on `DELETE /api/v1/packages/:id` are not reordered. - **The prescription.** The card says the refusal "prescribes exporting it … or deleting it first". Exporting alone does not unblock the uninstall, so the message says to delete the pair first and to export it before that to keep its customizations. ## Verification Readings at head `d79d71aa7` (branch base `ea74ebf93`). <details><summary>Package tests, typecheck and lint</summary> - `pnpm --filter @objectstack/objectql typecheck` and `test`: exit 0; Test Files 509 passed (509), Tests 9865 passed (9865). - `pnpm --filter @objectstack/metadata-protocol typecheck` and `test`: exit 0; Test Files 288 passed, 4 skipped (292), Tests 21888 passed, 26 skipped. - `pnpm --filter @objectstack/runtime typecheck` and `test`: exit 0; Test Files 496 passed (496), Tests 8057 passed, 19 skipped. - The two touched files alone: objectql 9/9, runtime 9/9 (4 new each). - `pnpm lint` (repo-wide, as the dispatch asked): exit 0. - All of the above ran under `scripts/pm/os-verify-lock.sh`, VERDICT command-exit 0, after `pnpm --filter '@objectstack/dogfood^...' build` (exit 0). The runtime suite reads `@objectstack/objectql` from `dist/` (no alias), so it ran on the rebuilt package. </details> <details><summary>Ablations (scripts/ablation-replace.mjs on packages/objectql/src/registry.ts; objectql rebuilt and scripts/ablation-dist-preflight.mjs exit 0 on each mutated and each restored leg)</summary> Each leg ran both touched files. Each restore leg was proven by the restored blob equal to the HEAD blob, `git diff HEAD` empty, the marker absent from `dist/` (`--absent`, exit 0), and a clean tree at the end. - A1, the pair is never a cause (`installedPair` always undefined): objectql 2 failed (the pair present; both causes), runtime 2 failed (the 409 with everything intact; the host with no persisted half). - A2, the registry is not asked whether the pair is installed (refuse whenever an id derives a pair): objectql 5 failed (the pair absent, the pair itself then the base, and the three older arms whose ids derive a pair); runtime 3 failed (both operator uninstalls and the pair-first control). - A3, with extenders present the pair is dropped from the refusal: objectql 1 failed (both causes); runtime green, as it carries no both-causes door case. </details> <details><summary>Dispatch gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands from 5 paths against merge base `ea74ebf93` (323 changed lines). Every command was run with stdin from `/dev/null`, and each exit code was written to disk before any pipe. `--ran` reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 7 packages outside the dogfood closure had no `dist/`). After building those 7 it exited 0 (113 require entry points across 70 packages load). - Every other derived command exited 0, including `pnpm check:type-check-debt`, `pnpm check:engine-double-contract` and `node scripts/check-adr-0087-registration.mjs --base origin/main`. - The clone was unshallowed (`git fetch --unshallow origin main`) before deriving. </details> <details><summary>Full dogfood battery (Clause-② carries the narrowing arm)</summary> At head `d79d71aa7`: `pnpm --filter @objectstack/dogfood test` under the verify lock, with no `OS_TEST_SHARD` and no path. VERDICT command-exit 0 (held 20m29s). Test Files 363 passed, 3 skipped (366). Tests 2926 passed, 28 skipped (2954). No failures. </details> <details><summary>Wanted 3 measurement</summary> This was a scratch test in `packages/cloud-connection/src` and was deleted before any commit. It booted the real `MarketplaceInstallLocalPlugin` over a real `SchemaRegistry` holding `com.example.tasksapp` (source `marketplace`, scope `cloud`) and, in one arm, `com.example.tasksapp.custom`. Then it called the door's DELETE route. - No pair: status 200, ledger entry removed, base withdrawn. - Pair installed: status 200, `success: true`, ledger entry removed, base and pair both still registered, `cleanups` was `[{ name: 'registry.uninstallPackage', success: false, removed: 0 }]`. No response field names the pair; the operator warn does. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched, nothing to regenerate. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief is in Chinese, per AGENTS.md). --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3Bs4LxJh9Md3CJueiEQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efb1620 commit 1e346e9

5 files changed

Lines changed: 309 additions & 14 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: uninstalling a base package is refused while its paired `.custom` package is installed, naming the pair (409, the extender-refusal family) (#28408)
6+
7+
Clause-②: no (narrowing) — `SchemaRegistry.assertPackageUninstallable`, and so `uninstallPackage`, `unregisterObjectsByPackage` without `force`, the protocol's `deletePackage` and `DELETE /api/v1/packages/:id`, now refuse the uninstall of a base while the registry holds its paired customization package (`BASE.custom`), an uninstall that succeeded before; nothing is widened.
8+
Metadata-protocol: none
9+
Skills-docs: none — no file under `skills/**` teaches uninstalling a package, so every taught use stays true.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a registry state that refuses an uninstall gains one more cause, answered with the existing RESOURCE_CONFLICT / 409 refusal, so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** accept-set narrowing at the package uninstall (`@objectstack/objectql`, ADR-0146 D3).
14+
15+
- **Before.** A base package uninstalled while its paired customization package (`hotcrm.custom` for `hotcrm`) was installed. Every customization in the pair was stranded: nothing served it, and nothing said why.
16+
- **Now.** The uninstall is refused `409 RESOURCE_CONFLICT`, in the same refusal an extended object already gets (ADR-0029), before anything is removed. The message names the pair and opens with the way out: delete the pair first, exporting it before that to keep its customizations (`GET /api/v1/packages/PAIR/export`). When the base is also extended, one refusal names the extenders and the pair. The thrown error carries the pair as `customizationPackageId`.
17+
- **Unchanged.** Uninstalling the pair itself; a base with no installed pair; `force`, which still means not asking; a dependent that is not the base's own pair (ADR-0141 D3 still defers it). On `DELETE /api/v1/packages/:id` a base the deployment ships is still refused `422 WRITABLE_PACKAGE_REQUIRED` first.
18+
19+
**The remedy.** Export the pair if you want to keep its customizations, delete it, then uninstall the base.

‎packages/metadata-protocol/src/protocol-package-uninstall.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,14 @@ export async function deletePackage(this: Pick<PackageUninstallHost, 'deleteMeta
122122
// the same predicate (the one copy `unregisterObjectsByPackage` itself
123123
// calls) without performing the uninstall, so the refusal is thrown
124124
// here, as is, with nothing removed. The HTTP door answers it through
125-
// its `catch` around this verb: `500`, nothing changed.
125+
// its `catch` around this verb, with the refusal's own `409
126+
// RESOURCE_CONFLICT`: nothing changed.
127+
//
128+
// [#28408, ADR-0146 D3] The same question also refuses a base whose
129+
// paired customization package (`<base>.custom`) the registry holds,
130+
// naming the pair, in the same refusal; both causes ride on one throw.
131+
// It is asked here, before the store delete, for the same reason: a base
132+
// removed under its pair strands every customization the pair holds.
126133
//
127134
// The registry is reached the way the withdrawal below reaches it. A
128135
// registry that does not carry the method (an engine double, a host on

‎packages/objectql/src/registry-assert-package-uninstallable.test.ts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@
2424
* 4. `unregisterObjectsByPackage` and `uninstallPackage` refuse with that
2525
* same refusal, by calling it (not a copy of its predicate), and `force`
2626
* still means "do not ask".
27+
*
28+
* #28408 (ADR-0146 D3) — it also refuses a base while the registry holds its
29+
* paired customization package, `<base>.custom` (the epic's two-minute check:
30+
* 「卸载 HotCRM 被拒并点名 `hotcrm.custom`」). Pinned in the second block:
31+
* 5. the pair present: the same `RESOURCE_CONFLICT` / 409, naming the pair,
32+
* the way out (delete it, exporting it first) leading, and nothing mutated;
33+
* 6. the pair absent — or a `.custom` package that is not this base's pair —
34+
* answers as before;
35+
* 7. both causes: ONE refusal carrying the extenders and the pair;
36+
* 8. the pair itself uninstalls as before, after which the base does (the
37+
* prescription is a real way out), and `force` still does not ask.
2738
*/
2839

2940
import { describe, it, expect, beforeEach, vi } from 'vitest';
@@ -158,3 +169,114 @@ describe('#21276 SchemaRegistry.assertPackageUninstallable', () => {
158169
expect(ask).not.toHaveBeenCalled();
159170
});
160171
});
172+
173+
describe('#28408 (ADR-0146 D3) a base whose paired customization package is installed', () => {
174+
const BASE = 'hotcrm';
175+
const PAIR = 'hotcrm.custom';
176+
177+
/** The base with one object nothing extends, and (unless told not to) its pair. */
178+
function withBase(opts: { pair?: string | false } = {}) {
179+
const r = new SchemaRegistry({ multiTenant: false });
180+
(r as any).logLevel = 'silent';
181+
r.installPackage({ id: BASE, name: 'HotCRM', version: '3.2.1', type: 'app', namespace: 'crm' } as any, 'marketplace');
182+
r.registerObject({ name: 'account', fields: {} } as any, BASE, 'crm', 'own');
183+
const pair = opts.pair ?? PAIR;
184+
if (pair !== false) {
185+
// Declares the base, as the paired package does (`^<major>`, ADR-0146 D3).
186+
r.installPackage(
187+
{
188+
id: pair, name: 'HotCRM customizations', version: '1.0.0', type: 'app', namespace: 'crm_custom',
189+
dependencies: { [BASE]: '^3' },
190+
} as any,
191+
'authored',
192+
);
193+
}
194+
return r;
195+
}
196+
197+
/** What an uninstall would change: package records and object contributors, as plain data. */
198+
function stateOf(r: SchemaRegistry) {
199+
return {
200+
packages: r.getAllPackages().map((p) => p.manifest.id).sort(),
201+
account: r.getObjectContributors('account').map((c) => `${c.packageId}:${c.ownership}`),
202+
};
203+
}
204+
205+
it('the pair present: RESOURCE_CONFLICT / 409 naming it, the way out first, and nothing mutated', () => {
206+
const r = withBase();
207+
const before = stateOf(r);
208+
expect(before.packages, 'precondition: the base and its pair are installed').toEqual([BASE, PAIR]);
209+
210+
const err = thrownBy(() => r.assertPackageUninstallable(BASE));
211+
212+
expect({ code: err.code, status: err.status, httpStatus: err.httpStatus }).toEqual({
213+
code: 'RESOURCE_CONFLICT',
214+
status: 409,
215+
httpStatus: 409,
216+
});
217+
expect({
218+
packageId: err.packageId,
219+
customizationPackageId: err.customizationPackageId,
220+
extendedObjects: err.extendedObjects,
221+
extenders: err.extenders,
222+
}).toEqual({ packageId: BASE, customizationPackageId: PAIR, extendedObjects: [], extenders: [] });
223+
expect(err.message.startsWith(`Delete the customization package "${PAIR}" first`), err.message).toBe(true);
224+
expect(err.message).toContain(`GET /api/v1/packages/${PAIR}/export`);
225+
expect(stateOf(r)).toEqual(before);
226+
227+
// `uninstallPackage` asks it, so the uninstall itself is the same refusal
228+
// and removes nothing: the base keeps its record and its object.
229+
const viaUninstall = thrownBy(() => r.uninstallPackage(BASE));
230+
expect({ code: viaUninstall.code, status: viaUninstall.status, message: viaUninstall.message }).toEqual({
231+
code: err.code,
232+
status: err.status,
233+
message: err.message,
234+
});
235+
expect(stateOf(r)).toEqual(before);
236+
});
237+
238+
it('the pair absent: answers as before — and a .custom package that is not this base\'s pair changes nothing', () => {
239+
expect(() => withBase({ pair: false }).assertPackageUninstallable(BASE)).not.toThrow();
240+
// `hotcrmx.custom` is another base's pair, and `hotcrm.extras` is a
241+
// dependent that is not the pair, whose uninstall protection ADR-0141 D3
242+
// still defers: both declare the base, and neither is the id `hotcrm`
243+
// derives.
244+
for (const other of ['hotcrmx.custom', 'hotcrm.extras']) {
245+
const r = withBase({ pair: other });
246+
expect(() => r.assertPackageUninstallable(BASE), other).not.toThrow();
247+
expect(r.uninstallPackage(BASE), other).toBe(true);
248+
}
249+
});
250+
251+
it('both causes: ONE refusal carrying the extenders and the pair', () => {
252+
const r = withBase();
253+
r.registerObject({ name: 'account', fields: {} } as any, 'com.addon', undefined, 'extend');
254+
const before = stateOf(r);
255+
256+
const err = thrownBy(() => r.assertPackageUninstallable(BASE));
257+
258+
expect({ code: err.code, status: err.status }).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
259+
expect(err.customizationPackageId).toBe(PAIR);
260+
expect(err.extendedObjects).toEqual([{ objectName: 'account', extenders: ['com.addon'] }]);
261+
expect(err.extenders).toEqual(['com.addon']);
262+
expect(err.message.startsWith(
263+
`Uninstall the package that extends an object of "${BASE}" (com.addon) and delete the customization package "${PAIR}" first`,
264+
), err.message).toBe(true);
265+
expect(err.message).toContain('"account"');
266+
expect(stateOf(r)).toEqual(before);
267+
});
268+
269+
it('the pair itself uninstalls as before, and then the base does; force still does not ask', () => {
270+
const r = withBase();
271+
expect(() => r.assertPackageUninstallable(PAIR)).not.toThrow();
272+
expect(r.uninstallPackage(PAIR)).toBe(true);
273+
expect(r.uninstallPackage(BASE)).toBe(true);
274+
expect(stateOf(r).packages).toEqual([]);
275+
276+
const forced = withBase();
277+
const ask = vi.spyOn(forced, 'assertPackageUninstallable');
278+
forced.unregisterObjectsByPackage(BASE, true);
279+
expect(ask).not.toHaveBeenCalled();
280+
expect(forced.getObject('account')).toBeUndefined();
281+
});
282+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { SystemFieldName, scalarOverridesPackagedBase } from '@objectstack/spec/
4646
import { resolveTenancyPosture, resolveSearchPinyinEnabled } from '@objectstack/types';
4747
import { postureEnforcesWall } from '@objectstack/spec/security';
4848
import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from './search-companion.js';
49-
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, InstalledPackageSourceSchema, type InstalledPackageSource, checkFieldCompleteness } from '@objectstack/spec/kernel';
49+
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, InstalledPackageSourceSchema, type InstalledPackageSource, checkFieldCompleteness, customizationPackageId } from '@objectstack/spec/kernel';
5050
import { DEFAULT_FLOW_FUNCTION_EFFECT } from '@objectstack/spec/automation';
5151
import { AppSchema, ActionSchema } from '@objectstack/spec/ui';
5252
import { applyProtection } from '@objectstack/spec/shared';
@@ -2790,6 +2790,19 @@ const EXTENDED_OBJECTS_LISTED = 5;
27902790
* in registration order — and the prescription names every extending package,
27912791
* so one answer is the whole way out rather than the first step of several.
27922792
*
2793+
* [#28408, ADR-0146 D3] …and the uninstall refusal of a base whose paired
2794+
* customization package is installed: `<base>.custom`, the id
2795+
* {@link customizationPackageId} derives, held by this registry. That package
2796+
* holds every customization of the base, so uninstalling the base under it
2797+
* would strand them all, with nothing serving them and nothing saying why.
2798+
* ADR-0146 puts this refusal in this family and this shape, so it is this
2799+
* class rather than a sibling: one refusal, decided by the same method and
2800+
* raised at the same doors. When both causes hold, the ONE refusal carries
2801+
* both, and its prescription is the whole way out — uninstall the extenders
2802+
* and delete the pair. The pair is named by id, with the way to keep what it
2803+
* holds before it is deleted: its own export door. The class keeps its name
2804+
* because ADR-0141 and ADR-0146 cite it as the anchor of this family.
2805+
*
27932806
* ⛔ Unexported, like its siblings: a consumer compares `code`, never
27942807
* `instanceof` (see the note over the `*_CODE` constants).
27952808
*/
@@ -2804,25 +2817,53 @@ class PackageUninstallExtendedError extends Error {
28042817
readonly extendedObjects: readonly ExtendedObjectOfUninstall[];
28052818
/** Every extending package, once each, in the order the objects name them. */
28062819
readonly extenders: readonly string[];
2820+
/**
2821+
* [#28408, ADR-0146 D3] The base's paired customization package, when this
2822+
* registry holds it — `undefined` when that is not a cause of this refusal.
2823+
*/
2824+
readonly customizationPackageId: string | undefined;
28072825

2808-
constructor(packageId: string, extendedObjects: readonly ExtendedObjectOfUninstall[]) {
2826+
constructor(
2827+
packageId: string,
2828+
extendedObjects: readonly ExtendedObjectOfUninstall[],
2829+
customizationPackage?: string,
2830+
) {
28092831
const extenders = [...new Set(extendedObjects.flatMap((o) => o.extenders))];
28102832
const listed = extendedObjects
28112833
.slice(0, EXTENDED_OBJECTS_LISTED)
28122834
.map((o) => `object "${o.objectName}" is extended by ${o.extenders.join(', ')}`);
28132835
const more = extendedObjects.length - listed.length;
2836+
const extendersStep = extendedObjects.length === 0 ? undefined :
2837+
`the ${extenders.length === 1 ? 'package that extends' : 'packages that extend'} ` +
2838+
`${extendedObjects.length === 1 ? 'an object' : 'objects'} of "${packageId}"`;
2839+
const extendersFact = extendedObjects.length === 0 ? undefined :
2840+
`${listed.join('; ')}${more > 0 ? `; and ${more} more extended object${more === 1 ? '' : 's'}` : ''}`;
2841+
// The pair's step names its own export door, so "export it or delete it"
2842+
// is one instruction: export to keep what it holds, then delete it.
2843+
const pairStep = customizationPackage === undefined ? undefined :
2844+
`the customization package "${customizationPackage}" first (to keep its customizations, export it ` +
2845+
`before that: GET /api/v1/packages/${customizationPackage}/export)`;
2846+
const extendersReason = 'a package cannot be uninstalled while another package extends an object it owns ' +
2847+
'(objectExtensions, ADR-0029)';
2848+
const pairReason = 'while its paired customization package is installed, which would strand every ' +
2849+
'customization it holds (ADR-0146 D3)';
28142850
super(
2815-
`Uninstall the ${extenders.length === 1 ? 'package that extends' : 'packages that extend'} ` +
2816-
`${extendedObjects.length === 1 ? 'an object' : 'objects'} of "${packageId}" first ` +
2817-
`(${extenders.join(', ')}), then uninstall "${packageId}" again: a package cannot be ` +
2818-
`uninstalled while another package extends an object it owns (objectExtensions, ADR-0029). ` +
2819-
`Cannot uninstall package "${packageId}": ${listed.join('; ')}` +
2820-
`${more > 0 ? `; and ${more} more extended object${more === 1 ? '' : 's'}` : ''}.`,
2851+
extendersStep !== undefined && pairStep !== undefined
2852+
? `Uninstall ${extendersStep} (${extenders.join(', ')}) and delete ${pairStep}, then uninstall ` +
2853+
`"${packageId}" again: ${extendersReason}, nor ${pairReason}. Cannot uninstall package ` +
2854+
`"${packageId}": ${extendersFact}; and its customization package "${customizationPackage}" is installed.`
2855+
: pairStep !== undefined
2856+
? `Delete ${pairStep}, then uninstall "${packageId}" again: a package cannot be uninstalled ` +
2857+
`${pairReason}. Cannot uninstall package "${packageId}": its customization package ` +
2858+
`"${customizationPackage}" is installed.`
2859+
: `Uninstall ${extendersStep} first (${extenders.join(', ')}), then uninstall "${packageId}" again: ` +
2860+
`${extendersReason}. Cannot uninstall package "${packageId}": ${extendersFact}.`,
28212861
);
28222862
this.name = 'PackageUninstallExtendedError';
28232863
this.packageId = packageId;
28242864
this.extendedObjects = extendedObjects;
28252865
this.extenders = extenders;
2866+
this.customizationPackageId = customizationPackage;
28262867
}
28272868
}
28282869

@@ -5631,13 +5672,23 @@ export class SchemaRegistry {
56315672
* its prescription is the whole way out. The decision is unchanged: the
56325673
* package is refused exactly when the first such object exists.
56335674
*
5675+
* [#28408, ADR-0146 D3] It also refuses while this registry holds the
5676+
* package's paired customization package — the id
5677+
* {@link customizationPackageId} derives from `packageId`, so the pair is
5678+
* found by id alone, whichever door created or installed it. The same
5679+
* refusal, carrying both causes when both hold. Uninstalling the pair itself
5680+
* asks nothing of this kind: a `.custom` id derives no pair of its own. An
5681+
* id that derives no valid pair id (`@acme/crm`) cannot have one installed,
5682+
* so it is not asked either.
5683+
*
56345684
* ⛔ ONE predicate: {@link unregisterObjectsByPackage} calls this method
56355685
* rather than keeping its own copy, so the question asked ahead and the
56365686
* refusal raised by the uninstall cannot disagree. `force` is not a
56375687
* parameter here: forcing means not asking, and stays the caller's choice.
56385688
*
56395689
* @throws PackageUninstallExtendedError (`RESOURCE_CONFLICT` / 409) if the
5640-
* package owns an object another package extends (ADR-0029)
5690+
* package owns an object another package extends (ADR-0029), or its
5691+
* paired customization package is installed (ADR-0146 D3)
56415692
*/
56425693
assertPackageUninstallable(packageId: string): void {
56435694
const extended: ExtendedObjectOfUninstall[] = [];
@@ -5654,7 +5705,11 @@ export class SchemaRegistry {
56545705
extended.push({ objectName: fqn, extenders: otherExtenders.map(c => String(c.packageId)) });
56555706
}
56565707
}
5657-
if (extended.length > 0) throw new PackageUninstallExtendedError(packageId, extended);
5708+
const pair = customizationPackageId(packageId);
5709+
const installedPair = pair.ok && this.getPackage(pair.value) !== undefined ? pair.value : undefined;
5710+
if (extended.length > 0 || installedPair !== undefined) {
5711+
throw new PackageUninstallExtendedError(packageId, extended, installedPair);
5712+
}
56585713
}
56595714

56605715
/**
@@ -5668,7 +5723,8 @@ export class SchemaRegistry {
56685723
* its own mutations.
56695724
*
56705725
* @throws PackageUninstallExtendedError (`RESOURCE_CONFLICT` / 409) if trying
5671-
* to uninstall an owner that has extenders — {@link assertPackageUninstallable}'s
5726+
* to uninstall an owner that has extenders, or a base whose paired
5727+
* customization package is installed — {@link assertPackageUninstallable}'s
56725728
*/
56735729
unregisterObjectsByPackage(packageId: string, force: boolean = false): void {
56745730
// [#7970] REFUSAL PASS — see {@link assertPackageUninstallable}, which holds

0 commit comments

Comments
 (0)