From a3578973fc06568408c13e15c66f4ffae7630f7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:16:08 +0000 Subject: [PATCH 1/4] wip(objectql): an uninstall forgets the engine's manifest entry Claude-Session: https://claude.ai/code/session_01Utp6CbfmKbpDmbzwGpfLLp Co-authored-by: Claude --- .../engine-uninstall-forgets-manifest.test.ts | 151 ++++++++++++++++++ packages/objectql/src/engine.ts | 6 + packages/objectql/src/registry.ts | 44 +++++ 3 files changed, 201 insertions(+) create mode 100644 packages/objectql/src/engine-uninstall-forgets-manifest.test.ts diff --git a/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts b/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts new file mode 100644 index 00000000000..a3466002f40 --- /dev/null +++ b/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #30215 — an uninstalled package stops reading as a booted code package on + * the running instance. + * + * `registerApp` puts a package's manifest in the engine's `manifests` map, and + * ADR-0070's `isWritablePackage` reads that map FIRST: an id in it is a code + * package, read-only. Every uninstall door (the protocol's `deletePackage`, + * `DELETE /packages/:id`, the install-local DELETE) withdraws the package + * through `SchemaRegistry.uninstallPackage`, and nothing took the id out of + * the engine's map. So an administrator who uninstalled a package got a 200, + * then `ensure-customization` on the same instance answered `writable: false` + * for it until the server restarted. + * + * Pinned here, against a real engine and its own registry: + * - the registry's uninstall drops the engine's manifest entry, so the + * predicate answers writable again; + * - an uninstall the registry REFUSES (ADR-0029: another package extends an + * object this one owns) removes nothing, the manifest entry included; + * - a reinstall of the same id puts the entry back (read-only again); + * - the registry's announcement: once per withdrawn package, never for an id + * it does not hold, never after unsubscribe, and a throwing listener does + * not turn a completed uninstall into a refused one. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isWritablePackage } from '@objectstack/metadata-protocol'; +import { ObjectQL } from './engine.js'; +import { SchemaRegistry } from './registry.js'; + +const PKG = 'com.example.tasksapp'; + +function manifestsOf(engine: ObjectQL): Map { + return (engine as unknown as { manifests: Map }).manifests; +} + +function bootWithPackage(): ObjectQL { + const engine = new ObjectQL(); + engine.registerApp({ + id: PKG, + namespace: 'tasks_app', + objects: [{ name: 'tasks_app_task', fields: { name: { type: 'text' } } }], + } as any); + return engine; +} + +describe('ObjectQL — an uninstall forgets the package\'s manifest (#30215)', () => { + it('reads a registered code package as read-only, and writable once the registry uninstalls it', () => { + const engine = bootWithPackage(); + expect(manifestsOf(engine).has(PKG)).toBe(true); + expect(isWritablePackage(engine, PKG)).toBe(false); + + expect(engine.registry.uninstallPackage(PKG)).toBe(true); + + expect(engine.registry.getPackage(PKG)).toBeUndefined(); + expect(manifestsOf(engine).has(PKG)).toBe(false); + expect(isWritablePackage(engine, PKG)).toBe(true); + }); + + it('keeps the manifest entry when the registry refuses the uninstall (an extended object)', () => { + const engine = bootWithPackage(); + engine.registry.registerObject( + { name: 'tasks_app_task', fields: {} } as any, 'com.example.addon', undefined, 'extend', + ); + + expect(() => engine.registry.uninstallPackage(PKG)).toThrow(); + + expect(engine.registry.getPackage(PKG)).toBeDefined(); + expect(manifestsOf(engine).has(PKG)).toBe(true); + expect(isWritablePackage(engine, PKG)).toBe(false); + }); + + it('puts the entry back on a reinstall of the same id', () => { + const engine = bootWithPackage(); + engine.registry.uninstallPackage(PKG); + expect(isWritablePackage(engine, PKG)).toBe(true); + + engine.registerApp({ + id: PKG, + namespace: 'tasks_app', + objects: [{ name: 'tasks_app_task', fields: { name: { type: 'text' } } }], + } as any); + + expect(manifestsOf(engine).has(PKG)).toBe(true); + expect(isWritablePackage(engine, PKG)).toBe(false); + }); + + it('leaves every other package\'s manifest entry in place', () => { + const engine = bootWithPackage(); + engine.registerApp({ id: 'com.example.notesapp', namespace: 'notes_app' } as any); + + engine.registry.uninstallPackage(PKG); + + expect(manifestsOf(engine).has('com.example.notesapp')).toBe(true); + expect(isWritablePackage(engine, 'com.example.notesapp')).toBe(false); + }); +}); + +describe('SchemaRegistry.onPackageUninstalled (#30215)', () => { + afterEach(() => { vi.restoreAllMocks(); }); + + function registryWith(id: string): SchemaRegistry { + const registry = new SchemaRegistry(); + registry.installPackage({ id, name: id, version: '1.0.0', type: 'app' } as any, 'code'); + return registry; + } + + it('announces a withdrawn package once, after its record is gone', () => { + const registry = registryWith(PKG); + const seen: Array<{ id: string; recordGone: boolean }> = []; + registry.onPackageUninstalled((id) => { + seen.push({ id, recordGone: registry.getPackage(id) === undefined }); + }); + + registry.uninstallPackage(PKG); + registry.uninstallPackage(PKG); // nothing left to withdraw + + expect(seen).toEqual([{ id: PKG, recordGone: true }]); + }); + + it('announces nothing for an id the registry does not hold, or after unsubscribe', () => { + const registry = registryWith(PKG); + const listener = vi.fn(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const unsubscribe = registry.onPackageUninstalled(listener); + + registry.uninstallPackage('com.example.never-installed'); + expect(listener).not.toHaveBeenCalled(); + + unsubscribe(); + registry.uninstallPackage(PKG); + expect(listener).not.toHaveBeenCalled(); + }); + + it('a throwing listener neither refuses the uninstall nor starves the next listener, and is logged at error', () => { + const registry = registryWith(PKG); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const next = vi.fn(); + registry.onPackageUninstalled(() => { throw new Error('boom'); }); + registry.onPackageUninstalled(next); + + expect(registry.uninstallPackage(PKG)).toBe(true); + + expect(registry.getPackage(PKG)).toBeUndefined(); + expect(next).toHaveBeenCalledWith(PKG); + expect(error).toHaveBeenCalledTimes(1); + expect(String(error.mock.calls[0][0])).toContain(`package "${PKG}" was uninstalled`); + expect(String(error.mock.calls[0][0])).toContain('boom'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e7f68d2a7da..947b0720cde 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -734,6 +734,12 @@ export class ObjectQL implements IObjectQLEngine { constructor(hostContext: Record = {}) { // Use provided logger or create a new one this.logger = hostContext.logger || createLogger({ level: 'info', format: 'pretty' }); + // [#30215] `registerApp` writes `manifests`; an uninstall withdraws the + // package through the registry's own verb, on every door, so the entry goes + // with the registry's record. Left behind, ADR-0070's `isWritablePackage` + // (which reads this map first) kept an uninstalled package read-only until + // a restart. + this._registry.onPackageUninstalled((packageId) => { this.manifests.delete(packageId); }); // Pick up production hardening switches from env so deployers can // enforce strict-body without code changes: // OBJECTQL_STRICT_HOOKS=1 → unresolved hooks throw at bind time diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index edb21cc8c27..9b0443fea23 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -3619,6 +3619,47 @@ export class SchemaRegistry { this.initialDisabledPackageIds = new Set(ids); } + /** See {@link onPackageUninstalled}. */ + private packageUninstalledListeners = new Set<(packageId: string) => void>(); + + /** + * [#30215] Be told, once per package, that {@link uninstallPackage} has + * withdrawn it — after its record is gone, never for a refused uninstall + * (that one removes nothing). Returns the unsubscribe. + * + * For a holder of state keyed by package id OUTSIDE this registry, so it is + * withdrawn on every uninstall door at once (the protocol's `deletePackage`, + * `DELETE /packages/:id`, the install-local DELETE), never per door. The + * engine is the one today: its manifest map (`registerApp` → `manifests`) is + * the code-package signal ADR-0070's `isWritablePackage` reads first, and an + * id left in it kept an uninstalled package read-only until a restart. + */ + onPackageUninstalled(listener: (packageId: string) => void): () => void { + this.packageUninstalledListeners.add(listener); + return () => { this.packageUninstalledListeners.delete(listener); }; + } + + /** + * Tell every {@link onPackageUninstalled} listener. Never throws: the + * uninstall has already happened, so a throwing listener must not read as a + * refused one to the door that called {@link uninstallPackage}. It is logged + * at `error` instead, because that listener's state now disagrees with this + * registry while everything looks healthy. + */ + private announcePackageUninstalled(id: string): void { + for (const listener of this.packageUninstalledListeners) { + try { + listener(id); + } catch (err) { + console.error( + `[Registry] package "${id}" was uninstalled, but a listener keeping state keyed by its id failed to drop it ` + + `(${(err as Error)?.message ?? err}) — that state still names the package until the next restart, ` + + 'while the registry no longer does. Restart the runtime to bring the two back in line.', + ); + } + } + } + // ========================================== // Restore on refusal // ========================================== @@ -8219,6 +8260,9 @@ export class SchemaRegistry { if (collection) { collection.delete(id); this.log(`[Registry] Uninstalled package: ${id}`); + // …and, last, whoever keeps its own state keyed by this package's id + // (the engine's manifest map) drops it, on every uninstall door at once. + this.announcePackageUninstalled(id); return true; } return false; From a26305001e397857b3b5bcbde49c7e2305916717 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:19:18 +0000 Subject: [PATCH 2/4] fix(objectql): an uninstall forgets the engine's manifest entry Claude-Session: https://claude.ai/code/session_01Utp6CbfmKbpDmbzwGpfLLp Co-authored-by: Claude --- .../30215-uninstall-forgets-engine-manifest.md | 12 ++++++++++++ .../src/engine-uninstall-forgets-manifest.test.ts | 1 + 2 files changed, 13 insertions(+) create mode 100644 .changeset/30215-uninstall-forgets-engine-manifest.md diff --git a/.changeset/30215-uninstall-forgets-engine-manifest.md b/.changeset/30215-uninstall-forgets-engine-manifest.md new file mode 100644 index 00000000000..b015cb0b600 --- /dev/null +++ b/.changeset/30215-uninstall-forgets-engine-manifest.md @@ -0,0 +1,12 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): an uninstalled package no longer reads as read-only until a restart + +Clause-②: no +Metadata-protocol: none + +An administrator who uninstalled a package through `DELETE /api/v1/marketplace/install-local/:id` got a 200 "withdrawn from the running kernel", but on the same instance the package id still read as a booted code package: `POST /api/v1/packages/:id/ensure-customization` answered `writable: false`, and authoring into it was refused with `WRITABLE_PACKAGE_REQUIRED`, until the server restarted. The engine's manifest map, which the ADR-0070 writability check reads first, kept the id after the registry had withdrawn the package. + +`SchemaRegistry.uninstallPackage` now announces each completed uninstall to `onPackageUninstalled` listeners, after the package record is gone (never for a refused uninstall), and the engine drops its manifest entry there, so the id is released on whichever door withdrew the package, not per door. A reinstall of the same id registers it again as before. A listener that throws does not turn the completed uninstall into a refusal; it is logged at `error`. diff --git a/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts b/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts index a3466002f40..173e941fbca 100644 --- a/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts +++ b/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts @@ -113,6 +113,7 @@ describe('SchemaRegistry.onPackageUninstalled (#30215)', () => { seen.push({ id, recordGone: registry.getPackage(id) === undefined }); }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); registry.uninstallPackage(PKG); registry.uninstallPackage(PKG); // nothing left to withdraw From b5f98c8a38788ae71c70736695dd684584b6ad2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:32:21 +0000 Subject: [PATCH 3/4] docs(changeset): name the measured uninstall-then-ensure answer Claude-Session: https://claude.ai/code/session_01Utp6CbfmKbpDmbzwGpfLLp Co-authored-by: Claude --- .changeset/30215-uninstall-forgets-engine-manifest.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30215-uninstall-forgets-engine-manifest.md b/.changeset/30215-uninstall-forgets-engine-manifest.md index b015cb0b600..1fe9c9582d8 100644 --- a/.changeset/30215-uninstall-forgets-engine-manifest.md +++ b/.changeset/30215-uninstall-forgets-engine-manifest.md @@ -7,6 +7,6 @@ fix(objectql): an uninstalled package no longer reads as read-only until a resta Clause-②: no Metadata-protocol: none -An administrator who uninstalled a package through `DELETE /api/v1/marketplace/install-local/:id` got a 200 "withdrawn from the running kernel", but on the same instance the package id still read as a booted code package: `POST /api/v1/packages/:id/ensure-customization` answered `writable: false`, and authoring into it was refused with `WRITABLE_PACKAGE_REQUIRED`, until the server restarted. The engine's manifest map, which the ADR-0070 writability check reads first, kept the id after the registry had withdrawn the package. +An administrator who uninstalled a package through `DELETE /api/v1/marketplace/install-local/:id` got a 200 "withdrawn from the running kernel", but on the same instance the package id still read as a booted code package until the server restarted. Uninstalling a `.custom` pair that an older runtime had installed as code, as the pair install now prescribes, left `POST /api/v1/packages//ensure-customization` answering `created: true` with `writable: false`, so nothing could be customized into the new pair. The engine's manifest map, which the ADR-0070 writability check reads first, kept the id after the registry had withdrawn the package. `SchemaRegistry.uninstallPackage` now announces each completed uninstall to `onPackageUninstalled` listeners, after the package record is gone (never for a refused uninstall), and the engine drops its manifest entry there, so the id is released on whichever door withdrew the package, not per door. A reinstall of the same id registers it again as before. A listener that throws does not turn the completed uninstall into a refusal; it is logged at `error`. From f8e6c780f0c46932be761c5f692fc62f047cd460 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:51:09 +0000 Subject: [PATCH 4/4] test(objectql): the shared registry double models the uninstall subscription Claude-Session: https://claude.ai/code/session_01Utp6CbfmKbpDmbzwGpfLLp Co-authored-by: Claude --- .changeset/30215-uninstall-forgets-engine-manifest.md | 2 +- packages/objectql/src/registry-module-mock.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.changeset/30215-uninstall-forgets-engine-manifest.md b/.changeset/30215-uninstall-forgets-engine-manifest.md index 1fe9c9582d8..52d422d7308 100644 --- a/.changeset/30215-uninstall-forgets-engine-manifest.md +++ b/.changeset/30215-uninstall-forgets-engine-manifest.md @@ -4,7 +4,7 @@ fix(objectql): an uninstalled package no longer reads as read-only until a restart -Clause-②: no +Clause-②: yes (widening) — `@objectstack/objectql`'s exported `SchemaRegistry` gains the public method `onPackageUninstalled(listener)`, which returns its unsubscribe. Nothing is refused that was accepted before. Metadata-protocol: none An administrator who uninstalled a package through `DELETE /api/v1/marketplace/install-local/:id` got a 200 "withdrawn from the running kernel", but on the same instance the package id still read as a booted code package until the server restarted. Uninstalling a `.custom` pair that an older runtime had installed as code, as the pair install now prescribes, left `POST /api/v1/packages//ensure-customization` answering `created: true` with `writable: false`, so nothing could be customized into the new pair. The engine's manifest map, which the ADR-0070 writability check reads first, kept the id after the registry had withdrawn the package. diff --git a/packages/objectql/src/registry-module-mock.ts b/packages/objectql/src/registry-module-mock.ts index 9acf5f775ac..bc64b3eccfb 100644 --- a/packages/objectql/src/registry-module-mock.ts +++ b/packages/objectql/src/registry-module-mock.ts @@ -183,6 +183,11 @@ export function createRegistryModuleMock( // a refused registration is put back. The double runs the body as given: // it records nothing, so it has nothing to put back. restoringOnRefusal: vi.fn((register: () => unknown) => register()), + // [#30215] Every engine subscribes in its constructor, so its manifest map + // forgets a package the registry uninstalls. The double never uninstalls, + // so it never announces: it records the subscription and answers the + // unsubscribe the real member answers. + onPackageUninstalled: vi.fn(() => () => {}), reset: vi.fn(), metadata: { get: vi.fn(() => new Map()) }, ...instanceOverrides,