Skip to content

Commit 989aa48

Browse files
fix(objectql): an uninstall refused because another package extends one of its objects answers 409 RESOURCE_CONFLICT with the way out, not 500 INTERNAL_ERROR (#23522)
Fixes #23505 Clause-②: no Dispatched by the `domain:spec` seat 4 PM (`sunhaolin`) to session `session_0121ooBYRmS8ziFXm9ai5YHm`, branch `claude/issue-23505-extension-refusal-409`, claim comment 6172782630. ## What changed `SchemaRegistry.assertPackageUninstallable` (`packages/objectql/src/registry.ts`) threw a bare `Error` with no `code` and no `status` when the package owns an object another package extends (ADR-0029). `deletePackage` step 3 rethrows it as is, so `DELETE /api/v1/packages/:id` fell through `resolveThrownHttpError` to its `500 INTERNAL_ERROR` fallback. The caller could change the state that blocked the uninstall, but the answer said the server failed. The fix is at the producer. The refusal is now an unexported `PackageUninstallExtendedError`, in the registry's house shape (`code`, `status` and `httpStatus`, like `NamespaceConflictError` and `ObjectOwnershipConflictError`): - **`code: 'RESOURCE_CONFLICT'`, `status: 409`.** No new code: this is the standard catalog member that the verb's sibling refusals already answer for environment state (`dropStorageRemainingRowsRefusal`, and the shared-object pre-flight in PR #23504). - **The way out comes first**, because a 4xx message can be truncated. The diagnostic sentence the refusal always carried follows, and it now names every extended object (the first five, then a count of the rest), each with its extenders in registration order. Measured at the door: > Uninstall the package that extends an object of "com.owner" first (com.ext), then uninstall "com.owner" again: a package cannot be uninstalled while another package extends an object it owns (objectExtensions, ADR-0029). Cannot uninstall package "com.owner": object "alpha" is extended by com.ext. - **Structured fields** for in-process callers: `packageId`, `extendedObjects`, `extenders`. The decision itself is unchanged: a package is refused in the same cases as before, and before any mutation. `packages/metadata-protocol/src/protocol.ts` is not edited. Step 3 already rethrows the registry's error as is, so the door gets the status without touching steps 1–4, which PR #23504 holds. ## Every caller, measured | Caller | How it meets the throw | What changes | |---|---|---| | `deletePackage` step 3, then `DELETE /api/v1/packages/:id` (`errorFromThrown(e, 500)`) | rethrown as is | `500 INTERNAL_ERROR` becomes `409 RESOURCE_CONFLICT` with the prescription. Nothing changes in storage or the registry (pinned below). | | The same door on a host with no persisted half (the door calls `registry.uninstallPackage(id)` itself; terminal `errorFromThrown`) | thrown through `uninstallPackage` | Also `500` to `409 RESOURCE_CONFLICT`. The package and its object stay registered. Measured with a one-off probe, deleted after reading. Not pinned. | | The door's safety net after a persisted delete, and `deletePackage` step 6 | caught; `console.warn` with the message | Log text only. | | `@objectstack/cloud-connection` install-local withdrawal | caught; reads `err.message` only | The `cleanups` outcome is unchanged; only the log text changes. | | `MetadataFacade.unregisterPackage`, `ObjectQL.unregisterObject(name, packageId)` | propagate | No production caller in the tree (`git grep`). | | `ObjectQL.removePackage` | `force: true` | Never asks. | ## Tests Every reading below is at `fdc2dc05fc` (this branch after merging `origin/main` at `222293f60a`), except the full objectql run, which is noted. - **`packages/runtime/src/package-uninstall-store-refusal.integration.test.ts`** (the existing pin). It runs on a real stack: `ObjectQL` over `SqlDriver`, `PackageServicePlugin`, the protocol and `HttpDispatcher`, booted twice over one SQLite file. - The extender case moves from `{ status: 500, code: 'INTERNAL_ERROR' }` to `{ status: 409, code: 'RESOURCE_CONFLICT' }`. It also asserts the opening of the prescription and the object's name. - Every "nothing changes" assertion is kept: stored rows, registry entry and disable record, in the same process and after a restart. - New **CONTROL** case: the extender is installed as a real package, so the first uninstall is refused `409`. Then `DELETE` of the extender through the same door, and the same uninstall answers `200` and stays gone after a restart. - Result: 5/5 pass. - **`packages/objectql/src/registry-assert-package-uninstallable.test.ts`** pins: - the envelope; - the prescription first; - every object and extender named, in walk order; - the singular wording; - the five-then-count cap; - the same refusal through `unregisterObjectsByPackage` and `uninstallPackage`. - **`packages/objectql/src/registry.test.ts`**: the order pin added for the refusal-pass ordering fix now reads the order off the structured refusal (first refusable object first, its extenders in registration order) instead of the old sentence. | Run | Result | |---|---| | runtime `package-uninstall-*` + `domains/packages-*` | 29 files / 537 tests pass | | runtime pin + `package-uninstall-org-scope` | 2 / 11 pass | | objectql `registry*.test.ts` + the two objectql tests the merge brought | 37 / 480 pass | | metadata-protocol `deletePackage` tests | 10 / 140 pass | | objectql `vitest run --project local` (at `a9a88b1e34`; the merge changed no objectql source, only the two tests re-run above) | 415 / 8095 pass | | `pnpm --filter @objectstack/objectql typecheck`, same for runtime | exit 0; test-typecheck debt held | ## Ablation (the producer's `code` and `status` renamed away) The mutation went through `scripts/ablation-replace.mjs` (anchor hit 1 time; blob `69df6079e5a7` to `7d89ea3120fa`). objectql was rebuilt, and `ablation-dist-preflight` found the planted marker in 4 dist files. - **Re-pinned case: red on exactly the status and code assertion.** It expected `{ status: 409, code: 'RESOURCE_CONFLICT' }` and received `{ status: 500, code: 'INTERNAL_ERROR' }`, which is the pre-fix answer. - **CONTROL: red on its precondition** (`expected 500 to be 409`). - **Envelope test in `registry-assert-package-uninstallable.test.ts`: red.** - **Restore.** The blob equals HEAD, `git diff HEAD` is empty and `git status --porcelain` is empty. After an objectql rebuild, `--absent` found the marker in 0 of 14 dist files, and the pin passed 5/5 again. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` at `fdc2dc05fc`: 68 derived, 67 run with `exit 0`, 1 NOT-MEASURED, 0 UNRUN. - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`.** It answered `PREREQUISITE NOT MET` (exit 3) because it reads every package's `dist/` and no full workspace build was run locally. CI runs it. - **The families this change reads most directly:** - `check:dispatcher-error-vocabulary` is green. `RESOURCE_CONFLICT` is a standard member, so no table row is owed. - `check:error-status-conformance` is green. Its deriver records `RESOURCE_CONFLICT`/409 from `class PackageUninstallExtendedError`; I measured this by calling `deriveRuntimeStatuses` on `registry.ts`, because the report shows only four sites per code. - `check-adr-0087-registration` reports "no declared-breaking changeset (1 non-breaking changeset(s) seen)". - `check-changeset-no-major` and `check-empty-changeset` are green. - **Extra readings outside the derived set:** `check:error-code-provenance` and `check:error-code-casing`, both `exit 0`. The changeset is `.changeset/23505-extension-refusal-409.md`: `@objectstack/objectql` patch, with FROM, TO and Unchanged sections. ## Acceptance notes - **Two comments now say `500` where the door answers `409`.** Both are outside this claim's file surface and change no behaviour: - In `packages/metadata-protocol/src/protocol.ts`, `deletePackage` step 3: "The HTTP door answers it through its `catch` around this verb: `500`, nothing changed." PR #23504 holds that block. - In `packages/runtime/src/domains/packages.ts`, in the DELETE arm: "which the `catch` above answers — `500`, nothing changed." - Suggested wording for both: `409 RESOURCE_CONFLICT`, the refusal's own envelope, nothing changed. Carrier: none. - **A test fixture keeps the old text.** `packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts` builds its fake refusal with the old sentence. It pins pass-through identity, not the text, so it stays valid. - **"Detach" is not offered as a way out.** No verb detaches an extension short of uninstalling the extending package, or installing a version without it (not measured), so the message names only the measured remedy. - **The no-persisted-half door path** (second row of the table) moves `500` to `409` too. It was measured, not pinned. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0be162c commit 989aa48

5 files changed

Lines changed: 271 additions & 34 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): an uninstall refused because another package extends one of its objects answers `409 RESOURCE_CONFLICT` with the way out, instead of `500 INTERNAL_ERROR` (ADR-0029)
6+
7+
Clause-②: no
8+
9+
`SchemaRegistry.assertPackageUninstallable` refuses to uninstall a package while another package extends an object it owns (a manifest's `objectExtensions`, ADR-0029). `unregisterObjectsByPackage` and `uninstallPackage` raise the same refusal, because they ask it. When the refusal happens does not change, and a refused uninstall still removes nothing. Only the error it throws changes.
10+
11+
**FROM.** The error was a bare `Error` with no `code` and no `status`. Its message, `Cannot uninstall package "P": object "O" is extended by E. Uninstall extenders first.`, named only the first extended object. `DELETE /api/v1/packages/P` answered `500 INTERNAL_ERROR`, a server failure, although the caller can change the state that blocks the uninstall.
12+
13+
**TO.** The error carries `code: 'RESOURCE_CONFLICT'` and `status: 409` (and `httpStatus: 409`), so `DELETE /api/v1/packages/P` answers `409 RESOURCE_CONFLICT`. The message starts with the way out: `Uninstall the package that extends an object of "P" first (E), then uninstall "P" again: …`. It then names every extended object with the packages that extend it: the first five objects, then a count of the rest. The thrown error also carries `packageId`, `extendedObjects` and `extenders`.
14+
15+
**Unchanged.** A package is refused in the same cases as before. A refused uninstall changes nothing: the `sys_packages` row, the registry entry, the stored metadata rows and the disable record all stay. `unregisterObjectsByPackage(id, true)` still does not ask.
16+
17+
**If you matched the old text.** Match `error.code === 'RESOURCE_CONFLICT'` (HTTP `409`) instead. The message still contains `Cannot uninstall package "P": object "O" is extended by E`, after the way out. The closing `Uninstall extenders first.` is gone.

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

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,25 @@
1010
* mutates when it does not refuse. The refusal pass (#7970) is now this one
1111
* method, and `unregisterObjectsByPackage` calls it: ONE predicate.
1212
*
13+
* #23505 — the refusal carries the ADR-0112 envelope, `RESOURCE_CONFLICT` /
14+
* 409, so the uninstall door answers a located client refusal instead of its
15+
* `500 INTERNAL_ERROR` fallback; its message opens with the way out and names
16+
* every extended object and every extending package.
17+
*
1318
* Pinned here:
14-
* 1. it refuses with the uninstall's exact sentence, and mutates nothing;
15-
* 2. it answers normally for an uninstallable package, and mutates nothing;
16-
* 3. `unregisterObjectsByPackage` still refuses with that same sentence, by
17-
* calling this method (not a copy of its predicate), and `force` still
18-
* means "do not ask".
19+
* 1. it refuses with that envelope, the way out first, every extended object
20+
* and extender named, and mutates nothing;
21+
* 2. one extended object with one extender reads in the singular, and past
22+
* five extended objects the rest are counted, never dropped silently;
23+
* 3. it answers normally for an uninstallable package, and mutates nothing;
24+
* 4. `unregisterObjectsByPackage` and `uninstallPackage` refuse with that
25+
* same refusal, by calling it (not a copy of its predicate), and `force`
26+
* still means "do not ask".
1927
*/
2028

2129
import { describe, it, expect, beforeEach, vi } from 'vitest';
2230
import { SchemaRegistry } from './registry';
2331

24-
const REFUSAL =
25-
'Cannot uninstall package "com.owner": object "alpha" is extended by com.ext1, com.ext2. Uninstall extenders first.';
26-
2732
/** Every contributor of every object the fixture registers, as plain data. */
2833
function contributorsOf(registry: SchemaRegistry) {
2934
return ['free', 'alpha', 'beta'].map((name) => ({
@@ -33,6 +38,16 @@ function contributorsOf(registry: SchemaRegistry) {
3338
}));
3439
}
3540

41+
/** The error `fn` throws — it must throw. */
42+
function thrownBy(fn: () => unknown): any {
43+
try {
44+
fn();
45+
} catch (e) {
46+
return e;
47+
}
48+
throw new Error('expected a throw');
49+
}
50+
3651
describe('#21276 SchemaRegistry.assertPackageUninstallable', () => {
3752
let registry: SchemaRegistry;
3853

@@ -48,15 +63,63 @@ describe('#21276 SchemaRegistry.assertPackageUninstallable', () => {
4863
registry.registerObject({ name: 'beta', fields: {} } as any, 'com.ext3', undefined, 'extend');
4964
});
5065

51-
it('refuses with the uninstall\'s exact sentence, and mutates nothing', () => {
66+
it('[#23505] refuses RESOURCE_CONFLICT / 409, the way out first, every extended object and extender named, and mutates nothing', () => {
5267
const before = contributorsOf(registry);
5368

54-
expect(() => registry.assertPackageUninstallable('com.owner')).toThrow(REFUSAL);
69+
const err = thrownBy(() => registry.assertPackageUninstallable('com.owner'));
70+
71+
expect({ code: err.code, status: err.status, httpStatus: err.httpStatus }).toEqual({
72+
code: 'RESOURCE_CONFLICT',
73+
status: 409,
74+
httpStatus: 409,
75+
});
76+
expect(err.message.startsWith(
77+
'Uninstall the packages that extend objects of "com.owner" first (com.ext1, com.ext2, com.ext3), '
78+
+ 'then uninstall "com.owner" again',
79+
), err.message).toBe(true);
80+
// The named subjects, in walk order: the first refusable object first,
81+
// each with its extenders in registration order.
82+
expect(err.packageId).toBe('com.owner');
83+
expect(err.extendedObjects).toEqual([
84+
{ objectName: 'alpha', extenders: ['com.ext1', 'com.ext2'] },
85+
{ objectName: 'beta', extenders: ['com.ext3'] },
86+
]);
87+
expect(err.extenders).toEqual(['com.ext1', 'com.ext2', 'com.ext3']);
88+
expect(err.message).toContain('"alpha"');
89+
expect(err.message).toContain('"beta"');
90+
expect(err.message).not.toContain('"free"');
5591

5692
expect(contributorsOf(registry)).toEqual(before);
5793
expect(before.every((o) => o.resolves)).toBe(true);
5894
});
5995

96+
it('[#23505] one extended object with one extender reads in the singular', () => {
97+
const r = new SchemaRegistry({ multiTenant: false });
98+
r.registerObject({ name: 'leave_request', fields: {} } as any, 'com.leave', 'base', 'own');
99+
r.registerObject({ name: 'leave_request', fields: {} } as any, 'com.addon', undefined, 'extend');
100+
101+
const err = thrownBy(() => r.assertPackageUninstallable('com.leave'));
102+
103+
expect(err.message.startsWith(
104+
'Uninstall the package that extends an object of "com.leave" first (com.addon), then uninstall "com.leave" again',
105+
), err.message).toBe(true);
106+
});
107+
108+
it('[#23505] past five extended objects the rest are counted, and every extender is still named', () => {
109+
const r = new SchemaRegistry({ multiTenant: false });
110+
const names = ['o1', 'o2', 'o3', 'o4', 'o5', 'o6', 'o7'];
111+
for (const name of names) r.registerObject({ name, fields: {} } as any, 'com.big', 'base', 'own');
112+
names.forEach((name, i) => r.registerObject({ name, fields: {} } as any, `com.x${i}`, undefined, 'extend'));
113+
114+
const err = thrownBy(() => r.assertPackageUninstallable('com.big'));
115+
116+
expect(err.extendedObjects).toHaveLength(7);
117+
for (const name of names.slice(0, 5)) expect(err.message).toContain(`"${name}"`);
118+
expect(err.message).not.toContain('"o6"');
119+
expect(err.message).toContain('2 more');
120+
for (let i = 0; i < 7; i += 1) expect(err.message).toContain(`com.x${i}`);
121+
});
122+
60123
it('answers normally for a package nothing extends, and mutates nothing', () => {
61124
registry.registerObject({ name: 'free', fields: {} } as any, 'com.ext1', undefined, 'extend');
62125
const before = contributorsOf(registry);
@@ -67,14 +130,29 @@ describe('#21276 SchemaRegistry.assertPackageUninstallable', () => {
67130
expect(contributorsOf(registry)).toEqual(before);
68131
});
69132

70-
it('unregisterObjectsByPackage refuses with the same sentence BY calling it; force does not ask', () => {
133+
it('unregisterObjectsByPackage and uninstallPackage refuse with the same refusal BY calling it; force does not ask', () => {
134+
const asked = thrownBy(() => registry.assertPackageUninstallable('com.owner'));
71135
const ask = vi.spyOn(registry, 'assertPackageUninstallable');
72136

73-
expect(() => registry.unregisterObjectsByPackage('com.owner')).toThrow(REFUSAL);
137+
const viaUnregister = thrownBy(() => registry.unregisterObjectsByPackage('com.owner'));
74138
expect(ask).toHaveBeenCalledTimes(1);
75139
expect(ask).toHaveBeenCalledWith('com.owner');
76140
expect(registry.getObject('free')).toBeDefined();
77141

142+
registry.installPackage({ id: 'com.owner', name: 'Owner', version: '1.0.0' } as any);
143+
ask.mockClear();
144+
const viaUninstall = thrownBy(() => registry.uninstallPackage('com.owner'));
145+
expect(ask).toHaveBeenCalledTimes(1);
146+
expect(registry.getPackage('com.owner')).toBeDefined();
147+
148+
for (const err of [viaUnregister, viaUninstall]) {
149+
expect({ code: err.code, status: err.status, message: err.message }).toEqual({
150+
code: asked.code,
151+
status: asked.status,
152+
message: asked.message,
153+
});
154+
}
155+
78156
ask.mockClear();
79157
registry.unregisterObjectsByPackage('com.owner', true);
80158
expect(ask).not.toHaveBeenCalled();

‎packages/objectql/src/registry.test.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -383,18 +383,28 @@ describe('SchemaRegistry', () => {
383383
* pass BOTH before and after the fix: run it against the pre-fix
384384
* `registry.ts` and it stays green, which is what proves the message did
385385
* not move (only the mutations that used to precede it are gone).
386+
*
387+
* [#23505] The message did move, deliberately: it opens with the way
388+
* out and goes on to name EVERY extended object, not only the first.
389+
* The order this test holds is unchanged — the first refusable object
390+
* walked is named first, with its extenders in registration order — so
391+
* it reads that order off the structured refusal rather than off prose.
386392
*/
387-
it('[#7970] the refusal still names the first refusable object and all its extenders', () => {
393+
it('[#7970] the refusal still names the first refusable object first, with all its extenders', () => {
388394
registry.registerObject({ name: 'alpha', fields: {} }, 'com.owner', 'base', 'own');
389395
registry.registerObject({ name: 'beta', fields: {} }, 'com.owner', 'base', 'own');
390396
registry.registerObject({ name: 'alpha', fields: {} }, 'com.ext1', undefined, 'extend');
391397
registry.registerObject({ name: 'alpha', fields: {} }, 'com.ext2', undefined, 'extend');
392398
registry.registerObject({ name: 'beta', fields: {} }, 'com.ext3', undefined, 'extend');
393399

394-
expect(() => registry.unregisterObjectsByPackage('com.owner')).toThrow(
395-
'Cannot uninstall package "com.owner": object "alpha" is extended by ' +
396-
'com.ext1, com.ext2. Uninstall extenders first.',
397-
);
400+
let err: any;
401+
try {
402+
registry.unregisterObjectsByPackage('com.owner');
403+
} catch (e) {
404+
err = e;
405+
}
406+
expect(err?.extendedObjects?.[0]).toEqual({ objectName: 'alpha', extenders: ['com.ext1', 'com.ext2'] });
407+
expect(err.message.indexOf('"alpha"')).toBeLessThan(err.message.indexOf('"beta"'));
398408
});
399409

400410
it('[#7970] force still removes the owner even with a free sibling ahead of it', () => {

‎packages/objectql/src/registry.ts‎

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,6 +2002,82 @@ export class ObjectOwnershipConflictError extends Error {
20022002
}
20032003
}
20042004

2005+
/** One object {@link PackageUninstallExtendedError} names, with the packages extending it. */
2006+
interface ExtendedObjectOfUninstall {
2007+
/** The object's fully-qualified name, as the registry keys it. */
2008+
readonly objectName: string;
2009+
/** Every OTHER package contributing an `extend` to it, in registration order. */
2010+
readonly extenders: readonly string[];
2011+
}
2012+
2013+
/** How many extended objects the refusal spells out before it counts the rest. */
2014+
const EXTENDED_OBJECTS_LISTED = 5;
2015+
2016+
/**
2017+
* [#23505] The uninstall refusal of ADR-0029: a package cannot leave while
2018+
* another package `extend`s an object it owns (a manifest's
2019+
* `objectExtensions`, registered by `ObjectQL.registerApp`). Raised by
2020+
* {@link SchemaRegistry.assertPackageUninstallable}, and so by
2021+
* {@link SchemaRegistry.unregisterObjectsByPackage} and
2022+
* {@link SchemaRegistry.uninstallPackage}, which ask it.
2023+
*
2024+
* Carries the ADR-0112 envelope (`code` + `status`), like the registry's other
2025+
* refusals above. Before it carried one, this was a bare `Error`, and
2026+
* `DELETE /api/v1/packages/:id` — which reaches it through `deletePackage`'s
2027+
* pre-flight, or through `uninstallPackage` on a host with no persisted half —
2028+
* fell through `resolveThrownHttpError` to the door's `500` fallback: measured
2029+
* on the real stack as `500 INTERNAL_ERROR`, which tells a client, an AI author
2030+
* or an operator that the server failed, invites a retry, and changes nothing.
2031+
* The state is the caller's to change: uninstall the extending packages first.
2032+
*
2033+
* `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member for
2034+
* a refusal that comes from environment state rather than the request (the
2035+
* same uninstall is accepted once the extenders are gone), as the uninstall
2036+
* verb's sibling refusals in `@objectstack/metadata-protocol` answer. The
2037+
* prescription comes first, because a 4xx message can be truncated at a REST
2038+
* boundary; the diagnostic sentence the refusal always carried follows it.
2039+
*
2040+
* Every extended object is named (the first {@link EXTENDED_OBJECTS_LISTED},
2041+
* then a count), in the order the registry walks them, each with its extenders
2042+
* in registration order — and the prescription names every extending package,
2043+
* so one answer is the whole way out rather than the first step of several.
2044+
*
2045+
* ⛔ Unexported, like its siblings: a consumer compares `code`, never
2046+
* `instanceof` (see the note over the `*_CODE` constants).
2047+
*/
2048+
class PackageUninstallExtendedError extends Error {
2049+
readonly code = 'RESOURCE_CONFLICT';
2050+
readonly status = 409;
2051+
/** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */
2052+
readonly httpStatus = 409;
2053+
/** The package whose uninstall this refusal stopped. */
2054+
readonly packageId: string;
2055+
/** Every object of `packageId` another package extends, in walk order. */
2056+
readonly extendedObjects: readonly ExtendedObjectOfUninstall[];
2057+
/** Every extending package, once each, in the order the objects name them. */
2058+
readonly extenders: readonly string[];
2059+
2060+
constructor(packageId: string, extendedObjects: readonly ExtendedObjectOfUninstall[]) {
2061+
const extenders = [...new Set(extendedObjects.flatMap((o) => o.extenders))];
2062+
const listed = extendedObjects
2063+
.slice(0, EXTENDED_OBJECTS_LISTED)
2064+
.map((o) => `object "${o.objectName}" is extended by ${o.extenders.join(', ')}`);
2065+
const more = extendedObjects.length - listed.length;
2066+
super(
2067+
`Uninstall the ${extenders.length === 1 ? 'package that extends' : 'packages that extend'} ` +
2068+
`${extendedObjects.length === 1 ? 'an object' : 'objects'} of "${packageId}" first ` +
2069+
`(${extenders.join(', ')}), then uninstall "${packageId}" again: a package cannot be ` +
2070+
`uninstalled while another package extends an object it owns (objectExtensions, ADR-0029). ` +
2071+
`Cannot uninstall package "${packageId}": ${listed.join('; ')}` +
2072+
`${more > 0 ? `; and ${more} more extended object${more === 1 ? '' : 's'}` : ''}.`,
2073+
);
2074+
this.name = 'PackageUninstallExtendedError';
2075+
this.packageId = packageId;
2076+
this.extendedObjects = extendedObjects;
2077+
this.extenders = extenders;
2078+
}
2079+
}
2080+
20052081
// [commit fa5d137ab] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
20062082
// They now live in `@objectstack/metadata-core`
20072083
// (`code-artifact-provenance.ts`), imported at the top of this file and
@@ -4244,16 +4320,25 @@ export class SchemaRegistry {
42444320
* that exists to keep a registry whole was itself reached through a
42454321
* mutation, and nothing rolled it back. Same predicate and same iteration
42464322
* order as the inline check it replaced, so the same object still refuses
4247-
* with the same message.
4323+
* first.
4324+
*
4325+
* [#23505] The refusal is a {@link PackageUninstallExtendedError} —
4326+
* `RESOURCE_CONFLICT` / 409, so the uninstall door answers a located client
4327+
* refusal instead of its `500` fallback — and it names EVERY extended object
4328+
* and every extending package, not only the first object the walk meets, so
4329+
* its prescription is the whole way out. The decision is unchanged: the
4330+
* package is refused exactly when the first such object exists.
42484331
*
42494332
* ⛔ ONE predicate: {@link unregisterObjectsByPackage} calls this method
42504333
* rather than keeping its own copy, so the question asked ahead and the
42514334
* refusal raised by the uninstall cannot disagree. `force` is not a
42524335
* parameter here: forcing means not asking, and stays the caller's choice.
42534336
*
4254-
* @throws Error if the package owns an object another package extends (ADR-0029)
4337+
* @throws PackageUninstallExtendedError (`RESOURCE_CONFLICT` / 409) if the
4338+
* package owns an object another package extends (ADR-0029)
42554339
*/
42564340
assertPackageUninstallable(packageId: string): void {
4341+
const extended: ExtendedObjectOfUninstall[] = [];
42574342
for (const [fqn, contributors] of this.objectContributors.entries()) {
42584343
const ownedHere = contributors.some(
42594344
c => c.packageId === packageId && c.ownership === 'own'
@@ -4264,12 +4349,10 @@ export class SchemaRegistry {
42644349
c => c.packageId !== packageId && c.ownership === 'extend'
42654350
);
42664351
if (otherExtenders.length > 0) {
4267-
throw new Error(
4268-
`Cannot uninstall package "${packageId}": object "${fqn}" is extended by ` +
4269-
`${otherExtenders.map(c => c.packageId).join(', ')}. Uninstall extenders first.`
4270-
);
4352+
extended.push({ objectName: fqn, extenders: otherExtenders.map(c => String(c.packageId)) });
42714353
}
42724354
}
4355+
if (extended.length > 0) throw new PackageUninstallExtendedError(packageId, extended);
42734356
}
42744357

42754358
/**
@@ -4282,7 +4365,8 @@ export class SchemaRegistry {
42824365
* a no-op, which is what lets {@link uninstallPackage} run this verb ahead of
42834366
* its own mutations.
42844367
*
4285-
* @throws Error if trying to uninstall an owner that has extenders
4368+
* @throws PackageUninstallExtendedError (`RESOURCE_CONFLICT` / 409) if trying
4369+
* to uninstall an owner that has extenders — {@link assertPackageUninstallable}'s
42864370
*/
42874371
unregisterObjectsByPackage(packageId: string, force: boolean = false): void {
42884372
// [#7970] REFUSAL PASS — see {@link assertPackageUninstallable}, which holds

0 commit comments

Comments
 (0)