Skip to content

Commit b5459bc

Browse files
os-zhuangclaude
andauthored
fix(metadata): a writable datasource: loader must implement delete() (#5276) (#5652)
`MetadataLoader` declared `save?` and no `delete`, so `capabilities.write` meant two different things at the two ends of an item's life: "persist into me" to `register()`, and nothing at all to `unregister()`, which duck-typed `delete` at the call site and silently skipped a loader that had none — then dropped the registry entry, invalidated the list cache and announced a `deleted` event anyway. The caller was told the delete succeeded while the row stayed in the loader and was read straight back out by the next `list()`/`get()`, across restarts, with nothing to retry it. declared != enforced (Prime Directive #10). - `MetadataLoader` now declares `delete?(type, name): Promise<void>`, next to `save?`. The temporary `DeletableMetadataLoader` shape #5259 introduced to name the duck-typed hole is retired with both of its casts. - `registerLoader()` rejects, loudly, a loader declaring `protocol: 'datasource:'` with `capabilities.write: true` and no `delete()` method, naming the loader, the consequence, and both repairs (implement it, or declare `capabilities.write: false`). It is the sole writer of the loader map — the constructor's `config.loaders` funnel through it — so the combination cannot reach the runtime. - `unregister()`'s `typeof ... === 'function'` guard stays as defensive code whose unreachability is now guaranteed by the registration gate; the comment says so. Scope is exactly the combination `unregister()` acts on: `file:`/`memory:`/ `http:`/`s3:` loaders are never written to by the manager at runtime and are not gated, and a read-only `datasource:` loader is untouched. `DatabaseLoader`, the only `datasource:` loader in the repo, has always had `delete()` and is unchanged. Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2e1057 commit b5459bc

5 files changed

Lines changed: 437 additions & 23 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): `capabilities.write` now means BOTH directions — a writable datasource loader must implement `delete()` (#5276)
6+
7+
`MetadataLoader` declared `save?` and no `delete`, so `capabilities.write` meant
8+
two different things at the two ends of an item's life: to `register()` it meant
9+
"persist into me", and to `unregister()` it guaranteed nothing at all.
10+
`unregister()` duck-typed `delete` at the call site and, when a loader had none,
11+
**silently skipped it** — then dropped the registry entry, invalidated the list
12+
cache and announced a `deleted` event anyway. The caller (Studio/Setup, REST
13+
DELETE, the CLI, a package teardown) was told the delete succeeded while the row
14+
stayed in the loader's store, was read straight back out by the next
15+
`list()`/`get()`, and survived every restart with nothing to retry it.
16+
17+
Two changes, both making the declaration binding instead of decorative:
18+
19+
- **`MetadataLoader` now declares `delete?(type: string, name: string): Promise<void>`.**
20+
The capability is stated on the contract, next to `save?`, instead of being
21+
guessed at by each caller. A loader implemented against the interface can now
22+
see that the method exists.
23+
- **`MetadataManager.registerLoader()` rejects the combination that cannot
24+
honour it.** A loader declaring `protocol: 'datasource:'` **and**
25+
`capabilities.write: true` **without** a `delete()` method is refused at
26+
registration with an error naming the loader, the consequence, and both
27+
repairs. `registerLoader()` is the sole writer of the loader map — the
28+
constructor's `config.loaders` funnel through it — so the combination can no
29+
longer reach the runtime and lose a deletion there.
30+
31+
**Does this affect you?** Only if you register a custom metadata loader that
32+
declares `protocol: 'datasource:'` with `capabilities.write: true`. If it does
33+
and has no `delete()`, registration now throws where it previously succeeded and
34+
quietly discarded your deletions. Two ways to fix it, both stated in the error:
35+
36+
1. implement `async delete(type: string, name: string): Promise<void>` on the
37+
loader, removing the item from its store (`DatabaseLoader` in this package is
38+
the reference implementation); or
39+
2. if the loader is genuinely read-only, declare `capabilities.write: false` — a
40+
read-only `datasource:` loader registers without complaint and is never
41+
written to in the first place.
42+
43+
Loaders on the other protocols (`file:`, `memory:`, `http:`, `s3:`) are
44+
unaffected in either direction: `MetadataManager` never persists to them at
45+
runtime, so it has no deletion of its own to take back, and they may declare
46+
`capabilities.write` without a `delete()` exactly as before. The one
47+
`datasource:` loader shipped in this package, `DatabaseLoader`, has always
48+
implemented `delete()` and is unchanged.

packages/metadata/src/loaders/loader-interface.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,33 @@ export interface MetadataLoader {
8585
data: any,
8686
options?: MetadataSaveOptions
8787
): Promise<MetadataSaveResult>;
88+
89+
/**
90+
* Delete a metadata item from this loader's store.
91+
*
92+
* [#5276] Optional on the interface, **mandatory for a `datasource:` loader
93+
* that declares `capabilities.write`** — `MetadataManager.registerLoader()`
94+
* refuses to register such a loader when this method is missing, so the
95+
* combination "declared writable, cannot delete" never reaches the runtime.
96+
*
97+
* The reason it is enforced at registration rather than tolerated at the
98+
* delete site: `MetadataManager.register()` persists into every writable
99+
* `datasource:` loader, and `unregister()` has to take those rows back out
100+
* again. A loader that can be written to but not deleted from makes every
101+
* deletion a silent lie — `unregister()` would skip it, then drop the
102+
* registry entry, invalidate the list cache and announce a `deleted` event,
103+
* so the caller is told the delete succeeded while the row is read straight
104+
* back out of this loader by the next `list()`/`get()`. `capabilities.write`
105+
* therefore means *both* directions of the write, on both ends of the item's
106+
* life — declared = enforced.
107+
*
108+
* Loaders on the other protocols (`file:`, `memory:`, `http:`, `s3:`) are not
109+
* gated: `MetadataManager` never writes to them at runtime, so it never has a
110+
* deletion of its own to take back.
111+
*
112+
* @param type The metadata type
113+
* @param name The item name
114+
*/
115+
delete?(type: string, name: string): Promise<void>;
88116
}
89117

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #5276 — `capabilities.write` means BOTH directions, and registration enforces it.
5+
*
6+
* `MetadataLoader` declared `save?` and no `delete`, so `capabilities.write`
7+
* meant two different things at the two ends of an item's life: to
8+
* `register()` it meant "persist into me", and to `unregister()` it guaranteed
9+
* nothing at all. `unregister()` duck-typed `delete` at the call site and, when
10+
* the loader had none, **silently skipped it** — then dropped the registry
11+
* entry, invalidated the list cache and announced a `deleted` event anyway. The
12+
* caller was told the delete succeeded; the row stayed in the loader and was
13+
* read straight back out by the next `list()`/`get()`, across restarts, with
14+
* nothing to retry it. Standard declared ≠ enforced (Prime Directive #10).
15+
*
16+
* The fix enforces the declaration where the author is standing:
17+
* 1. `MetadataLoader` now declares `delete?(type, name): Promise<void>` — the
18+
* contract states the capability instead of leaving each caller to guess;
19+
* 2. `registerLoader()` REJECTS a `datasource:` loader that declares
20+
* `capabilities.write` without a `delete()` method, loudly, naming the
21+
* consequence and both ways out. `registerLoader()` is the sole writer of
22+
* the loader map (the constructor's `config.loaders` funnel through it),
23+
* so the rejected combination cannot reach the runtime at all;
24+
* 3. `unregister()`'s `typeof … === 'function'` guard stays as defensive code
25+
* whose unreachability is now guaranteed by construction.
26+
*
27+
* What these tests pin:
28+
* 1. the rejection, on both entry points (constructor config and the direct
29+
* `registerLoader()` call), including that nothing is half-registered;
30+
* 2. the message is actionable — it names the loader and BOTH repairs;
31+
* 3. the positive case is untouched: a writable datasource loader WITH
32+
* `delete` registers and `unregister()` really calls it;
33+
* 4. the gate's scope is exactly the combination `unregister()` acts on —
34+
* a read-only `datasource:` loader and every non-`datasource:` protocol
35+
* register without a `delete`, because the manager never writes to them;
36+
* 5. `DatabaseLoader`, the repo's only real `datasource:` loader, passes the
37+
* gate unchanged.
38+
*/
39+
40+
import { describe, it, expect, vi, beforeEach } from 'vitest';
41+
import type {
42+
MetadataLoadResult,
43+
MetadataLoaderContract,
44+
MetadataSaveResult,
45+
MetadataStats,
46+
} from '@objectstack/spec/system';
47+
import type { IDataDriver } from '@objectstack/spec/contracts';
48+
import { MetadataManager } from './metadata-manager.js';
49+
import { DatabaseLoader } from './loaders/database-loader.js';
50+
import type { MetadataLoader } from './loaders/loader-interface.js';
51+
52+
const logger = vi.hoisted(() => ({
53+
info: vi.fn(),
54+
warn: vi.fn(),
55+
error: vi.fn(),
56+
debug: vi.fn(),
57+
}));
58+
59+
vi.mock('@objectstack/core', () => ({
60+
createLogger: () => logger,
61+
}));
62+
63+
type Protocol = MetadataLoaderContract['protocol'];
64+
65+
/**
66+
* A loader whose contract is dictated per test and whose `delete` is present or
67+
* absent on demand — the two axes the gate reads, and nothing else.
68+
*/
69+
function makeLoader(opts: {
70+
name: string;
71+
protocol: Protocol;
72+
write: boolean;
73+
withDelete: boolean;
74+
}): MetadataLoader & { deleteCalls: Array<[string, string]>; saveCalls: Array<[string, string]> } {
75+
const deleteCalls: Array<[string, string]> = [];
76+
const saveCalls: Array<[string, string]> = [];
77+
const store = new Map<string, unknown>();
78+
const key = (type: string, name: string) => `${type}/${name}`;
79+
80+
const loader: MetadataLoader & {
81+
deleteCalls: Array<[string, string]>;
82+
saveCalls: Array<[string, string]>;
83+
} = {
84+
contract: {
85+
name: opts.name,
86+
protocol: opts.protocol,
87+
capabilities: { read: true, write: opts.write, watch: false, list: true },
88+
},
89+
deleteCalls,
90+
saveCalls,
91+
async load(type: string, name: string): Promise<MetadataLoadResult> {
92+
const data = store.get(key(type, name));
93+
return data === undefined ? { data: null } : { data };
94+
},
95+
async loadMany<T = unknown>(): Promise<T[]> {
96+
return Array.from(store.values()) as T[];
97+
},
98+
async exists(type: string, name: string): Promise<boolean> {
99+
return store.has(key(type, name));
100+
},
101+
async stat(): Promise<MetadataStats | null> {
102+
return null;
103+
},
104+
async list(): Promise<string[]> {
105+
return [];
106+
},
107+
async save(type: string, name: string, data: unknown): Promise<MetadataSaveResult> {
108+
saveCalls.push([type, name]);
109+
store.set(key(type, name), data);
110+
return { success: true };
111+
},
112+
};
113+
114+
if (opts.withDelete) {
115+
loader.delete = async (type: string, name: string): Promise<void> => {
116+
deleteCalls.push([type, name]);
117+
store.delete(key(type, name));
118+
};
119+
}
120+
121+
return loader;
122+
}
123+
124+
/** Read the manager's private loader map — the thing registration writes. */
125+
const registeredLoaderNames = (mgr: MetadataManager): string[] =>
126+
Array.from((mgr as unknown as { loaders: Map<string, unknown> }).loaders.keys());
127+
128+
beforeEach(() => {
129+
logger.info.mockClear();
130+
logger.warn.mockClear();
131+
logger.error.mockClear();
132+
logger.debug.mockClear();
133+
});
134+
135+
describe("a `datasource:` loader that declares `capabilities.write` MUST implement `delete()`", () => {
136+
it('registerLoader() throws rather than accepting a loader it can never delete from', () => {
137+
const mgr = new MetadataManager({ formats: ['json'], loaders: [] });
138+
const undeletable = makeLoader({
139+
name: 'half_writable_store',
140+
protocol: 'datasource:',
141+
write: true,
142+
withDelete: false,
143+
});
144+
145+
expect(() => mgr.registerLoader(undeletable)).toThrow(/half_writable_store/);
146+
});
147+
148+
it('…and nothing is half-registered — the rejected loader is not in the map', () => {
149+
const mgr = new MetadataManager({ formats: ['json'], loaders: [] });
150+
const undeletable = makeLoader({
151+
name: 'half_writable_store',
152+
protocol: 'datasource:',
153+
write: true,
154+
withDelete: false,
155+
});
156+
157+
expect(() => mgr.registerLoader(undeletable)).toThrow();
158+
expect(registeredLoaderNames(mgr)).not.toContain('half_writable_store');
159+
});
160+
161+
it('the constructor rejects it too — `config.loaders` is not a back door', () => {
162+
const undeletable = makeLoader({
163+
name: 'half_writable_store',
164+
protocol: 'datasource:',
165+
write: true,
166+
withDelete: false,
167+
});
168+
169+
expect(
170+
() => new MetadataManager({ formats: ['json'], loaders: [undeletable] }),
171+
).toThrow(/half_writable_store/);
172+
});
173+
174+
it('the message names the loader, the consequence, and BOTH repairs', () => {
175+
const mgr = new MetadataManager({ formats: ['json'], loaders: [] });
176+
const undeletable = makeLoader({
177+
name: 'half_writable_store',
178+
protocol: 'datasource:',
179+
write: true,
180+
withDelete: false,
181+
});
182+
183+
let message = '';
184+
try {
185+
mgr.registerLoader(undeletable);
186+
} catch (error) {
187+
message = error instanceof Error ? error.message : String(error);
188+
}
189+
190+
// Which loader, and what it declared.
191+
expect(message).toContain('half_writable_store');
192+
expect(message).toContain("protocol: 'datasource:'");
193+
expect(message).toContain('capabilities.write: true');
194+
// The consequence: the delete is announced but never lands.
195+
expect(message).toContain('`unregister()`');
196+
expect(message).toContain('`deleted`');
197+
// Repair A — implement it. Repair B — stop declaring the capability.
198+
expect(message).toContain('delete(type: string, name: string)');
199+
expect(message).toContain('capabilities.write: false');
200+
});
201+
202+
it('the same loader WITH `delete` registers, and `unregister()` really calls it', async () => {
203+
const deletable = makeLoader({
204+
name: 'writable_store',
205+
protocol: 'datasource:',
206+
write: true,
207+
withDelete: true,
208+
});
209+
const mgr = new MetadataManager({ formats: ['json'], loaders: [deletable] });
210+
211+
expect(registeredLoaderNames(mgr)).toContain('writable_store');
212+
213+
await mgr.register('object', 'account', { name: 'account' });
214+
expect(deletable.saveCalls).toEqual([['object', 'account']]);
215+
216+
await mgr.unregister('object', 'account');
217+
expect(deletable.deleteCalls).toEqual([['object', 'account']]);
218+
// The announced deletion is now the truth in every store.
219+
expect(await mgr.get('object', 'account')).toBeUndefined();
220+
expect(await deletable.exists('object', 'account')).toBe(false);
221+
});
222+
});
223+
224+
describe('the gate covers exactly the combination `unregister()` acts on', () => {
225+
it('a read-only `datasource:` loader needs no `delete` — nothing ever writes to it', async () => {
226+
const readOnly = makeLoader({
227+
name: 'reporting_replica',
228+
protocol: 'datasource:',
229+
write: false,
230+
withDelete: false,
231+
});
232+
233+
const mgr = new MetadataManager({ formats: ['json'], loaders: [readOnly] });
234+
expect(registeredLoaderNames(mgr)).toContain('reporting_replica');
235+
236+
await mgr.register('object', 'account', { name: 'account' });
237+
expect(readOnly.saveCalls).toEqual([]);
238+
await expect(mgr.unregister('object', 'account')).resolves.toBeUndefined();
239+
});
240+
241+
it.each<Protocol>(['file:', 'memory:', 'http:', 's3:'])(
242+
'a `%s` loader may declare write without a `delete` — the manager never persists there',
243+
(protocol) => {
244+
const loader = makeLoader({
245+
name: `loader_${protocol.replace(':', '')}`,
246+
protocol,
247+
write: true,
248+
withDelete: false,
249+
});
250+
251+
const mgr = new MetadataManager({ formats: ['json'], loaders: [] });
252+
expect(() => mgr.registerLoader(loader)).not.toThrow();
253+
expect(registeredLoaderNames(mgr)).toContain(loader.contract.name);
254+
},
255+
);
256+
});
257+
258+
describe('regression — the real `datasource:` loader is unaffected', () => {
259+
/**
260+
* `DatabaseLoader` declares `datasource:` + `capabilities.write` and has
261+
* implemented `delete()` all along; the gate must be a no-op for it. The
262+
* driver is a stub because registration touches no storage — construction
263+
* and the contract are the whole surface under test here.
264+
*/
265+
it('DatabaseLoader registers under the gate', () => {
266+
const loader = new DatabaseLoader({ driver: {} as IDataDriver });
267+
268+
expect(loader.contract.protocol).toBe('datasource:');
269+
expect(loader.contract.capabilities.write).toBe(true);
270+
expect(typeof loader.delete).toBe('function');
271+
272+
const mgr = new MetadataManager({ formats: ['json'], loaders: [] });
273+
expect(() => mgr.registerLoader(loader)).not.toThrow();
274+
expect(registeredLoaderNames(mgr)).toContain('database');
275+
});
276+
});

0 commit comments

Comments
 (0)