Skip to content

Commit 608838a

Browse files
committed
test(runtime): pin the delete door against a refused sys_packages delete, across a restart
A real SQLite composition (ObjectQL, SqlDriver, PackageServicePlugin, the metadata protocol and the dispatcher) booted twice over one file. The #7557 envelope double now models an unregistered package through getPackage, the read the door now makes. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e86cf0d commit 608838a

2 files changed

Lines changed: 286 additions & 2 deletions

File tree

‎packages/runtime/src/domains/packages-uninstall-envelope.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,20 @@ const authed = (caps: string[] = ['manage_metadata']): any => ({
7676
});
7777

7878
function make(deletePackageResult: any, opts: { registryRemoved?: boolean } = {}) {
79+
// [#21276] The door reads existence with `getPackage` before it asks
80+
// `deletePackage`, and withdraws only afterwards, so both verbs read one
81+
// `registered` flag: `registryRemoved: false` is a package the registry
82+
// does not hold, exactly as `SchemaRegistry` would answer it.
83+
let registered = opts.registryRemoved ?? true;
84+
const pkg = { id: 'com.example.pkg-a', manifest: { id: 'com.example.pkg-a', name: 'A' } };
7985
const registry = {
8086
getAllPackages: vi.fn().mockReturnValue([]),
81-
getPackage: vi.fn().mockReturnValue({ id: 'com.example.pkg-a', manifest: { id: 'com.example.pkg-a', name: 'A' } }),
82-
uninstallPackage: vi.fn().mockReturnValue(opts.registryRemoved ?? true),
87+
getPackage: vi.fn(() => (registered ? pkg : undefined)),
88+
uninstallPackage: vi.fn(() => {
89+
const removed = registered;
90+
registered = false;
91+
return removed;
92+
}),
8393
};
8494
const protocol = {
8595
deletePackage: vi.fn().mockResolvedValue(deletePackageResult),
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21276 — `DELETE /api/v1/packages/:id` when the store refuses the
5+
* `sys_packages` delete.
6+
*
7+
* ## The defect, as measured at this door
8+
*
9+
* On SQLite, with a trigger refusing `DELETE` on `sys_packages`, the door
10+
* answered `200` with `success: true`, the same process then answered `404`, and
11+
* after a restart the package was back. With `deletePackage` refusing first,
12+
* the door answered `500` but the same process still answered `404`. A
13+
* package disabled beforehand also came back ENABLED after the restart. The
14+
* door had already run `registry.uninstallPackage(id)` and
15+
* `setPackageDisabled(environmentId, id, false)` before it asked the store.
16+
*
17+
* ## The contract pinned here (triage's ruling: refuse before withdrawing)
18+
*
19+
* 1. A refused store delete answers the failure, and the same process still
20+
* serves the package, still disabled, with its metadata.
21+
* 2. After a restart, the package is still there and still disabled.
22+
* 3. CONTROL: an ordinary delete removes it — `404` in the same process, `404`
23+
* 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.
29+
*
30+
* ## The composition — the shipped pieces, booted twice over one database file
31+
*
32+
* A REAL `ObjectQL` over a REAL `SqlDriver` (better-sqlite3, on disk); the REAL
33+
* `PackageServicePlugin`, whose `start()` creates `sys_packages`, registers the
34+
* `package` service and hydrates the stored rows into the registry; the REAL
35+
* `ObjectStackProtocolImplementation`; and the REAL `HttpDispatcher`. Before
36+
* the plugin starts, each boot plants the persisted disabled ids the way
37+
* `AppPlugin.seedPersistedDisabledPackages` does
38+
* (`loadDisabledPackageIds` → `setInitialDisabledPackageIds`). A second `boot`
39+
* over the same directory, after the first engine is destroyed, IS the
40+
* restart. `OS_HOME` is a temp directory, so the disable record is a real file
41+
* that only this file writes.
42+
*/
43+
44+
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest';
45+
import { mkdtempSync, rmSync } from 'node:fs';
46+
import { tmpdir } from 'node:os';
47+
import { join } from 'node:path';
48+
import { ObjectQL } from '@objectstack/objectql';
49+
import { SqlDriver } from '@objectstack/driver-sql';
50+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
51+
import {
52+
SysMetadataObject,
53+
SysMetadataHistoryObject,
54+
SysMetadataAuditObject,
55+
} from '@objectstack/metadata-core';
56+
import { PackageServicePlugin } from '@objectstack/service-package';
57+
import { HttpDispatcher } from './http-dispatcher.js';
58+
import { loadDisabledPackageIds } from './package-state-store.js';
59+
60+
const PKG = 'com.acme.leave';
61+
const OTHER_PKG = 'com.acme.addon';
62+
const PLATFORM_PKG = 'com.objectstack.platform';
63+
const ENV = 'platform';
64+
const ORG = 'org_acme';
65+
66+
/** A signed-in admin acting in an organization — the caller the dev-server measurement used. */
67+
const CALLER: any = {
68+
request: {},
69+
environmentId: ENV,
70+
executionContext: {
71+
userId: 'u_admin',
72+
isSystem: false,
73+
systemPermissions: ['manage_metadata', 'studio.access'],
74+
tenantId: ORG,
75+
},
76+
};
77+
78+
const quiet = { debug() {}, info() {}, warn() {}, error() {} };
79+
80+
let cleanup: Array<() => void | Promise<void>> = [];
81+
afterEach(async () => {
82+
for (const c of cleanup.reverse()) await c();
83+
cleanup = [];
84+
});
85+
86+
const envSnapshot = { OS_HOME: process.env.OS_HOME };
87+
let home: string;
88+
beforeAll(() => {
89+
home = mkdtempSync(join(tmpdir(), 'os-21276-home-'));
90+
process.env.OS_HOME = home;
91+
});
92+
afterAll(() => {
93+
if (envSnapshot.OS_HOME === undefined) delete process.env.OS_HOME;
94+
else process.env.OS_HOME = envSnapshot.OS_HOME;
95+
rmSync(home, { recursive: true, force: true });
96+
});
97+
98+
let warnSpy: ReturnType<typeof vi.spyOn>;
99+
beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
100+
afterEach(() => { warnSpy.mockRestore(); });
101+
102+
/** One process over the database in `dir`. */
103+
async function boot(dir: string) {
104+
const driver = new SqlDriver({
105+
client: 'better-sqlite3',
106+
connection: { filename: join(dir, 'data.sqlite') },
107+
useNullAsDefault: true,
108+
});
109+
const objects = [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject] as any[];
110+
await driver.initObjects(objects);
111+
112+
const engine = new ObjectQL();
113+
engine.registerDriver(driver as any, true);
114+
await engine.init();
115+
for (const o of objects) engine.registry.registerObject(o, PLATFORM_PKG);
116+
// The boot seed, planted before any package row is installed.
117+
engine.registry.setInitialDisabledPackageIds(loadDisabledPackageIds(ENV));
118+
119+
const services = new Map<string, unknown>([['objectql', engine]]);
120+
const ctx: any = {
121+
logger: quiet,
122+
getService: (name: string) => services.get(name),
123+
registerService: (name: string, service: unknown) => { services.set(name, service); },
124+
};
125+
const plugin = new PackageServicePlugin();
126+
await plugin.init(ctx);
127+
await plugin.start(ctx);
128+
129+
const protocol = new ObjectStackProtocolImplementation(engine as any, () => services as any, undefined, 'package-author');
130+
services.set('protocol', protocol);
131+
const get = (name: string) => services.get(name) ?? null;
132+
const dispatcher = new HttpDispatcher({ context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) } as any);
133+
134+
let destroyed = false;
135+
const destroy = async () => {
136+
if (destroyed) return;
137+
destroyed = true;
138+
await engine.destroy();
139+
};
140+
cleanup.push(destroy);
141+
142+
return {
143+
engine,
144+
protocol,
145+
destroy,
146+
async call(method: string, path: string) {
147+
const res = await dispatcher.handlePackages(path, method, undefined, {}, CALLER);
148+
const body = JSON.parse(JSON.stringify(res.response?.body ?? null));
149+
return { status: res.response?.status ?? 0, code: body?.error?.code as string | undefined, body };
150+
},
151+
async metadataRows() {
152+
const rows = (await engine.find('sys_metadata', { where: { package_id: PKG } })) as any[];
153+
return rows.map((r) => `${r.type}/${r.name}`).sort();
154+
},
155+
};
156+
}
157+
158+
type Process = Awaited<ReturnType<typeof boot>>;
159+
160+
/** Install the package, give it one stored view, and disable it through the door. */
161+
async function seed(p: Process) {
162+
await p.protocol.installPackage({ manifest: { id: PKG, name: 'Leave', version: '1.0.0', type: 'app' } } as any);
163+
await (p.protocol as any).saveMetaItem({
164+
type: 'view',
165+
name: 'leave_list',
166+
item: {
167+
name: 'leave_list',
168+
label: 'Leave',
169+
type: 'grid',
170+
object: 'anything',
171+
viewKind: 'list',
172+
data: { provider: 'object', object: 'anything' },
173+
columns: ['id'],
174+
},
175+
packageId: PKG,
176+
});
177+
const disabled = await p.call('PATCH', `/${PKG}/disable`);
178+
expect(disabled.status, 'precondition: the package is disabled through the door').toBe(200);
179+
expect(loadDisabledPackageIds(ENV).has(PKG), 'precondition: the disable record is on disk').toBe(true);
180+
}
181+
182+
/** The forced store refusal: a trigger refusing `DELETE` on `sys_packages` for this package. */
183+
async function refuseStoreDelete(p: Process) {
184+
await (p.engine as any).execute({
185+
sql: `CREATE TRIGGER refuse_${PKG.replace(/\./g, '_')}_delete BEFORE DELETE ON sys_packages `
186+
+ `WHEN OLD.id = '${PKG}' BEGIN SELECT RAISE(ABORT, 'refused by the test trigger'); END`,
187+
});
188+
}
189+
190+
function newDir() {
191+
const dir = mkdtempSync(join(tmpdir(), 'os-21276-db-'));
192+
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
193+
return dir;
194+
}
195+
196+
describe('#21276 DELETE /packages/:id — a refused sys_packages delete changes nothing', () => {
197+
it('answers 500 DATABASE_ERROR, and the same process still serves the package, disabled, with its metadata', async () => {
198+
const p = await boot(newDir());
199+
await seed(p);
200+
await refuseStoreDelete(p);
201+
202+
const answer = await p.call('DELETE', `/${PKG}`);
203+
204+
expect({ status: answer.status, code: answer.code }).toEqual({ status: 500, code: 'DATABASE_ERROR' });
205+
const detail = await p.call('GET', `/${PKG}`);
206+
expect(detail.status).toBe(200);
207+
expect(detail.body?.data).toMatchObject({ enabled: false, status: 'disabled' });
208+
expect(await p.metadataRows()).toEqual(['view/leave_list']);
209+
});
210+
211+
it('after a restart the package is still installed, still DISABLED, and still has its metadata', async () => {
212+
const dir = newDir();
213+
const first = await boot(dir);
214+
await seed(first);
215+
await refuseStoreDelete(first);
216+
expect((await first.call('DELETE', `/${PKG}`)).status).toBe(500);
217+
await first.destroy();
218+
219+
const restarted = await boot(dir);
220+
221+
const detail = await restarted.call('GET', `/${PKG}`);
222+
expect(detail.status).toBe(200);
223+
expect(detail.body?.data).toMatchObject({ enabled: false, status: 'disabled' });
224+
expect(loadDisabledPackageIds(ENV).has(PKG)).toBe(true);
225+
expect(await restarted.metadataRows()).toEqual(['view/leave_list']);
226+
});
227+
});
228+
229+
describe('#21276 CONTROL — an ordinary delete removes the package, and a restart does not bring it back', () => {
230+
it('200, then 404 in the same process, 404 after a restart, its metadata gone and its disable record cleared', async () => {
231+
const dir = newDir();
232+
const first = await boot(dir);
233+
await seed(first);
234+
235+
const answer = await first.call('DELETE', `/${PKG}`);
236+
237+
expect(answer.status).toBe(200);
238+
expect(answer.body?.data).toMatchObject({ packageId: PKG, success: true, registryRemoved: true });
239+
expect((await first.call('GET', `/${PKG}`)).status).toBe(404);
240+
expect(await first.metadataRows()).toEqual([]);
241+
expect(loadDisabledPackageIds(ENV).has(PKG)).toBe(false);
242+
await first.destroy();
243+
244+
const restarted = await boot(dir);
245+
246+
expect((await restarted.call('GET', `/${PKG}`)).status).toBe(404);
247+
});
248+
});
249+
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+
const dir = newDir();
253+
const first = await boot(dir);
254+
await seed(first);
255+
// 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.
257+
first.engine.registry.registerObject({ name: 'leave_request', fields: { title: { type: 'text' } } } as any, PKG, 'leave', 'own');
258+
const fqn = first.engine.registry.getAllObjects(PKG).map((o: any) => o.name).find((n: string) => n.endsWith('leave_request'));
259+
first.engine.registry.registerObject({ name: fqn, fields: { note: { type: 'text' } } } as any, OTHER_PKG, undefined, 'extend');
260+
261+
const answer = await first.call('DELETE', `/${PKG}`);
262+
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);
268+
await first.destroy();
269+
270+
const restarted = await boot(dir);
271+
272+
expect((await restarted.call('GET', `/${PKG}`)).status).toBe(404);
273+
});
274+
});

0 commit comments

Comments
 (0)