Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/30215-uninstall-forgets-engine-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/objectql': patch
---

fix(objectql): an uninstalled package no longer reads as read-only until a restart

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 `<base>.custom` pair that an older runtime had installed as code, as the pair install now prescribes, left `POST /api/v1/packages/<base>/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`.
152 changes: 152 additions & 0 deletions packages/objectql/src/engine-uninstall-forgets-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// 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<string, unknown> {
return (engine as unknown as { manifests: Map<string, unknown> }).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 });
});

vi.spyOn(console, 'warn').mockImplementation(() => {});
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');
});
});
6 changes: 6 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,12 @@ export class ObjectQL implements IObjectQLEngine {
constructor(hostContext: Record<string, any> = {}) {
// 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
Expand Down
5 changes: 5 additions & 0 deletions packages/objectql/src/registry-module-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ==========================================
Expand Down Expand Up @@ -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;
Expand Down
Loading