Skip to content

Commit 01dae72

Browse files
committed
test: pin the uninstall refusal asked before the store delete — registry, protocol and door
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9688bde commit 01dae72

3 files changed

Lines changed: 132 additions & 15 deletions

File tree

‎packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const thrownDatabaseError: StoreDelete = async () => {
107107
};
108108

109109
/** One process over `world`. A second call over the same world is a restart. */
110-
function boot(world: World, storeDelete: StoreDelete = landed) {
110+
function boot(world: World, storeDelete: StoreDelete = landed, opts: { uninstallRefusal?: Error } = {}) {
111111
const rows = new Map<string, { manifest: Record<string, unknown>; status: string; enabled: boolean }>();
112112
const registry = {
113113
installPackage(manifest: Record<string, unknown>) {
@@ -117,6 +117,12 @@ function boot(world: World, storeDelete: StoreDelete = landed) {
117117
},
118118
getPackage: (id: string) => rows.get(id),
119119
getAllPackages: () => [...rows.values()],
120+
// The real `SchemaRegistry` verb: asks the uninstall's ADR-0029 refusal and
121+
// mutates nothing. It refuses only when the case says another package
122+
// extends an object this one owns.
123+
assertPackageUninstallable(_id: string) {
124+
if (opts.uninstallRefusal) throw opts.uninstallRefusal;
125+
},
120126
uninstallPackage(id: string) {
121127
world.steps.push('registry.uninstallPackage');
122128
return rows.delete(id);
@@ -236,6 +242,28 @@ describe('#21276 deletePackage — a refused sys_packages delete fails the unins
236242
});
237243
});
238244

245+
describe('#21276 deletePackage — the registry\'s uninstall refusal is asked BEFORE the store delete', () => {
246+
it('another package extends an object this one owns: the refusal is thrown as is, and the sys_packages row survives', async () => {
247+
const extender = new Error(
248+
'Cannot uninstall package "com.example.leave": object "leave_request" is extended by com.example.addon. Uninstall extenders first.',
249+
);
250+
const world = makeWorld();
251+
const before = snapshot(world);
252+
const { impl, registry } = boot(world, landed, { uninstallRefusal: extender });
253+
254+
const err = await rejectionOf(impl.deletePackage({ packageId: PKG, allTenants: true }));
255+
256+
// The registry's own error, unwrapped: the door answers it as it always did.
257+
expect(err).toBe(extender);
258+
// Asked before the first durable step: not even the store delete ran.
259+
expect(world.steps).toEqual([]);
260+
expect(snapshot(world)).toEqual(before);
261+
expect(registry.getPackage(PKG)).toBeDefined();
262+
// …and a restart therefore still has the package, whole.
263+
expect(boot(world).registry.getPackage(PKG)).toBeDefined();
264+
});
265+
});
266+
239267
describe('#21276 the restart — a fresh process over the same store holds the package as the uninstall reported it', () => {
240268
it.each(REFUSALS)('after a %s refusal, the package comes back WITH its metadata and grants', async (_label, _code, refusal) => {
241269
const world = makeWorld();
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21276 — `SchemaRegistry.assertPackageUninstallable` asks the uninstall's
5+
* ADR-0029 refusal without performing the uninstall.
6+
*
7+
* `deletePackage` (`@objectstack/metadata-protocol`) must decide this refusal
8+
* before it deletes the stored `sys_packages` row, and the only place the
9+
* registry used to decide it was inside `unregisterObjectsByPackage`, which
10+
* mutates when it does not refuse. The refusal pass (#7970) is now this one
11+
* method, and `unregisterObjectsByPackage` calls it: ONE predicate.
12+
*
13+
* 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+
*/
20+
21+
import { describe, it, expect, beforeEach, vi } from 'vitest';
22+
import { SchemaRegistry } from './registry';
23+
24+
const REFUSAL =
25+
'Cannot uninstall package "com.owner": object "alpha" is extended by com.ext1, com.ext2. Uninstall extenders first.';
26+
27+
/** Every contributor of every object the fixture registers, as plain data. */
28+
function contributorsOf(registry: SchemaRegistry) {
29+
return ['free', 'alpha', 'beta'].map((name) => ({
30+
name,
31+
resolves: registry.getObject(name) !== undefined,
32+
contributors: registry.getObjectContributors(name).map((c) => `${c.packageId}:${c.ownership}`),
33+
}));
34+
}
35+
36+
describe('#21276 SchemaRegistry.assertPackageUninstallable', () => {
37+
let registry: SchemaRegistry;
38+
39+
beforeEach(() => {
40+
registry = new SchemaRegistry({ multiTenant: false });
41+
// `free` is walked FIRST and is not extended; `alpha` is the first
42+
// refusable object, with two extenders; `beta` is refusable too.
43+
registry.registerObject({ name: 'free', fields: {} } as any, 'com.owner', 'base', 'own');
44+
registry.registerObject({ name: 'alpha', fields: {} } as any, 'com.owner', 'base', 'own');
45+
registry.registerObject({ name: 'beta', fields: {} } as any, 'com.owner', 'base', 'own');
46+
registry.registerObject({ name: 'alpha', fields: {} } as any, 'com.ext1', undefined, 'extend');
47+
registry.registerObject({ name: 'alpha', fields: {} } as any, 'com.ext2', undefined, 'extend');
48+
registry.registerObject({ name: 'beta', fields: {} } as any, 'com.ext3', undefined, 'extend');
49+
});
50+
51+
it('refuses with the uninstall\'s exact sentence, and mutates nothing', () => {
52+
const before = contributorsOf(registry);
53+
54+
expect(() => registry.assertPackageUninstallable('com.owner')).toThrow(REFUSAL);
55+
56+
expect(contributorsOf(registry)).toEqual(before);
57+
expect(before.every((o) => o.resolves)).toBe(true);
58+
});
59+
60+
it('answers normally for a package nothing extends, and mutates nothing', () => {
61+
registry.registerObject({ name: 'free', fields: {} } as any, 'com.ext1', undefined, 'extend');
62+
const before = contributorsOf(registry);
63+
64+
expect(() => registry.assertPackageUninstallable('com.ext1')).not.toThrow();
65+
expect(() => registry.assertPackageUninstallable('com.unknown')).not.toThrow();
66+
67+
expect(contributorsOf(registry)).toEqual(before);
68+
});
69+
70+
it('unregisterObjectsByPackage refuses with the same sentence BY calling it; force does not ask', () => {
71+
const ask = vi.spyOn(registry, 'assertPackageUninstallable');
72+
73+
expect(() => registry.unregisterObjectsByPackage('com.owner')).toThrow(REFUSAL);
74+
expect(ask).toHaveBeenCalledTimes(1);
75+
expect(ask).toHaveBeenCalledWith('com.owner');
76+
expect(registry.getObject('free')).toBeDefined();
77+
78+
ask.mockClear();
79+
registry.unregisterObjectsByPackage('com.owner', true);
80+
expect(ask).not.toHaveBeenCalled();
81+
});
82+
});

‎packages/runtime/src/package-uninstall-store-refusal.integration.test.ts‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
* 2. After a restart, the package is still there and still disabled.
2222
* 3. CONTROL: an ordinary delete removes it — `404` in the same process, `404`
2323
* after a restart (no resurrection) — and clears its disable record.
24-
* 4. The step that can still refuse after the store delete — the registry
25-
* withdrawal, when another package extends an object this one owns
26-
* (ADR-0029) — is answered as the stored state stands: `200` with
27-
* `registryRemoved: false`, the process serving the package until it
28-
* restarts, and gone after the restart.
24+
* 4. The registry's own uninstall refusal — another package extends an object
25+
* this one owns (ADR-0029) — is decided BEFORE the store delete
26+
* (`SchemaRegistry.assertPackageUninstallable`, asked by `deletePackage`):
27+
* the door answers `500` and nothing changes — the stored rows, the
28+
* registry entry and the disable record are all intact, in the same
29+
* process and after a restart. That is the envelope the door gave when it
30+
* withdrew the package itself first.
2931
*
3032
* ## The composition — the shipped pieces, booted twice over one database file
3133
*
@@ -247,28 +249,33 @@ describe('#21276 CONTROL — an ordinary delete removes the package, and a resta
247249
});
248250
});
249251

250-
describe('#21276 the refusal that can still come after the store delete — an ADR-0029 extender', () => {
251-
it('200 with registryRemoved: false; the process serves the package until it restarts, and the restart does not', async () => {
252+
describe('#21276 the registry\'s uninstall refusal (an ADR-0029 extender) is decided before the store delete', () => {
253+
it('500, and nothing changes: stored rows, registry entry and disable record intact, in the same process and after a restart', async () => {
252254
const dir = newDir();
253255
const first = await boot(dir);
254256
await seed(first);
255257
// Another package extends an object this one owns, so the registry refuses
256-
// to withdraw it. Registered in memory only: nothing about it is stored.
258+
// the uninstall. Registered in memory only: nothing about it is stored.
257259
first.engine.registry.registerObject({ name: 'leave_request', fields: { title: { type: 'text' } } } as any, PKG, 'leave', 'own');
258260
const fqn = first.engine.registry.getAllObjects(PKG).map((o: any) => o.name).find((n: string) => n.endsWith('leave_request'));
259261
first.engine.registry.registerObject({ name: fqn, fields: { note: { type: 'text' } } } as any, OTHER_PKG, undefined, 'extend');
260262

261263
const answer = await first.call('DELETE', `/${PKG}`);
262264

263-
expect(answer.status).toBe(200);
264-
expect(answer.body?.data).toMatchObject({ packageId: PKG, success: true, registryRemoved: false });
265-
expect((await first.call('GET', `/${PKG}`)).status).toBe(200);
266-
expect(await first.metadataRows()).toEqual([]);
267-
expect(loadDisabledPackageIds(ENV).has(PKG)).toBe(false);
265+
expect({ status: answer.status, code: answer.code }).toEqual({ status: 500, code: 'INTERNAL_ERROR' });
266+
const detail = await first.call('GET', `/${PKG}`);
267+
expect(detail.status).toBe(200);
268+
expect(detail.body?.data).toMatchObject({ enabled: false, status: 'disabled' });
269+
expect(first.engine.registry.getObject(fqn)).toBeDefined();
270+
expect(await first.metadataRows()).toEqual(['view/leave_list']);
271+
expect(loadDisabledPackageIds(ENV).has(PKG)).toBe(true);
268272
await first.destroy();
269273

270274
const restarted = await boot(dir);
271275

272-
expect((await restarted.call('GET', `/${PKG}`)).status).toBe(404);
276+
const after = await restarted.call('GET', `/${PKG}`);
277+
expect(after.status).toBe(200);
278+
expect(after.body?.data).toMatchObject({ enabled: false, status: 'disabled' });
279+
expect(await restarted.metadataRows()).toEqual(['view/leave_list']);
273280
});
274281
});

0 commit comments

Comments
 (0)