Skip to content

Commit 6ad13bb

Browse files
os-zhuangClaude
andauthored
fix(objectql): a refused package uninstall mutates nothing (#7970) (#8105)
* wip: reorder uninstallPackage refusal ahead of mutations (#7970) * changeset * test: pin refusal message identity under multiple extended objects (#7970) --------- Co-authored-by: Claude <support@objectstack.ai>
1 parent 36c2f00 commit 6ad13bb

3 files changed

Lines changed: 199 additions & 18 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a refused package uninstall now mutates nothing (#7970)
6+
7+
`SchemaRegistry.uninstallPackage` has one step that can **refuse**:
8+
`unregisterObjectsByPackage`, which throws when the package owns an object
9+
another package `extend`s (ADR-0029 — the refusal that tells an operator to
10+
uninstall the extenders first). That guard exists to keep a registry whole, and
11+
it was reached **through** mutations, so exercising it half-tore down the very
12+
package it was protecting. Two limbs were exposed:
13+
14+
- **The namespace.** `uninstallPackage` released it before calling the refusing
15+
verb. A refused uninstall therefore left the package installed — record,
16+
objects and items all intact — while its namespace no longer resolved, for the
17+
life of the process. The invariant was already written three lines below the
18+
defect ("a refused uninstall must remove nothing at all"); the code above it
19+
did the opposite.
20+
- **The package's other objects.** `unregisterObjectsByPackage` decided the
21+
refusal one object at a time, inside the walk that removes them, so a package
22+
owning `account` (free) and `contact` (extended) lost `account` on its way to
23+
refusing over `contact`.
24+
25+
Both are fixed by ordering, not by a transaction or a second probe: the refusing
26+
verb runs first in `uninstallPackage`, and the verb itself now decides the
27+
refusal across every object before removing any. A throw from
28+
`unregisterObjectsByPackage` is a no-op, which is what lets its callers run it
29+
ahead of their own mutations.
30+
31+
Unchanged: the refusal's message and the object it names, the `force: true` path
32+
(which skips the refusal and removes exactly what it removed before), and the
33+
successful uninstall's observable outcome. Grade is **latent** — no in-tree
34+
caller reaches the refusal path today, so no shipped behaviour was
35+
observably broken; this restores the invariant before one does.

packages/objectql/src/registry.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,60 @@ describe('SchemaRegistry', () => {
357357
registry.unregisterObjectsByPackage('com.owner', true);
358358
}).not.toThrow();
359359
});
360+
361+
it('[#7970] refuses before removing anything — a free sibling object survives', () => {
362+
// `free` is walked FIRST (Map insertion order) and used to be spliced
363+
// out on the way to refusing over `important`, so the refusal that
364+
// exists to keep the registry whole half-tore it down instead.
365+
registry.registerObject({ name: 'free', fields: {} }, 'com.owner', 'base', 'own');
366+
registry.registerObject({ name: 'important', fields: {} }, 'com.owner', 'base', 'own');
367+
registry.registerObject({ name: 'important', fields: {} }, 'com.ext', undefined, 'extend');
368+
369+
expect(() => {
370+
registry.unregisterObjectsByPackage('com.owner');
371+
}).toThrow(/object "important" is extended by com\.ext/);
372+
373+
expect(registry.getObject('free')).toBeDefined();
374+
expect(registry.getObject('important')).toBeDefined();
375+
});
376+
377+
/**
378+
* [#7970] MESSAGE IDENTITY, the half the reorder must not disturb. The
379+
* refusal pass replaces an inline check, so it must name the SAME object
380+
* and the SAME extenders as before — with two refusable objects the
381+
* first one walked still wins, and an object's extenders are still
382+
* listed in registration order. This test is deliberately written to
383+
* pass BOTH before and after the fix: run it against the pre-fix
384+
* `registry.ts` and it stays green, which is what proves the message did
385+
* not move (only the mutations that used to precede it are gone).
386+
*/
387+
it('[#7970] the refusal still names the first refusable object and all its extenders', () => {
388+
registry.registerObject({ name: 'alpha', fields: {} }, 'com.owner', 'base', 'own');
389+
registry.registerObject({ name: 'beta', fields: {} }, 'com.owner', 'base', 'own');
390+
registry.registerObject({ name: 'alpha', fields: {} }, 'com.ext1', undefined, 'extend');
391+
registry.registerObject({ name: 'alpha', fields: {} }, 'com.ext2', undefined, 'extend');
392+
registry.registerObject({ name: 'beta', fields: {} }, 'com.ext3', undefined, 'extend');
393+
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+
);
398+
});
399+
400+
it('[#7970] force still removes the owner even with a free sibling ahead of it', () => {
401+
registry.registerObject({ name: 'free', fields: {} }, 'com.owner', 'base', 'own');
402+
registry.registerObject({ name: 'important', fields: {} }, 'com.owner', 'base', 'own');
403+
registry.registerObject({ name: 'important', fields: {} }, 'com.ext', undefined, 'extend');
404+
405+
registry.unregisterObjectsByPackage('com.owner', true);
406+
407+
// The refusal pass is skipped under `force`, and the mutation pass is
408+
// unchanged: both of the package's contributions are gone, and the
409+
// extender's own contribution is left where it was.
410+
expect(registry.getObject('free')).toBeUndefined();
411+
expect(registry.getObjectOwner('important')).toBeUndefined();
412+
expect(registry.getObjectContributors('important')).toHaveLength(1);
413+
});
360414
});
361415

362416
// ==========================================
@@ -427,6 +481,64 @@ describe('SchemaRegistry', () => {
427481
expect(registry.getNamespaceOwner('test')).toBeUndefined();
428482
});
429483

484+
/**
485+
* [#7970] The uninstall's ONE refusable step is `unregisterObjectsByPackage`
486+
* (ADR-0029: you may not uninstall the owner of an object another package
487+
* extends). It now runs before every mutation, so reaching that refusal
488+
* costs nothing. The namespace is the limb that measured this: the release
489+
* used to run FIRST, so a refused uninstall left the package installed —
490+
* record, objects and items all intact — while its namespace no longer
491+
* resolved, for the life of the process. No test refused and then inspected
492+
* the namespace, which is exactly why the defect was invisible.
493+
*
494+
* Latent by grade: no in-tree caller reaches the refusal path today.
495+
*/
496+
it('[#7970] a refused uninstall leaves the namespace still resolving', () => {
497+
registry.installPackage({ id: 'com.crm', name: 'CRM', namespace: 'crm', version: '1.0.0' } as any);
498+
registry.registerObject({ name: 'contact', fields: {} }, 'com.crm', 'crm', 'own');
499+
registry.registerObject({ name: 'contact', fields: {} }, 'com.analytics', undefined, 'extend');
500+
501+
expect(registry.getNamespaceOwner('crm')).toBe('com.crm');
502+
503+
expect(() => registry.uninstallPackage('com.crm')).toThrow(
504+
/Cannot uninstall package "com\.crm".*extended by com\.analytics/,
505+
);
506+
507+
// The assertion the card names: refused ⇒ the namespace still resolves.
508+
expect(registry.getNamespaceOwner('crm')).toBe('com.crm');
509+
expect(registry.getNamespaceOwners('crm')).toEqual(['com.crm']);
510+
});
511+
512+
it('[#7970] a refused uninstall leaves the whole package intact, not just the namespace', () => {
513+
registry.installPackage({ id: 'com.crm', name: 'CRM', namespace: 'crm', version: '1.0.0' } as any);
514+
// Registered ahead of the extended object, so the object walk reaches
515+
// this one before it can refuse.
516+
registry.registerObject({ name: 'account', fields: {} }, 'com.crm', 'crm', 'own');
517+
registry.registerObject({ name: 'contact', fields: {} }, 'com.crm', 'crm', 'own');
518+
registry.registerObject({ name: 'contact', fields: {} }, 'com.analytics', undefined, 'extend');
519+
registry.registerItem('page', { name: 'home' }, 'name', 'com.crm');
520+
521+
expect(() => registry.uninstallPackage('com.crm')).toThrow(/extended by com\.analytics/);
522+
523+
// Every limb `uninstallPackage` mutates, in the order it mutates them.
524+
expect(registry.getNamespaceOwner('crm')).toBe('com.crm');
525+
expect(registry.getObject('account')).toBeDefined();
526+
expect(registry.getObject('contact')).toBeDefined();
527+
expect(registry.getItem('page', 'home')).toMatchObject({ name: 'home' });
528+
expect(registry.getPackage('com.crm')).toBeDefined();
529+
});
530+
531+
it('[#7970] the successful path still releases the namespace after the object verb', () => {
532+
registry.installPackage({ id: 'com.crm', name: 'CRM', namespace: 'crm', version: '1.0.0' } as any);
533+
registry.registerObject({ name: 'contact', fields: {} }, 'com.crm', 'crm', 'own');
534+
535+
expect(registry.uninstallPackage('com.crm')).toBe(true);
536+
537+
expect(registry.getNamespaceOwner('crm')).toBeUndefined();
538+
expect(registry.getObject('contact')).toBeUndefined();
539+
expect(registry.getPackage('com.crm')).toBeUndefined();
540+
});
541+
430542
it('updatePackageManifest merges editable fields, preserving lifecycle state', () => {
431543
registry.installPackage({ id: 'com.test', name: 'Old', version: '1.0.0' } as any);
432544
registry.disablePackage('com.test'); // lifecycle state that must survive an edit

packages/objectql/src/registry.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,28 +1838,53 @@ export class SchemaRegistry {
18381838

18391839
/**
18401840
* Unregister all objects contributed by a package.
1841-
*
1841+
*
1842+
* [#7970] **Refuses before it mutates.** If any object this package owns is
1843+
* extended by another package (ADR-0029), the call throws having removed
1844+
* nothing — the refusal is decided across every object first. Callers may
1845+
* therefore treat a throw as a no-op, which is what lets
1846+
* {@link uninstallPackage} run this verb ahead of its own mutations.
1847+
*
18421848
* @throws Error if trying to uninstall an owner that has extenders
18431849
*/
18441850
unregisterObjectsByPackage(packageId: string, force: boolean = false): void {
1851+
// [#7970] REFUSAL PASS — the whole decision, taken before a single
1852+
// contribution is removed. This check used to live inline in the mutation
1853+
// walk below, one object at a time, so a package owning `account` (free)
1854+
// and `contact` (extended by another package) lost `account` on the way to
1855+
// refusing over `contact`: the guard that exists to keep a registry whole
1856+
// was itself reached through a mutation, and nothing rolled it back. Same
1857+
// predicate and same iteration order as the inline check it replaces, so
1858+
// the same object still refuses with the same message — what changed is
1859+
// only that no removal precedes the throw.
1860+
if (!force) {
1861+
for (const [fqn, contributors] of this.objectContributors.entries()) {
1862+
const ownedHere = contributors.some(
1863+
c => c.packageId === packageId && c.ownership === 'own'
1864+
);
1865+
if (!ownedHere) continue;
1866+
// Extenders from other packages
1867+
const otherExtenders = contributors.filter(
1868+
c => c.packageId !== packageId && c.ownership === 'extend'
1869+
);
1870+
if (otherExtenders.length > 0) {
1871+
throw new Error(
1872+
`Cannot uninstall package "${packageId}": object "${fqn}" is extended by ` +
1873+
`${otherExtenders.map(c => c.packageId).join(', ')}. Uninstall extenders first.`
1874+
);
1875+
}
1876+
}
1877+
}
1878+
1879+
// MUTATION PASS — carries no refusal of its own; the pass above already
1880+
// proved every removal below is allowed. Keep it that way: a second copy of
1881+
// the predicate here is the two-places-that-must-agree shape this ordering
1882+
// fix was chosen over.
18451883
for (const [fqn, contributors] of this.objectContributors.entries()) {
18461884
// Find this package's contributions
18471885
const packageContribs = contributors.filter(c => c.packageId === packageId);
1848-
1849-
for (const contrib of packageContribs) {
1850-
if (contrib.ownership === 'own' && !force) {
1851-
// Check if there are extenders from other packages
1852-
const otherExtenders = contributors.filter(
1853-
c => c.packageId !== packageId && c.ownership === 'extend'
1854-
);
1855-
if (otherExtenders.length > 0) {
1856-
throw new Error(
1857-
`Cannot uninstall package "${packageId}": object "${fqn}" is extended by ` +
1858-
`${otherExtenders.map(c => c.packageId).join(', ')}. Uninstall extenders first.`
1859-
);
1860-
}
1861-
}
18621886

1887+
for (const contrib of packageContribs) {
18631888
// Remove contribution
18641889
const idx = contributors.indexOf(contrib);
18651890
if (idx !== -1) {
@@ -2704,14 +2729,23 @@ export class SchemaRegistry {
27042729
return false;
27052730
}
27062731

2732+
// [#7970] Unregister objects FIRST — this is the one step that can REFUSE
2733+
// (ADR-0029: the package owns an object another package `extend`s), so
2734+
// every mutation below is downstream of the refusal point and a refused
2735+
// uninstall removes nothing at all. The namespace release used to sit
2736+
// ABOVE this line, which meant reaching the guard that exists to keep a
2737+
// registry whole cost the namespace on the way in: the package stayed
2738+
// installed with its objects and its record intact, while its namespace no
2739+
// longer resolved, for the life of the process. Safe as the first step
2740+
// because the verb reads `objectContributors` only — it depends on nothing
2741+
// the steps below establish.
2742+
this.unregisterObjectsByPackage(id);
2743+
27072744
// Unregister namespace
27082745
if (pkg.manifest.namespace) {
27092746
this.unregisterNamespace(pkg.manifest.namespace, id);
27102747
}
27112748

2712-
// Unregister objects (will throw if extenders exist)
2713-
this.unregisterObjectsByPackage(id);
2714-
27152749
// [#7221] …and everything else the package shipped. The object verb above
27162750
// reaches `objectContributors` only, so without this an uninstall dropped
27172751
// the package record while its `page`/`view`/`flow`/`app`/`api` entries

0 commit comments

Comments
 (0)