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
14 changes: 14 additions & 0 deletions .changeset/32986-host-unregister-keeps-environment-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/metadata': patch
---

fix(metadata): unregistering the host package keeps the environment's own object, view, app, hook and dashboard rows

Clause-②: no — no released answer moves: `unregisterPackage` reads the `_packageId` stamp since #32198, which has not shipped, and with this change it leaves the environment's own rows the boot pass marked, as the released version does. The boot pass's stamp, and every reader of it, answer as before.
Metadata-protocol: none

On a host that declares its package and stores metadata in a database, the boot pass stamps each stored row that names no package with the host's package. For a type not stored per `(package, name)` — `object`, `view`, `app`, `hook`, `dashboard` and the rest — that stamp stays, so ObjectQL still registers such an object as the host package's contribution. But `MetadataManager.unregisterPackage(host)` took the stamp as membership and deleted those rows, from memory and from the database: the environment's own items were lost when the host package was unregistered.

`unregisterPackage` now leaves an item that a stored-row loader handed on naming no package and that names the package only by the stamp. The host's own items (a row whose stored body carries the host's stamp or names it under the legacy `package` key, an item registered for the host in memory or from files) are removed as before. Types stored per `(package, name)` are unchanged here.

The limit: a row is kept when the boot pass marked it and nothing has rewritten it since. The mark is on the item the pass registered. A later write that replaces that item with a copy still stamped with the host (ObjectQL's late-manifest bridge, `publishPackage` and `revertPackage` of the host) drops the mark and stores the stamp in the row, and `unregisterPackage(host)` then deletes that row again. #33003 closes that before the release that ships #32198.
106 changes: 106 additions & 0 deletions packages/metadata/src/loaders/database-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2930,3 +2930,109 @@ describe('[#32521] the boot priming pass primes every holder of a name', () => {
expect((await new DatabaseLoader({ driver }).loadManyKeyed('email_template')).map((entry) => entry.packageId)).toEqual([ALPHA, BETA]);
});
});

/**
* [#32986; follow-up of #32198, ADR-0150 D4] On a host that declares its
* package, the boot priming pass stamps a package-less stored row of a type
* NOT stored per `(package, name)` (`object`, `view`, `app`, `hook`,
* `dashboard` …) with the host's package (`applyProtection`), and the stamp
* stays: an `object` row so stamped is still registered by ObjectQL as the
* host package's contribution (`_packageId ?? 'metadata-service'`). But since
* #32198 `unregisterPackage` selects a package's items by `_packageId` too,
* so `unregisterPackage(host)` deleted the environment's own rows — the
* registry entry and the stored row both. The removal now leaves an item a
* stored-row loader handed on naming no package, whose only claim to the
* package is the stamp the pass put on it.
*
* BEFORE (`main`): every case below not marked CONTROL lost the row (the
* registry entry gone, the stored row deleted). The CONTROLs answer on `main`
* as here.
*/
describe('[#32986] unregisterPackage of the host package keeps the environment\'s own rows the boot pass stamped with the host', () => {
const HOST = 'com.host.app';
const OTHER = 'com.other.app';
/** One name per type, each a name the type's own write rule keeps as it is. */
const ITEMS: ReadonlyArray<readonly [type: string, name: string]> = [
['object', 'env_account'], ['view', 'env_account.all'], ['app', 'env_app'], ['hook', 'env_hook'], ['dashboard', 'env_dashboard'],
];
let driver: IDataDriver;

const seed = (type: string, name: string, packageId: string | null, body: Record<string, unknown> = {}) =>
driver.create('sys_metadata', {
id: `${type}_${name}_${packageId ?? 'env'}`, type, name, state: 'active', version: 1, checksum: `${type}_${name}_${packageId ?? 'env'}`,
source: 'database', package_id: packageId, metadata: JSON.stringify({ name, label: packageId ?? 'env', ...body }),
});
/** The real pass over one database loader, on a host that declares `hostPackage`. */
const bootPass = async (hostPackage?: string) => {
const plugin = new MetadataPlugin({ ...(hostPackage ? { packageId: hostPackage } : {}), watch: false, config: { bootstrap: 'eager' } });
const m = (plugin as any).manager as MetadataManager;
(m as any).loaders.clear();
m.registerLoader(new DatabaseLoader({ driver }));
await (plugin as any)._loadFromFileSystem({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } });
return m;
};
/** Per item: the registry's stamp, what a read answers, and whether its stored row is still there. */
const state = async (m: MetadataManager) => {
const out: unknown[] = [];
for (const [type, name] of ITEMS) {
const held = (m as any).registry.get(type)?.get(name) as { _packageId?: string } | undefined;
const read = (await m.get(type, name)) as { label?: string } | undefined;
const rows = await driver.find('sys_metadata', { where: { type, name } } as never);
out.push([type, held?._packageId ?? null, read?.label ?? null, rows.length]);
}
return out;
};

beforeEach(() => {
driver = createMockDriver();
});

it('package-less rows of object, view, app, hook and dashboard, on a host declaring its package: the pass stamps them with the host as before, and unregisterPackage(host) keeps each registry entry and stored row (BEFORE: every one deleted)', async () => {
for (const [type, name] of ITEMS) await seed(type, name, null);
const m = await bootPass(HOST);
const primed = ITEMS.map(([type]) => [type, HOST, 'env', 1]);
expect(await state(m), 'the stamp, as before: the object contribution owner does not move').toEqual(primed);
await m.unregisterPackage(HOST);
expect(await state(m)).toEqual(primed);
});

it('CONTROL the host\'s own rows (their bodies carry the host\'s stamp, as a host item the pass seeded is stored): unregisterPackage(host) still removes each, from the registry and storage', async () => {
for (const [type, name] of ITEMS) await seed(type, name, null, { _packageId: HOST });
const m = await bootPass(HOST);
expect(await state(m)).toEqual(ITEMS.map(([type]) => [type, HOST, 'env', 1]));
await m.unregisterPackage(HOST);
expect(await state(m)).toEqual(ITEMS.map(([type]) => [type, null, null, 0]));
});

/**
* [#32986, review S2] The legacy `package` key is the item's own claim, as
* 18.3.0's `unregisterPackage` read it: the row is the host's, though its
* stored body names no package the store's reads index (`_packageId`).
*/
it('CONTROL the host\'s own rows naming it through the legacy `package` key in their stored bodies: unregisterPackage(host) still removes each, from the registry and storage', async () => {
for (const [type, name] of ITEMS) await seed(type, name, null, { package: HOST });
const m = await bootPass(HOST);
expect(await state(m)).toEqual(ITEMS.map(([type]) => [type, HOST, 'env', 1]));
await m.unregisterPackage(HOST);
expect(await state(m)).toEqual(ITEMS.map(([type]) => [type, null, null, 0]));
});

it('an item registered in memory for the host (code) beside the environment\'s rows: unregisterPackage(host) removes the host\'s item and keeps the environment\'s (BEFORE: both removed)', async () => {
for (const [type, name] of ITEMS) await seed(type, name, null);
const m = await bootPass(HOST);
await m.registerInMemory('object', 'host_thing', { name: 'host_thing', label: 'code', _packageId: HOST });
await m.unregisterPackage(HOST);
expect(await m.get('object', 'host_thing')).toBeUndefined();
expect(await state(m)).toEqual(ITEMS.map(([type]) => [type, HOST, 'env', 1]));
});

it('CONTROL unregisterPackage of another package, and a host declaring no package: the environment\'s rows are untouched, unstamped on the second', async () => {
for (const [type, name] of ITEMS) await seed(type, name, null);
const m = await bootPass(HOST);
await m.unregisterPackage(OTHER);
expect(await state(m)).toEqual(ITEMS.map(([type]) => [type, HOST, 'env', 1]));
const bare = await bootPass();
await bare.unregisterPackage(HOST);
expect(await state(bare)).toEqual(ITEMS.map(([type]) => [type, null, 'env', 1]));
});
});
39 changes: 37 additions & 2 deletions packages/metadata/src/metadata-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,17 @@ export class MetadataManager implements IMetadataService {
* one with the host's package before it registers it ({@link slotViewOf}).
*/
private readonly storedPackagelessRows = new WeakSet<object>();
/**
* [#32986] The items of every OTHER type (one not stored per
* `(package, name)`: `object`, `view`, `app`, `hook`, `dashboard` …) that
* {@link loadMany} read from a stored-row loader (`datasource:`) naming no
* package: the environment's own rows. The boot priming pass stamps one with
* the host's declared package (`_packageId`), and the stamp stays — ObjectQL
* registers such an `object` as the host package's contribution — but the
* row is not the package's item, so {@link unregisterPackage} leaves it
* ({@link isStampedEnvironmentRow}).
*/
private readonly storedEnvironmentRows = new WeakSet<object>();
/**
* [ADR-0150 D4, #32791] The {@link storedPackagelessRows} whose registration
* replaced an item no loader handed out (one registered in memory or by
Expand Down Expand Up @@ -2674,6 +2685,10 @@ export class MetadataManager implements IMetadataService {
* `(package, name)` drops this package's slot and keeps another package's
* same-named item — and, for such a type, the storage half deletes only this
* package's row and the `deleted` event names this package.
*
* [#32986] Of every other type, an environment row the boot priming pass
* stamped with this package (the host's) is not one of its items, and stays
* ({@link isStampedEnvironmentRow}).
*/
async unregisterPackage(packageName: string): Promise<void> {
// Collect all items to delete (type and name pairs). [#32517] One entry
Expand All @@ -2686,6 +2701,10 @@ export class MetadataManager implements IMetadataService {
for (const [key, data] of typeStore) {
// [#32246] A by-package type: also every item in the package's slot,
// a package-less customization of its item (D4) included.
// [#32986] Not the environment's own row the boot pass stamped with
// this package: since #32198 the stamp alone made it a member, and the
// removal deleted it from the registry and from storage.
if (!slotted && this.isStampedEnvironmentRow(data, packageName)) continue;
if (isPackageMember(data, packageName) || (slotted && slotPackageOf(key) === packageName)) {
const slot = `${type}\u0000${slotOfKey(key)}`;
const entry = itemsToDelete.get(slot);
Expand All @@ -2704,6 +2723,20 @@ export class MetadataManager implements IMetadataService {
}
}

/**
* [#32986] Whether `data` is one of the {@link storedEnvironmentRows} that
* names `packageId` only by the `_packageId` stamp — the boot priming pass's
* host stamp, which `applyProtection` writes on an unstamped item alone. The
* row's own body named no package when the loader handed it on, so
* `packageId` / `package` naming the package is the item's own claim (the
* row is the package's, as {@link isPackageMember} reads it).
*/
private isStampedEnvironmentRow(data: unknown, packageId: string): boolean {
if (data === null || typeof data !== 'object' || !this.storedEnvironmentRows.has(data)) return false;
const meta = data as { packageId?: unknown; package?: unknown; _packageId?: unknown };
return meta._packageId === packageId && meta.packageId !== packageId && meta.package !== packageId;
}

/**
* [#22113] The registry items that belong to `packageId`, for the two
* package-wide writes ({@link publishPackage}, {@link revertPackage}).
Expand Down Expand Up @@ -3845,8 +3878,10 @@ export class MetadataManager implements IMetadataService {
if (item !== null && typeof item === 'object') {
this.loadedFrom.set(item, loader);
// [#32791] Read here, before any caller stamps it.
if (PACKAGE_SLOTTED_STORE_TYPES.has(type) && loader.contract.protocol === 'datasource:' && indexedItemPackageId(item) === undefined) {
this.storedPackagelessRows.add(item);
if (loader.contract.protocol === 'datasource:' && indexedItemPackageId(item) === undefined) {
if (PACKAGE_SLOTTED_STORE_TYPES.has(type)) this.storedPackagelessRows.add(item);
// [#32986] Read here too, before the boot pass stamps it.
else this.storedEnvironmentRows.add(item);
}
}
});
Expand Down
Loading