Skip to content

Commit 1fd5664

Browse files
fix(metadata-protocol): a refused sys_packages delete fails the uninstall before anything is removed (#21438)
Fixes #21276 Clause-②: no When `DELETE /api/v1/packages/:id` is refused — by the store, or by the registry because another package extends an object this one owns — it now answers that refusal and changes nothing. The running process keeps serving the package, a disabled package stays disabled across a restart, and its metadata, tables and grants stay in place. Three changes, in three files: - **`metadata-protocol`.** `deletePackage` asks the registry's uninstall refusal first, then deletes the stored row as its first durable step. A refusal from either throws before anything else is removed. - **`objectql`.** The registry's refusal check becomes one public method that changes nothing, so it can be asked before that first durable step. - **Door (`runtime`).** The dispatcher asks `deletePackage` before it withdraws the package from the running registry or clears its disable record. **Cross-lane surfaces, named before the change list.** - `packages/metadata-protocol/src/protocol.ts` (`domain:engine`): `deletePackage`. Beside it, the two #21243 helpers it reuses (`packagePersistFailureError` and `packagePersistFailureMessage`) take one more verb value, `'delete'`, with one new sentence. - `packages/objectql/src/registry.ts` (`domain:engine`, added by claim revision 2 on #21276): the refusal pass of `unregisterObjectsByPackage`, extracted into `assertPackageUninstallable`. - `packages/runtime/src/domains/packages.ts` (`domain:cli`, claim revision 1): the `DELETE` arm only, plus the header note of its organization-scope refusal. - Pins and fixtures: - `packages/objectql/src/registry-assert-package-uninstallable.test.ts` (new); - `packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts` (new); - `packages/runtime/src/package-uninstall-store-refusal.integration.test.ts` (new); - the registry double of `packages/runtime/src/domains/packages-uninstall-envelope.test.ts`. - `service-package` is unchanged: its `delete` already states a refusal on both channels (`PackageDeleteResult`). No `packages/spec` edit. `assertPackageUninstallable` is not added to the spec's engine contract interface; `deletePackage` reaches it through a capability probe. ## The door, measured live `pnpm dev:crm` was booted twice over one SQLite file (a private `OS_HOME`, no `--fresh`) and driven as the seeded platform admin. A trigger refuses `DELETE` on `sys_packages` for the forced package only: `CREATE TRIGGER refuse_forced_delete BEFORE DELETE ON sys_packages WHEN OLD.id = 'com.example.forced' BEGIN SELECT RAISE(ABORT, '…'); END`. The control package has no trigger. | step | `main` `22c2d6f4d`, forced | protocol half only (`6d823ae7a`), forced | with the door half (`608838a73`), forced | control, every tree | |:--|:--|:--|:--|:--| | `DELETE /api/v1/packages/:id` | `200`, `success: true` | `500 DATABASE_ERROR` | **`500 DATABASE_ERROR`** | `200` | | `GET`, same process | `404` | `404` | **`200`** | `404` | | `GET` after a restart | `200`: comes back | `200` | `200` | `404` | | the package disabled first, then the forced delete; `GET` after a restart | not measured | `enabled: true`, `status: installed` | **`enabled: false`, `status: disabled`** | — | The disable leg has a control leg on `6d823ae7a`: the package was disabled and the server restarted with no `DELETE`, and it came back `disabled`. This round's change (the registry question) is pinned at the door by the real-composition pins below rather than re-measured live, because a live extender needs an installed app whose `objectExtensions` target a writable package's object. The store-refusal `500` message reads: "Package 'com.example.forced' was not uninstalled: the package registry could not delete its stored record, and a package whose record is kept comes back on the next restart, so its metadata, data and grants were left in place. The reason is in the server log." The driver's `SQLITE_CONSTRAINT_TRIGGER` line stays in the server log and on `cause`. ## The steps of `deletePackage`, in their new order | # | step | durable? | can it refuse? | on refusal | |:--|:--|:--|:--|:--| | 1 | tenant-scope check | no | yes, `400 TENANT_SCOPE_REQUIRED` | thrown; nothing changed | | 2 | `sys_metadata` read | no | yes, `503` or a classified refusal | thrown; nothing changed | | 3 | **the registry's uninstall refusal**, asked through `SchemaRegistry.assertPackageUninstallable` | no | yes: another package extends an object this one owns (ADR-0029) | **thrown as is; nothing changed** | | 4 | **`sys_packages` delete** (on `main`: after step 5) | yes, the first | yes: returned `{ success: false }`, or thrown | **thrown through `packagePersistFailureError`; nothing changed** (on `main`: `console.warn`, then success) | | 5 | per-item `sys_metadata` deletes and table teardown | yes | yes: authorization, lock, store fault | collected in `failed[]`; the door answers `400 PACKAGE_DELETE_PARTIAL` | | 6 | registry withdrawal (`SchemaRegistry.uninstallPackage`, which also releases the namespace) | process state | its refusal was asked at step 3; anything else it throws is a safety-net case | `console.warn`; the package leaves at the next restart | | 7 | uninstall cleanups | yes | yes | reported in `cleanups[]` | The store error shape is #21243's, unchanged. A declared 4xx leaves as the producer answered it. Anything else is a `500` that quotes nothing, with the original on `cause` and a catalogued code carried (`DATABASE_ERROR` from a live SQL driver; `INTERNAL_ERROR` derived for a returned `{ success: false }`). No code is minted. There is no undo machinery, because nothing durable precedes step 4. Step 3 reaches the registry the way step 6 already did, through `this.engine.registry`. A registry without `assertPackageUninstallable` (an engine double, or a host on a registry without it) is not asked. `deletePackage` then behaves as it did before the method existed: the refusal surfaces at step 6. **Steps that can still refuse after the store delete** (triage's ruling asks for them to be moved ahead or reported here): - **Step 5.** An authorization or lock refusal could in principle be decided first, but only by a dry run of `deleteMetaItem`'s own checks, which is another `protocol.ts` region. A store fault cannot be decided ahead. This is unchanged from `main`, where the store delete ran after these steps whatever they answered. The door answers `400 PACKAGE_DELETE_PARTIAL` and lists what was left. - **Step 7.** A cleanup is a data-plane delete, so deciding it means doing it. This is unchanged: each refusal is reported in `cleanups[]`. ## `SchemaRegistry.assertPackageUninstallable` This is the refusal pass of `unregisterObjectsByPackage` (#7970: "refuses before it mutates"), moved into one public method. It has the same predicate, the same iteration order and the same message, and it mutates nothing. `unregisterObjectsByPackage` calls it (`if (!force) this.assertPackageUninstallable(packageId)`), so there is still one copy of the check. `force` stays the caller's decision: forcing means not asking. **Why the name:** this class already names its non-mutating throw-or-pass check `assertSingleOwnerPerObject`, and the repo's other `assert…` verbs (`assertProtocolCompat`, `assertLockAllowsDelete`) also check and throw without changing state. "Uninstallable" names the question at the level both of its callers ask it. ## Door half `DELETE /api/v1/packages/:id` used to run `registry.uninstallPackage(id)` and `setPackageDisabled(environmentId, id, false)` before it called `protocol.deletePackage`. It now runs in this order: 1. the unchanged refusals: `requireManageMetadata`, the ADR-0070 read-only gate, and the organization-scope mirror; 2. an existence **read**: `registry.getPackage(id)`; 3. `deletePackage`. Its throw — the store's refusal or the registry's ADR-0029 refusal — is answered through the existing `errorFromThrown(e, 500)`, and nothing has been touched at that point. For the extender refusal this is the envelope the door gave before this PR, when its own up-front `uninstallPackage` raised it: `500 INTERNAL_ERROR`, nothing changed; 4. only then the withdrawal from the running registry (skipped when `deletePackage` already withdrew it), and the clear of the disable record for a package this request found. The late withdrawal keeps its `try`/`catch` as a safety net, for a registry without the new method or a throw nothing asked ahead of time. Since the stored rows are gone by then, that case is reported as `registryRemoved: false`, not as a failure. The extender refusal no longer arrives there. The refusal envelopes are unchanged: `403`, `422 WRITABLE_PACKAGE_REQUIRED`, `400 TENANT_SCOPE_REQUIRED`, the thrown `deletePackage` failure, `400 PACKAGE_DELETE_PARTIAL` and the `404`. The `404` asks whether the package existed rather than whether it was withdrawn. A host with no persisted half keeps its old behaviour: the withdrawal is the uninstall, and its refusal is the request's refusal. ## Tests (code at `01dae724d`; the head `fc6b6515a` differs from it only in the changeset) **Registry pin** (`registry-assert-package-uninstallable.test.ts`, 3 tests): - the method refuses with the uninstall's exact sentence, and every contributor of every object is unchanged; - it answers normally for a package nothing extends, and for an unknown id, and changes nothing; - `unregisterObjectsByPackage` refuses with the same sentence by calling the method (a spy sees the call), and `force: true` does not ask. **Protocol pins** (`protocol.package-delete-refusal.test.ts`, 7 tests): - a store refusal on either channel gives `500` with nothing removed; - a declared `409` passes through unchanged; - **new:** the registry's ADR-0029 refusal is thrown as is, with not even the store delete run, and the stored row survives a restart; - after a store refusal, a restart still has the package, its metadata and its grants; - CONTROL: an ordinary uninstall runs in the order store delete, metadata, registry, cleanup. **Door pins** (`package-uninstall-store-refusal.integration.test.ts`, 4 tests). They run on a real composition booted twice over one SQLite file: real `ObjectQL` and `SqlDriver`; the real `PackageServicePlugin` hydration; the real protocol and `HttpDispatcher`; and `AppPlugin`'s disable seed. - a store refusal answers `500 DATABASE_ERROR`, and the same process still serves the package, disabled, with its view row; - after a restart the package is still installed, still disabled, and still has its view row; - CONTROL: an ordinary delete answers `200`, then `404` in the same process and `404` after a restart. Its rows are gone and its disable record is cleared; - **flipped:** with an ADR-0029 extender, the delete answers `500 INTERNAL_ERROR`. The stored row, the view row, the registry entry (disabled) and the disable record are all intact, in the same process and after a restart. **Fixture change.** In `packages-uninstall-envelope.test.ts`, the registry double reads one `registered` flag from both `getPackage` and `uninstallPackage`, because the door now reads existence with `getPackage`. The case still asserts the same `404`. Full suites at `fc6b6515a`: | package | command | files | tests | |:--|:--|:--|:--| | `objectql` | `vitest run --project local`, 2 shards | 183 + 183 passed | 3602 + 3780 passed | | `metadata-protocol` | `vitest run`, 2 shards | 103 passed; 100 passed, 3 skipped | 1372 passed; 1669 passed, 19 skipped | | `runtime` | `vitest run --project local`, 2 shards | 155 + 154 passed | 2048 passed, 4 skipped; 2314 passed, 15 skipped | | `rest` | `vitest run --project local`, 2 shards | 128 + 128 passed | 2581 passed, 87 skipped; 2267 passed, 235 skipped | The skipped files are the opt-in live-database files. `typecheck` passes for `objectql`, `metadata-protocol` and `runtime` (each with its `check:test-typecheck` layer where it has one). ## Ablations (every mutation through `scripts/ablation-replace.mjs`; each anchor hit once; each restore proven with the blob equal to `HEAD` and `git diff HEAD` empty; each prediction written before the run) | ablation | tree | predicted | observed | |:--|:--|:--|:--| | A1, protocol: the returned-result check removed | `6d823ae7a` | returned-channel pins | 2 of 6 red | | A2, protocol: the `catch` that logs a warning and goes on restored | `6d823ae7a` | thrown-channel pins | 3 of 6 red | | A3, protocol: the store delete moved back after the metadata deletes | `6d823ae7a` | every refusal and restart pin | 6 of 6 red | | D1, door: withdraw before `deletePackage` | `608838a73` | same-process pin | 2 of 4 red | | D2, door: clear the disable record before `deletePackage` | `608838a73` | restart-disabled pin | 1 of 4 red | | **P1**, protocol: `deletePackage` no longer asks `assertPackageUninstallable` | `01dae724d` | protocol and door extender pins | protocol 1 of 7 red; door 1 of 4 red | | **P2**, registry: `unregisterObjectsByPackage` no longer calls the method | `01dae724d` | my registry pin, plus at least 4 #7970 cases | 6 of 104 red: my pin and 5 refusal cases in `registry.test.ts` | | **P2b**, registry: the call replaced by an inline copy of the predicate, so behaviour is identical | `01dae724d` | the spy assertion only | 1 of 104 red: my pin only | The protocol and registry pins import their subjects from `src`. The door pins read `metadata-protocol` through `dist`, so P1's door leg rebuilt it: - `scripts/ablation-dist-preflight.mjs` found the marker in 2 built files; - the door pins went 1 of 4 red; - after the restore and a rebuild, the marker was absent from all 24 built files, and the door pins were 4 of 4 green again. Void attempts, declared: - The first P1 run used a comment as its marker. The build strips comments, so the dist preflight reported the marker absent, and that door reading is void. It was re-run with a marker that survives the build: a comparison against a string constant. - The first A1 attempt, and the first two attempts of A3's call leg, were refused by the tool before any test ran, because each replacement contained its own anchor. They were re-run. P2b is what makes "`unregisterObjectsByPackage` not calling the method" distinguishable: an inline copy behaves exactly like the call, so only the spy can tell them apart. ## Gates (head `fc6b6515a`) `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 69 commands. Compared with the previous round's 65, this round's new paths add `check:engine-split-ratio` and `check:rest-log-declared`, each with its self-test. All 69 ran on that head and exited 0. Reconciling with `--ran` gives "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded. `origin/main` was merged at `7d29e5c5d` and at `2db45821b`. The second merge brought `535d1d25a`, which edits other `protocol.ts` regions. The 3 commits on `main` after that merge touch none of this PR's files. ESLint was narrowed to the seven touched TypeScript files, which is a measured narrowing: - ESLint's own `--print-config` resolves every one of them; - `--format json` counts 7 files, with 0 errors and 0 warnings; - no resolved config carries `parserOptions.project` or `projectService`, so type-aware linting is off and no untouched file's verdict can move. The repo-wide `pnpm lint` is CI's. ## Changeset This PR's own file covers `@objectstack/metadata-protocol`, `@objectstack/runtime` and `@objectstack/objectql`, all at `patch`. All three are released packages in one `fixed` version group. The bump for the new `SchemaRegistry` method follows the repo's rules: - AGENTS.md Post-Task Checklist step 3: a bug fix in a released package takes a `patch`, and this method is the mechanism of this fix; - `check-changeset-no-major.mjs`'s level axis demands `minor` only for a `Clause-②: yes` (a new key on a published payload), and this PR declares `no`. ## Docs I grepped `content/docs/**` (outside `releases/` and `references/`) and `skills/**` for uninstall and package-delete semantics: `api/metadata-api.mdx`, `kernel/contracts/metadata-service.mdx`, `permissions/permission-sets.mdx`, `protocol/kernel/plugin-spec.mdx` and `deployment/publish-and-preview.mdx`. Each describes a successful uninstall, and that path is unchanged, so none of their sentences is now false. No doc edits. ## Acceptance notes - The ADR-0029 extender refusal is decided before the store delete again, through `SchemaRegistry.assertPackageUninstallable`. At the door it answers `500` with nothing changed, the envelope it had before this PR. - Observed and not filed: an ordinary uninstall of a package with no `sys_metadata` rows carries `persisted.success: false` inside the door's `200`, because `deletePackage` computes `success` as `failed.length === 0 && deleted.length > 0`. The door does not read that field. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 94a8761 commit 1fd5664

8 files changed

Lines changed: 944 additions & 83 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/objectql': patch
5+
---
6+
7+
fix: when the store refuses an uninstall's `sys_packages` delete, the uninstall now answers the failure and removes nothing else, instead of answering success and coming back after the next restart (#21276)
8+
9+
Clause-②: no
10+
11+
**`@objectstack/metadata-protocol`.** `deletePackage` now deletes the package's `sys_packages` row first, before its `sys_metadata` rows, its tables, its registry entry and the rows the uninstall cleanups own. When the `package` service refuses that delete, whether it returns `{ success: false }` or throws, `deletePackage` throws and nothing else is removed. A store fault answers `500`, with `DATABASE_ERROR` from a live SQL driver and `INTERNAL_ERROR` otherwise. A declared 4xx refusal is passed through unchanged. Before this, the refusal was logged as a warning, and `DELETE /api/v1/packages/:id` answered `200` after the package's metadata, tables and grants had been removed. The package then came back after the next restart.
12+
13+
Before that store delete, `deletePackage` now also asks the registry whether the uninstall would be refused because another package extends an object this package owns (ADR-0029). If so, it throws the registry's own refusal with nothing removed. A registry without the new question is not asked, and the refusal then surfaces at the registry withdrawal, as before.
14+
15+
**`@objectstack/objectql`.** New: `SchemaRegistry.assertPackageUninstallable(packageId)`. It throws the refusal `unregisterObjectsByPackage` and `uninstallPackage` raise for an object another package extends, with the same message, and it changes nothing. `unregisterObjectsByPackage` now calls it, so there is still one copy of that check.
16+
17+
**`@objectstack/runtime`.** `DELETE /api/v1/packages/:id` now asks `deletePackage` before it touches anything. It checks that the package exists with a read, and it withdraws the package from the running registry and clears its saved disable record only after `deletePackage` has answered. So when the store refuses, the door answers `500`, the same process keeps serving the package, and a package that was disabled stays disabled after a restart. Before this, the door withdrew the package and cleared its disable record first. A refused delete then left the package missing until a restart, and brought a disabled package back enabled.
18+
19+
An uninstall refused because another package extends an object this package owns still answers `500` with nothing changed: the stored rows, the registry entry and the disable record all stay as they were, in the same process and after a restart. That refusal is now decided before the store delete, instead of by the door withdrawing the package first. An ordinary uninstall, and a host with no `package` service, are unchanged.
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21276 — an uninstall the store refused is never answered as success.
5+
*
6+
* ## The defect
7+
*
8+
* `deletePackage` deleted the package's `sys_metadata` rows (and tore down its
9+
* tables), then deleted its `sys_packages` row through the `package` service,
10+
* then withdrew it from the registry and ran the uninstall cleanups. The
11+
* `sys_packages` delete sat inside a `catch` that logged a thrown failure with
12+
* `console.warn`, and a RETURNED `{ success: false }` was never read at all.
13+
* So when the store refused that one delete, the uninstall answered success,
14+
* and `PackageServicePlugin.start()` hydrated the surviving row back into the
15+
* registry on the next boot. Measured at `DELETE /api/v1/packages/:id` on
16+
* SQLite with a trigger refusing the delete: 200, then 404 in the same
17+
* process, then 200 after a restart.
18+
*
19+
* ## The contract pinned here (triage's ruling: refuse before withdrawing)
20+
*
21+
* 1. The `sys_packages` delete is the FIRST durable step, and its refusal is
22+
* the uninstall's refusal — a thrown error with the status and code the
23+
* dispatcher door reads (`resolveThrownHttpError`, the one rule every
24+
* door applies), never a success body.
25+
* 2. Nothing after it ran: the registry still holds the package, and its
26+
* `sys_metadata` rows and the rows an uninstall cleanup owns are all
27+
* still there. ⛔ No undo — there is nothing to undo.
28+
* 3. After a restart — a fresh process over the same store — the package is
29+
* in the state the refusal reported: installed, with its metadata.
30+
* 4. CONTROL: an ordinary uninstall still removes everything, store row
31+
* first, and a restart does not bring it back.
32+
*
33+
* Both failure channels of the service's `delete` are driven
34+
* (`PackageDeleteResult`, `packages/services/service-package/src/index.ts`):
35+
* RETURNED `{ success: false }` (an undeclared driver fault the service
36+
* swallowed) and THROWN (a failure that declared its own answer — what a live
37+
* SQL driver's refused raw statement is, `500 DATABASE_ERROR`).
38+
*
39+
* ## The doubles
40+
*
41+
* `World` is the database: `sysPackages` stands for `sys_packages`,
42+
* `sysMetadata` for the package's `sys_metadata` rows, and `grants` for the
43+
* data-plane rows an uninstall cleanup removes (plugin-security's package
44+
* permission sets). `boot(world)` is one process over it: the registry is
45+
* hydrated from `sysPackages` the way `PackageServicePlugin.start()` does it,
46+
* so a second `boot` over the same world IS a restart. The registry double
47+
* mirrors the real `SchemaRegistry` verbs by name (`installPackage`,
48+
* `getPackage`, `uninstallPackage`, `packages/objectql/src/registry.ts`), and
49+
* the per-item `deleteMetaItem` is replaced by a double that removes the row
50+
* from `sysMetadata` — the same seam `protocol-package-lifecycle.test.ts`
51+
* stubs, since the per-item teardown has its own pins.
52+
*/
53+
54+
import { describe, it, expect, vi } from 'vitest';
55+
import { resolveThrownHttpError } from '@objectstack/types';
56+
import { ObjectStackProtocolImplementation } from './index.js';
57+
58+
const PKG = 'com.example.leave';
59+
60+
interface MetadataRow {
61+
type: string;
62+
name: string;
63+
state: string;
64+
package_id: string;
65+
organization_id: string | null;
66+
}
67+
68+
interface World {
69+
sysPackages: Map<string, Record<string, unknown>>;
70+
sysMetadata: MetadataRow[];
71+
grants: Set<string>;
72+
/** Every step that changes the world or the registry, in the order it ran. */
73+
steps: string[];
74+
}
75+
76+
function makeWorld(): World {
77+
return {
78+
sysPackages: new Map([[PKG, { id: PKG, name: 'Leave', version: '1.0.0', namespace: 'leave' }]]),
79+
sysMetadata: [
80+
{ type: 'object', name: 'leave_request', state: 'active', package_id: PKG, organization_id: null },
81+
{ type: 'view', name: 'leave_request_list', state: 'draft', package_id: PKG, organization_id: null },
82+
],
83+
grants: new Set([`${PKG}:leave_manager`]),
84+
steps: [],
85+
};
86+
}
87+
88+
type StoreDelete = (world: World, id: string) => Promise<unknown>;
89+
90+
/** The delete lands: the row leaves `sys_packages`. */
91+
const landed: StoreDelete = async (world, id) => {
92+
world.sysPackages.delete(id);
93+
return { success: true };
94+
};
95+
96+
/** The RETURNED channel: the DELETE broke and declared nothing (`PackageDeleteResult`). */
97+
const returnedRefusal: StoreDelete = async () => ({ success: false });
98+
99+
/** The THROWN channel, shaped as a live SQL driver's refused raw statement (`rawStatementFaultError`). */
100+
const DRIVER_LINE = "DELETE FROM sys_packages WHERE id = 'com.example.leave' - refused by trigger";
101+
const thrownDatabaseError: StoreDelete = async () => {
102+
throw Object.assign(new Error('The database refused to run a raw statement.'), {
103+
code: 'DATABASE_ERROR',
104+
status: 500,
105+
cause: new Error(DRIVER_LINE),
106+
});
107+
};
108+
109+
/** One process over `world`. A second call over the same world is a restart. */
110+
function boot(world: World, storeDelete: StoreDelete = landed, opts: { uninstallRefusal?: Error } = {}) {
111+
const rows = new Map<string, { manifest: Record<string, unknown>; status: string; enabled: boolean }>();
112+
const registry = {
113+
installPackage(manifest: Record<string, unknown>) {
114+
const row = { manifest: { ...manifest }, status: 'installed', enabled: true };
115+
rows.set(String(manifest.id), row);
116+
return row;
117+
},
118+
getPackage: (id: string) => rows.get(id),
119+
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+
},
126+
uninstallPackage(id: string) {
127+
world.steps.push('registry.uninstallPackage');
128+
return rows.delete(id);
129+
},
130+
};
131+
// The boot hydration: every stored row is registered.
132+
for (const manifest of world.sysPackages.values()) registry.installPackage(manifest);
133+
134+
const engine = {
135+
registry,
136+
// Scalar equality on every `where` key, and the caller's `limit` by
137+
// presence after the filter. A combinator (`$or`, the org-scoped
138+
// uninstall's) is not implemented here, so it is refused, never answered
139+
// as "no rows" — these pins uninstall with `allTenants: true`.
140+
find: async (object: string, query?: { where?: Record<string, unknown>; limit?: number }) => {
141+
if (object !== 'sys_metadata') return [];
142+
const where = query?.where ?? {};
143+
for (const key of Object.keys(where)) {
144+
if (key.startsWith('$')) throw new Error(`find double: '${key}' is not implemented`);
145+
}
146+
const matched = world.sysMetadata.filter((row) =>
147+
Object.entries(where).every(([key, value]) => (row as unknown as Record<string, unknown>)[key] === value));
148+
const page = typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched;
149+
return page.map((row) => ({ ...row }));
150+
},
151+
};
152+
const services = new Map<string, unknown>([
153+
['package', {
154+
publish: async () => ({ success: true }),
155+
delete: async (id: string) => {
156+
world.steps.push('sys_packages.delete');
157+
return storeDelete(world, id);
158+
},
159+
}],
160+
]);
161+
const impl = new ObjectStackProtocolImplementation(engine as never, () => services as never) as any;
162+
vi.spyOn(impl, 'deleteMetaItem').mockImplementation(async (req: any) => {
163+
world.steps.push(`sys_metadata.delete ${req.type}/${req.name}/${req.state}`);
164+
world.sysMetadata = world.sysMetadata.filter(
165+
(r) => !(r.type === req.type && r.name === req.name && r.state === req.state),
166+
);
167+
return { success: true };
168+
});
169+
impl.registerUninstallCleanup('security.package-permissions', async ({ packageId }: { packageId: string }) => {
170+
world.steps.push('cleanup security.package-permissions');
171+
let removed = 0;
172+
for (const grant of [...world.grants]) {
173+
if (grant.startsWith(`${packageId}:`)) {
174+
world.grants.delete(grant);
175+
removed++;
176+
}
177+
}
178+
return { success: true, removed };
179+
});
180+
return { impl, registry };
181+
}
182+
183+
/** What the dispatcher door answers for a thrown value (`errorFromThrown` → `resolveThrownHttpError`). */
184+
const door = (e: unknown) => {
185+
const r = resolveThrownHttpError(e, 500);
186+
return { status: r.status, code: r.code, message: r.message };
187+
};
188+
189+
async function rejectionOf(p: Promise<unknown>): Promise<unknown> {
190+
try {
191+
await p;
192+
} catch (e) {
193+
return e;
194+
}
195+
throw new Error('expected the call to reject, and it resolved');
196+
}
197+
198+
const snapshot = (world: World) => ({
199+
sysPackages: [...world.sysPackages.keys()],
200+
sysMetadata: world.sysMetadata.map((r) => `${r.type}/${r.name}/${r.state}`),
201+
grants: [...world.grants],
202+
});
203+
204+
const REFUSALS = [
205+
['returned { success: false }', 'INTERNAL_ERROR', returnedRefusal],
206+
['thrown DATABASE_ERROR', 'DATABASE_ERROR', thrownDatabaseError],
207+
] as const;
208+
209+
describe('#21276 deletePackage — a refused sys_packages delete fails the uninstall and removes nothing', () => {
210+
it.each(REFUSALS)('%s → 500 %s; the registry, the metadata rows and the grants are untouched', async (_label, code, refusal) => {
211+
const world = makeWorld();
212+
const before = snapshot(world);
213+
const { impl, registry } = boot(world, refusal);
214+
215+
const err = await rejectionOf(impl.deletePackage({ packageId: PKG, allTenants: true }));
216+
217+
expect(door(err)).toMatchObject({ status: 500, code });
218+
// The driver's words stay on `cause` for the operator, never in the caller's sentence.
219+
expect(door(err).message).not.toContain(DRIVER_LINE);
220+
expect((err as { cause?: unknown }).cause).toBeDefined();
221+
// The store delete was the first and only step: nothing after it ran.
222+
expect(world.steps).toEqual(['sys_packages.delete']);
223+
expect(snapshot(world)).toEqual(before);
224+
expect(registry.getPackage(PKG)).toBeDefined();
225+
});
226+
227+
it('a declared 4xx refusal from the store leaves as the producer answered it, and nothing is removed', async () => {
228+
const refusal = Object.assign(new Error('Refused by the platform.'), { code: 'DESTRUCTIVE_CHANGE', status: 409 });
229+
const world = makeWorld();
230+
const before = snapshot(world);
231+
const { impl, registry } = boot(world, async () => {
232+
throw refusal;
233+
});
234+
235+
const err = await rejectionOf(impl.deletePackage({ packageId: PKG, allTenants: true }));
236+
237+
expect(err).toBe(refusal);
238+
expect(door(err)).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' });
239+
expect(world.steps).toEqual(['sys_packages.delete']);
240+
expect(snapshot(world)).toEqual(before);
241+
expect(registry.getPackage(PKG)).toBeDefined();
242+
});
243+
});
244+
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+
267+
describe('#21276 the restart — a fresh process over the same store holds the package as the uninstall reported it', () => {
268+
it.each(REFUSALS)('after a %s refusal, the package comes back WITH its metadata and grants', async (_label, _code, refusal) => {
269+
const world = makeWorld();
270+
await rejectionOf(boot(world, refusal).impl.deletePackage({ packageId: PKG, allTenants: true }));
271+
272+
const restarted = boot(world);
273+
274+
// Reported: not uninstalled. So, after a restart: installed, and whole.
275+
expect(restarted.registry.getPackage(PKG)).toBeDefined();
276+
expect(snapshot(world)).toEqual({
277+
sysPackages: [PKG],
278+
sysMetadata: ['object/leave_request/active', 'view/leave_request_list/draft'],
279+
grants: [`${PKG}:leave_manager`],
280+
});
281+
});
282+
283+
it('CONTROL — an ordinary uninstall removes the store row first, then the rest, and a restart does not bring it back', async () => {
284+
const world = makeWorld();
285+
const first = boot(world);
286+
287+
const res = await first.impl.deletePackage({ packageId: PKG, allTenants: true });
288+
289+
expect(res).toMatchObject({ success: true, deletedCount: 2, failedCount: 0 });
290+
expect(res.cleanups).toEqual([{ name: 'security.package-permissions', success: true, removed: 1 }]);
291+
expect(world.steps).toEqual([
292+
'sys_packages.delete',
293+
'sys_metadata.delete view/leave_request_list/draft',
294+
'sys_metadata.delete object/leave_request/active',
295+
'registry.uninstallPackage',
296+
'cleanup security.package-permissions',
297+
]);
298+
expect(first.registry.getPackage(PKG)).toBeUndefined();
299+
300+
const restarted = boot(world);
301+
302+
expect(restarted.registry.getPackage(PKG)).toBeUndefined();
303+
expect(snapshot(world)).toEqual({ sysPackages: [], sysMetadata: [], grants: [] });
304+
});
305+
});

0 commit comments

Comments
 (0)