diff --git a/.changeset/metadata-loader-delete-contract.md b/.changeset/metadata-loader-delete-contract.md new file mode 100644 index 0000000000..ad76b2f409 --- /dev/null +++ b/.changeset/metadata-loader-delete-contract.md @@ -0,0 +1,48 @@ +--- +"@objectstack/metadata": patch +--- + +fix(metadata): `capabilities.write` now means BOTH directions — a writable datasource loader must implement `delete()` (#5276) + +`MetadataLoader` declared `save?` and no `delete`, so `capabilities.write` meant +two different things at the two ends of an item's life: to `register()` it meant +"persist into me", and to `unregister()` it guaranteed nothing at all. +`unregister()` duck-typed `delete` at the call site and, when a loader had none, +**silently skipped it** — then dropped the registry entry, invalidated the list +cache and announced a `deleted` event anyway. The caller (Studio/Setup, REST +DELETE, the CLI, a package teardown) was told the delete succeeded while the row +stayed in the loader's store, was read straight back out by the next +`list()`/`get()`, and survived every restart with nothing to retry it. + +Two changes, both making the declaration binding instead of decorative: + +- **`MetadataLoader` now declares `delete?(type: string, name: string): Promise`.** + The capability is stated on the contract, next to `save?`, instead of being + guessed at by each caller. A loader implemented against the interface can now + see that the method exists. +- **`MetadataManager.registerLoader()` rejects the combination that cannot + honour it.** A loader declaring `protocol: 'datasource:'` **and** + `capabilities.write: true` **without** a `delete()` method is refused at + registration with an error naming the loader, the consequence, and both + repairs. `registerLoader()` is the sole writer of the loader map — the + constructor's `config.loaders` funnel through it — so the combination can no + longer reach the runtime and lose a deletion there. + +**Does this affect you?** Only if you register a custom metadata loader that +declares `protocol: 'datasource:'` with `capabilities.write: true`. If it does +and has no `delete()`, registration now throws where it previously succeeded and +quietly discarded your deletions. Two ways to fix it, both stated in the error: + +1. implement `async delete(type: string, name: string): Promise` on the + loader, removing the item from its store (`DatabaseLoader` in this package is + the reference implementation); or +2. if the loader is genuinely read-only, declare `capabilities.write: false` — a + read-only `datasource:` loader registers without complaint and is never + written to in the first place. + +Loaders on the other protocols (`file:`, `memory:`, `http:`, `s3:`) are +unaffected in either direction: `MetadataManager` never persists to them at +runtime, so it has no deletion of its own to take back, and they may declare +`capabilities.write` without a `delete()` exactly as before. The one +`datasource:` loader shipped in this package, `DatabaseLoader`, has always +implemented `delete()` and is unchanged. diff --git a/packages/metadata/src/loaders/loader-interface.ts b/packages/metadata/src/loaders/loader-interface.ts index 1d1509343b..cbfa2592e0 100644 --- a/packages/metadata/src/loaders/loader-interface.ts +++ b/packages/metadata/src/loaders/loader-interface.ts @@ -85,5 +85,33 @@ export interface MetadataLoader { data: any, options?: MetadataSaveOptions ): Promise; + + /** + * Delete a metadata item from this loader's store. + * + * [#5276] Optional on the interface, **mandatory for a `datasource:` loader + * that declares `capabilities.write`** — `MetadataManager.registerLoader()` + * refuses to register such a loader when this method is missing, so the + * combination "declared writable, cannot delete" never reaches the runtime. + * + * The reason it is enforced at registration rather than tolerated at the + * delete site: `MetadataManager.register()` persists into every writable + * `datasource:` loader, and `unregister()` has to take those rows back out + * again. A loader that can be written to but not deleted from makes every + * deletion a silent lie — `unregister()` would skip it, then drop the + * registry entry, invalidate the list cache and announce a `deleted` event, + * so the caller is told the delete succeeded while the row is read straight + * back out of this loader by the next `list()`/`get()`. `capabilities.write` + * therefore means *both* directions of the write, on both ends of the item's + * life — declared = enforced. + * + * Loaders on the other protocols (`file:`, `memory:`, `http:`, `s3:`) are not + * gated: `MetadataManager` never writes to them at runtime, so it never has a + * deletion of its own to take back. + * + * @param type The metadata type + * @param name The item name + */ + delete?(type: string, name: string): Promise; } diff --git a/packages/metadata/src/metadata-manager-loader-delete-contract.test.ts b/packages/metadata/src/metadata-manager-loader-delete-contract.test.ts new file mode 100644 index 0000000000..e409c3ff66 --- /dev/null +++ b/packages/metadata/src/metadata-manager-loader-delete-contract.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5276 — `capabilities.write` means BOTH directions, and registration enforces it. + * + * `MetadataLoader` declared `save?` and no `delete`, so `capabilities.write` + * meant two different things at the two ends of an item's life: to + * `register()` it meant "persist into me", and to `unregister()` it guaranteed + * nothing at all. `unregister()` duck-typed `delete` at the call site and, when + * the loader had none, **silently skipped it** — then dropped the registry + * entry, invalidated the list cache and announced a `deleted` event anyway. The + * caller was told the delete succeeded; the row stayed in the loader and was + * read straight back out by the next `list()`/`get()`, across restarts, with + * nothing to retry it. Standard declared ≠ enforced (Prime Directive #10). + * + * The fix enforces the declaration where the author is standing: + * 1. `MetadataLoader` now declares `delete?(type, name): Promise` — the + * contract states the capability instead of leaving each caller to guess; + * 2. `registerLoader()` REJECTS a `datasource:` loader that declares + * `capabilities.write` without a `delete()` method, loudly, naming the + * consequence and both ways out. `registerLoader()` is the sole writer of + * the loader map (the constructor's `config.loaders` funnel through it), + * so the rejected combination cannot reach the runtime at all; + * 3. `unregister()`'s `typeof … === 'function'` guard stays as defensive code + * whose unreachability is now guaranteed by construction. + * + * What these tests pin: + * 1. the rejection, on both entry points (constructor config and the direct + * `registerLoader()` call), including that nothing is half-registered; + * 2. the message is actionable — it names the loader and BOTH repairs; + * 3. the positive case is untouched: a writable datasource loader WITH + * `delete` registers and `unregister()` really calls it; + * 4. the gate's scope is exactly the combination `unregister()` acts on — + * a read-only `datasource:` loader and every non-`datasource:` protocol + * register without a `delete`, because the manager never writes to them; + * 5. `DatabaseLoader`, the repo's only real `datasource:` loader, passes the + * gate unchanged. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { + MetadataLoadResult, + MetadataLoaderContract, + MetadataSaveResult, + MetadataStats, +} from '@objectstack/spec/system'; +import type { IDataDriver } from '@objectstack/spec/contracts'; +import { MetadataManager } from './metadata-manager.js'; +import { DatabaseLoader } from './loaders/database-loader.js'; +import type { MetadataLoader } from './loaders/loader-interface.js'; + +const logger = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('@objectstack/core', () => ({ + createLogger: () => logger, +})); + +type Protocol = MetadataLoaderContract['protocol']; + +/** + * A loader whose contract is dictated per test and whose `delete` is present or + * absent on demand — the two axes the gate reads, and nothing else. + */ +function makeLoader(opts: { + name: string; + protocol: Protocol; + write: boolean; + withDelete: boolean; +}): MetadataLoader & { deleteCalls: Array<[string, string]>; saveCalls: Array<[string, string]> } { + const deleteCalls: Array<[string, string]> = []; + const saveCalls: Array<[string, string]> = []; + const store = new Map(); + const key = (type: string, name: string) => `${type}/${name}`; + + const loader: MetadataLoader & { + deleteCalls: Array<[string, string]>; + saveCalls: Array<[string, string]>; + } = { + contract: { + name: opts.name, + protocol: opts.protocol, + capabilities: { read: true, write: opts.write, watch: false, list: true }, + }, + deleteCalls, + saveCalls, + async load(type: string, name: string): Promise { + const data = store.get(key(type, name)); + return data === undefined ? { data: null } : { data }; + }, + async loadMany(): Promise { + return Array.from(store.values()) as T[]; + }, + async exists(type: string, name: string): Promise { + return store.has(key(type, name)); + }, + async stat(): Promise { + return null; + }, + async list(): Promise { + return []; + }, + async save(type: string, name: string, data: unknown): Promise { + saveCalls.push([type, name]); + store.set(key(type, name), data); + return { success: true }; + }, + }; + + if (opts.withDelete) { + loader.delete = async (type: string, name: string): Promise => { + deleteCalls.push([type, name]); + store.delete(key(type, name)); + }; + } + + return loader; +} + +/** Read the manager's private loader map — the thing registration writes. */ +const registeredLoaderNames = (mgr: MetadataManager): string[] => + Array.from((mgr as unknown as { loaders: Map }).loaders.keys()); + +beforeEach(() => { + logger.info.mockClear(); + logger.warn.mockClear(); + logger.error.mockClear(); + logger.debug.mockClear(); +}); + +describe("a `datasource:` loader that declares `capabilities.write` MUST implement `delete()`", () => { + it('registerLoader() throws rather than accepting a loader it can never delete from', () => { + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); + const undeletable = makeLoader({ + name: 'half_writable_store', + protocol: 'datasource:', + write: true, + withDelete: false, + }); + + expect(() => mgr.registerLoader(undeletable)).toThrow(/half_writable_store/); + }); + + it('…and nothing is half-registered — the rejected loader is not in the map', () => { + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); + const undeletable = makeLoader({ + name: 'half_writable_store', + protocol: 'datasource:', + write: true, + withDelete: false, + }); + + expect(() => mgr.registerLoader(undeletable)).toThrow(); + expect(registeredLoaderNames(mgr)).not.toContain('half_writable_store'); + }); + + it('the constructor rejects it too — `config.loaders` is not a back door', () => { + const undeletable = makeLoader({ + name: 'half_writable_store', + protocol: 'datasource:', + write: true, + withDelete: false, + }); + + expect( + () => new MetadataManager({ formats: ['json'], loaders: [undeletable] }), + ).toThrow(/half_writable_store/); + }); + + it('the message names the loader, the consequence, and BOTH repairs', () => { + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); + const undeletable = makeLoader({ + name: 'half_writable_store', + protocol: 'datasource:', + write: true, + withDelete: false, + }); + + let message = ''; + try { + mgr.registerLoader(undeletable); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + // Which loader, and what it declared. + expect(message).toContain('half_writable_store'); + expect(message).toContain("protocol: 'datasource:'"); + expect(message).toContain('capabilities.write: true'); + // The consequence: the delete is announced but never lands. + expect(message).toContain('`unregister()`'); + expect(message).toContain('`deleted`'); + // Repair A — implement it. Repair B — stop declaring the capability. + expect(message).toContain('delete(type: string, name: string)'); + expect(message).toContain('capabilities.write: false'); + }); + + it('the same loader WITH `delete` registers, and `unregister()` really calls it', async () => { + const deletable = makeLoader({ + name: 'writable_store', + protocol: 'datasource:', + write: true, + withDelete: true, + }); + const mgr = new MetadataManager({ formats: ['json'], loaders: [deletable] }); + + expect(registeredLoaderNames(mgr)).toContain('writable_store'); + + await mgr.register('object', 'account', { name: 'account' }); + expect(deletable.saveCalls).toEqual([['object', 'account']]); + + await mgr.unregister('object', 'account'); + expect(deletable.deleteCalls).toEqual([['object', 'account']]); + // The announced deletion is now the truth in every store. + expect(await mgr.get('object', 'account')).toBeUndefined(); + expect(await deletable.exists('object', 'account')).toBe(false); + }); +}); + +describe('the gate covers exactly the combination `unregister()` acts on', () => { + it('a read-only `datasource:` loader needs no `delete` — nothing ever writes to it', async () => { + const readOnly = makeLoader({ + name: 'reporting_replica', + protocol: 'datasource:', + write: false, + withDelete: false, + }); + + const mgr = new MetadataManager({ formats: ['json'], loaders: [readOnly] }); + expect(registeredLoaderNames(mgr)).toContain('reporting_replica'); + + await mgr.register('object', 'account', { name: 'account' }); + expect(readOnly.saveCalls).toEqual([]); + await expect(mgr.unregister('object', 'account')).resolves.toBeUndefined(); + }); + + it.each(['file:', 'memory:', 'http:', 's3:'])( + 'a `%s` loader may declare write without a `delete` — the manager never persists there', + (protocol) => { + const loader = makeLoader({ + name: `loader_${protocol.replace(':', '')}`, + protocol, + write: true, + withDelete: false, + }); + + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); + expect(() => mgr.registerLoader(loader)).not.toThrow(); + expect(registeredLoaderNames(mgr)).toContain(loader.contract.name); + }, + ); +}); + +describe('regression — the real `datasource:` loader is unaffected', () => { + /** + * `DatabaseLoader` declares `datasource:` + `capabilities.write` and has + * implemented `delete()` all along; the gate must be a no-op for it. The + * driver is a stub because registration touches no storage — construction + * and the contract are the whole surface under test here. + */ + it('DatabaseLoader registers under the gate', () => { + const loader = new DatabaseLoader({ driver: {} as IDataDriver }); + + expect(loader.contract.protocol).toBe('datasource:'); + expect(loader.contract.capabilities.write).toBe(true); + expect(typeof loader.delete).toBe('function'); + + const mgr = new MetadataManager({ formats: ['json'], loaders: [] }); + expect(() => mgr.registerLoader(loader)).not.toThrow(); + expect(registeredLoaderNames(mgr)).toContain('database'); + }); +}); diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index e1d724e659..3a82a53b8b 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -79,21 +79,59 @@ import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; export type WatchCallback = (event: MetadataWatchEvent) => void | Promise; /** - * [#5259] A {@link MetadataLoader} that also implements deletion. + * [#5276] The registration gate's message for a loader that declares it can be + * written to but cannot be deleted from. * - * `MetadataLoader` declares `save?` but no `delete?`, so `unregister()` has - * always duck-typed the method at the call site. Naming the shape here replaces - * the two `as any` casts that did it before — the cast is still a cast, but it - * is now one declared shape rather than an untyped hole, and the `typeof - * … === 'function'` guard in front of it is what actually decides. + * Built here rather than inline so the gate and its tests quote one text, in the + * shape AGENTS.md → "Degradation log levels" asks of a loud failure: the + * **consequence** (concretely, and that the system keeps looking healthy) and + * the **fix** (both ways out, so the author does not have to guess which one + * their loader wants). + */ +export function buildWritableLoaderMissingDeleteMessage(loaderName: string): string { + return ( + `[MetadataManager] Refusing to register metadata loader \`${loaderName}\`: it declares ` + + "`protocol: 'datasource:'` with `capabilities.write: true` but implements no `delete()` method. " + + 'A write-capable datasource loader is written to AND deleted from — `register()` persists every item into it, ' + + 'and `unregister()` has to take those rows back out again. ' + + 'Registered as-is, every deletion would be a silent lie: `unregister()` skips a loader that cannot delete, then ' + + 'drops the registry entry, invalidates the list cache and announces a `deleted` event, so the caller ' + + '(Studio/Setup, REST DELETE, the CLI, a package teardown) is told the delete succeeded while the row stays in ' + + `\`${loaderName}\` and is read straight back out by the very next \`list()\`/\`get()\` — across restarts, with ` + + 'nothing to retry it. ' + + `Fix: either implement \`delete(type: string, name: string): Promise\` on \`${loaderName}\` ` + + '(`DatabaseLoader` in this package is the reference implementation), or, if the loader is genuinely read-only, ' + + "declare `capabilities.write: false` — a read-only `datasource:` loader registers without complaint and is " + + 'never written to in the first place.' + ); +} + +/** + * [#5276] Registration gate: a `datasource:` loader that declares + * `capabilities.write` MUST implement `delete()`. * - * Whether the loader contract itself should declare `delete?` (and what a - * `capabilities.write` loader *without* one means) is a separate question, - * deliberately not answered here. + * `capabilities.write` used to mean two different things at the two ends of an + * item's life — "persist into me" to {@link MetadataManager.register}, and + * nothing at all to {@link MetadataManager.unregister}, which duck-typed + * `delete` at the call site and **silently skipped** a loader that had none + * before announcing the deletion anyway. That is the declared ≠ enforced shape + * (Prime Directive #10), and the cure it prescribes is to enforce the + * declaration, not to tolerate the gap: the loader is rejected at registration, + * where the author is standing, instead of losing a row at delete time in a + * deployment nobody is watching. + * + * Scope is deliberately exactly the combination `unregister()` acts on. Other + * protocols (`file:`, `memory:`, `http:`, `s3:`) are never written to by the + * manager at runtime — `register()` filters on `datasource:` too — so they have + * no deletion of their own to take back and are not gated. A `datasource:` + * loader with `capabilities.write: false` is likewise untouched by both paths. */ -type DeletableMetadataLoader = MetadataLoader & { - delete?: (type: string, name: string) => Promise; -}; +function assertWritableLoaderCanDelete(loader: MetadataLoader): void { + const { name, protocol, capabilities } = loader.contract; + if (protocol !== 'datasource:' || capabilities.write !== true) return; + if (typeof loader.delete === 'function') return; + throw new Error(buildWritableLoaderMissingDeleteMessage(name)); +} /** * [#5189] Appended to the namespace gate's message when `publishPackage` was @@ -561,8 +599,15 @@ export class MetadataManager implements IMetadataService { /** * Register a new metadata loader (data source) + * + * [#5276] Rejects — loudly, before the loader is stored — a `datasource:` + * loader that declares `capabilities.write` without implementing `delete()`. + * This is the **only** way into `this.loaders` (the constructor's + * `config.loaders` come through here too), which is what lets every later + * delete-capability guard be defensive rather than load-bearing. */ registerLoader(loader: MetadataLoader) { + assertWritableLoaderCanDelete(loader); this.loaders.set(loader.contract.name, loader); this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`); } @@ -1032,7 +1077,14 @@ export class MetadataManager implements IMetadataService { // Delete only from database-backed loaders that declare write capability. for (const loader of this.loaders.values()) { if (loader.contract.protocol !== 'datasource:' || !loader.contract.capabilities.write) continue; - if (typeof (loader as DeletableMetadataLoader).delete !== 'function') continue; + // [#5276] Defensive only — unreachable for a registered loader. This exact + // combination (`datasource:` + `capabilities.write`, no `delete`) is + // rejected by `registerLoader()`, the sole writer of `this.loaders`, so + // reaching this `continue` would mean a loader entered the map without + // passing the gate. Kept because the alternative is a TypeError on the + // line below, and because `delete?` stays optional on the interface for + // the protocols the gate does not cover. + if (typeof loader.delete !== 'function') continue; try { await this.deleteMetaItemFromLoader(loader, type, name); } catch (error) { @@ -1083,16 +1135,20 @@ export class MetadataManager implements IMetadataService { * with a blast radius of precisely this call site, mirroring `saveMetaItem` * on the write side (#4754). * - * `MetadataLoader` declares `save?` but no `delete?`, which is why the caller - * duck-types before getting here; widening the loader contract is a separate - * question and deliberately not answered by this issue. + * [#5276] `MetadataLoader` now declares `delete?`, so no cast is left here. + * It stays *optional* on the interface — `file:`/`memory:`/`http:`/`s3:` + * loaders legitimately have none — and the guard below is therefore a type + * narrowing rather than a policy decision. The policy lives at + * `registerLoader()`: a `datasource:` loader that declares + * `capabilities.write` cannot be registered without a `delete()`, which is + * exactly the set of loaders this method is ever called for. */ private async deleteMetaItemFromLoader( loader: MetadataLoader, type: string, name: string, ): Promise { - const del = (loader as DeletableMetadataLoader).delete; + const del = loader.delete; if (typeof del !== 'function') return; await del.call(loader, type, name); } diff --git a/packages/metadata/src/metadata.test.ts b/packages/metadata/src/metadata.test.ts index 0b46a64e09..c079c5b6f3 100644 --- a/packages/metadata/src/metadata.test.ts +++ b/packages/metadata/src/metadata.test.ts @@ -239,6 +239,12 @@ describe('MetadataManager', () => { stat: vi.fn().mockResolvedValue(null), list: vi.fn().mockResolvedValue([]), save: vi.fn().mockResolvedValue({ success: true }), + // [#5276] A `datasource:` loader that declares `capabilities.write` must + // implement `delete` — `registerLoader()` rejects it otherwise. This + // stub is a write-capable datasource loader by intent (that is the whole + // point of the assertion below), so it gains the method rather than + // narrowing its capabilities. + delete: vi.fn().mockResolvedValue(undefined), }; const m = new MetadataManager({ formats: ['json'], loaders: [dbLoader] }); @@ -309,7 +315,7 @@ describe('MetadataManager', () => { describe('unregister — loader protocol filtering', () => { it('should delete from datasource: protocol loaders', async () => { - const deleteFn = vi.fn(); + const deleteFn = vi.fn().mockResolvedValue(undefined); const dbLoader: MetadataLoader = { contract: { name: 'database', protocol: 'datasource:' as const, capabilities: { read: true, write: true, watch: false, list: true } }, load: vi.fn().mockResolvedValue({ data: null }), @@ -319,7 +325,7 @@ describe('MetadataManager', () => { list: vi.fn().mockResolvedValue([]), save: vi.fn().mockResolvedValue({ success: true }), delete: deleteFn, - } as any; + }; const m = new MetadataManager({ formats: ['json'], loaders: [dbLoader] }); await m.register('object', 'account', { name: 'account' }); @@ -329,7 +335,7 @@ describe('MetadataManager', () => { }); it('should NOT delete from file: protocol loaders', async () => { - const deleteFn = vi.fn(); + const deleteFn = vi.fn().mockResolvedValue(undefined); const fsLoader: MetadataLoader = { contract: { name: 'filesystem', protocol: 'file:' as const, capabilities: { read: true, write: true, watch: true, list: true } }, load: vi.fn().mockResolvedValue({ data: null }), @@ -339,7 +345,7 @@ describe('MetadataManager', () => { list: vi.fn().mockResolvedValue([]), save: vi.fn().mockResolvedValue({ success: true }), delete: deleteFn, - } as any; + }; const m = new MetadataManager({ formats: ['json'], loaders: [fsLoader] }); await m.register('object', 'account', { name: 'account' }); @@ -349,7 +355,7 @@ describe('MetadataManager', () => { }); it('should NOT delete from datasource: protocol loaders with write: false', async () => { - const deleteFn = vi.fn(); + const deleteFn = vi.fn().mockResolvedValue(undefined); const readOnlyDbLoader: MetadataLoader = { contract: { name: 'database-ro', protocol: 'datasource:' as const, capabilities: { read: true, write: false, watch: false, list: true } }, load: vi.fn().mockResolvedValue({ data: null }), @@ -359,7 +365,7 @@ describe('MetadataManager', () => { list: vi.fn().mockResolvedValue([]), save: vi.fn().mockResolvedValue({ success: true }), delete: deleteFn, - } as any; + }; const m = new MetadataManager({ formats: ['json'], loaders: [readOnlyDbLoader] }); await m.register('object', 'account', { name: 'account' });