Skip to content

Commit c06e669

Browse files
Copilothotlong
andauthored
fix: MetadataPlugin driver bridging and register() loader filtering
1. MetadataPlugin.start() now discovers driver services from kernel and calls manager.setDatabaseDriver() to enable DatabaseLoader. 2. register() only persists to 'datasource:' protocol loaders, preventing FilesystemLoader from being written to at runtime. 3. unregister() applies the same datasource: protocol filter. 4. Added 10 new tests covering all three fixes. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/12f98d90-59fd-4398-acd1-e267ef7256cc Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent bef09b9 commit c06e669

File tree

3 files changed

+191
-6
lines changed

3 files changed

+191
-6
lines changed

packages/metadata/src/metadata-manager.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,21 @@ export class MetadataManager implements IMetadataService {
153153

154154
/**
155155
* Register/save a metadata item by type
156-
* Stores in-memory registry and persists to writable loaders (if configured)
156+
* Stores in-memory registry and persists to database-backed loaders only.
157+
* FilesystemLoader (protocol 'file:') is read-only for static metadata and
158+
* should not be written to during runtime registration.
157159
*/
158160
async register(type: string, name: string, data: unknown): Promise<void> {
159161
if (!this.registry.has(type)) {
160162
this.registry.set(type, new Map());
161163
}
162164
this.registry.get(type)!.set(name, data);
163165

164-
// Persist to writable loaders (e.g., DatabaseLoader for history tracking)
166+
// Persist only to database-backed loaders (protocol 'datasource:')
167+
// FilesystemLoader is read-only at runtime — writing to it can crash in
168+
// read-only environments (e.g. serverless, containerized deployments).
165169
for (const loader of this.loaders.values()) {
166-
if (loader.save) {
170+
if (loader.save && loader.contract.protocol === 'datasource:') {
167171
await loader.save(type, name, data);
168172
}
169173
}
@@ -218,7 +222,8 @@ export class MetadataManager implements IMetadataService {
218222
}
219223

220224
/**
221-
* Unregister/remove a metadata item by type and name
225+
* Unregister/remove a metadata item by type and name.
226+
* Deletes from database-backed loaders only (same rationale as register()).
222227
*/
223228
async unregister(type: string, name: string): Promise<void> {
224229
// Remove from in-memory registry
@@ -230,9 +235,9 @@ export class MetadataManager implements IMetadataService {
230235
}
231236
}
232237

233-
// Also delete from all loaders that support deletion
238+
// Delete only from database-backed loaders (protocol 'datasource:')
234239
for (const loader of this.loaders.values()) {
235-
// Check if the loader has a delete method
240+
if (loader.contract.protocol !== 'datasource:') continue;
236241
if (typeof (loader as any).delete === 'function') {
237242
try {
238243
await (loader as any).delete(type, name);

packages/metadata/src/metadata.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,118 @@ describe('MetadataManager', () => {
226226
});
227227
});
228228

229+
describe('register — loader protocol filtering', () => {
230+
it('should persist to datasource: protocol loaders', async () => {
231+
const dbLoader: MetadataLoader = {
232+
contract: { name: 'database', protocol: 'datasource:' as const, capabilities: { read: true, write: true, watch: false, list: true } },
233+
load: vi.fn().mockResolvedValue({ data: null }),
234+
loadMany: vi.fn().mockResolvedValue([]),
235+
exists: vi.fn().mockResolvedValue(false),
236+
stat: vi.fn().mockResolvedValue(null),
237+
list: vi.fn().mockResolvedValue([]),
238+
save: vi.fn().mockResolvedValue({ success: true }),
239+
};
240+
241+
const m = new MetadataManager({ formats: ['json'], loaders: [dbLoader] });
242+
await m.register('object', 'account', { name: 'account' });
243+
244+
expect(dbLoader.save).toHaveBeenCalledWith('object', 'account', { name: 'account' });
245+
});
246+
247+
it('should NOT persist to file: protocol loaders', async () => {
248+
const fsLoader: MetadataLoader = {
249+
contract: { name: 'filesystem', protocol: 'file:' as const, capabilities: { read: true, write: true, watch: true, list: true } },
250+
load: vi.fn().mockResolvedValue({ data: null }),
251+
loadMany: vi.fn().mockResolvedValue([]),
252+
exists: vi.fn().mockResolvedValue(false),
253+
stat: vi.fn().mockResolvedValue(null),
254+
list: vi.fn().mockResolvedValue([]),
255+
save: vi.fn().mockResolvedValue({ success: true }),
256+
};
257+
258+
const m = new MetadataManager({ formats: ['json'], loaders: [fsLoader] });
259+
await m.register('object', 'account', { name: 'account' });
260+
261+
expect(fsLoader.save).not.toHaveBeenCalled();
262+
});
263+
264+
it('should NOT persist to memory: protocol loaders', async () => {
265+
const memLoader: MetadataLoader = {
266+
contract: { name: 'memory', protocol: 'memory:' as const, capabilities: { read: true, write: true, watch: false, list: true } },
267+
load: vi.fn().mockResolvedValue({ data: null }),
268+
loadMany: vi.fn().mockResolvedValue([]),
269+
exists: vi.fn().mockResolvedValue(false),
270+
stat: vi.fn().mockResolvedValue(null),
271+
list: vi.fn().mockResolvedValue([]),
272+
save: vi.fn().mockResolvedValue({ success: true }),
273+
};
274+
275+
const m = new MetadataManager({ formats: ['json'], loaders: [memLoader] });
276+
await m.register('object', 'account', { name: 'account' });
277+
278+
expect(memLoader.save).not.toHaveBeenCalled();
279+
});
280+
281+
it('should still store in in-memory registry regardless of loaders', async () => {
282+
const m = new MetadataManager({ formats: ['json'], loaders: [] });
283+
await m.register('object', 'account', { name: 'account' });
284+
285+
const result = await m.get('object', 'account');
286+
expect(result).toEqual({ name: 'account' });
287+
});
288+
});
289+
290+
describe('unregister — loader protocol filtering', () => {
291+
it('should delete from datasource: protocol loaders', async () => {
292+
const deleteFn = vi.fn();
293+
const dbLoader: MetadataLoader = {
294+
contract: { name: 'database', protocol: 'datasource:' as const, capabilities: { read: true, write: true, watch: false, list: true } },
295+
load: vi.fn().mockResolvedValue({ data: null }),
296+
loadMany: vi.fn().mockResolvedValue([]),
297+
exists: vi.fn().mockResolvedValue(false),
298+
stat: vi.fn().mockResolvedValue(null),
299+
list: vi.fn().mockResolvedValue([]),
300+
save: vi.fn().mockResolvedValue({ success: true }),
301+
delete: deleteFn,
302+
} as any;
303+
304+
const m = new MetadataManager({ formats: ['json'], loaders: [dbLoader] });
305+
await m.register('object', 'account', { name: 'account' });
306+
await m.unregister('object', 'account');
307+
308+
expect(deleteFn).toHaveBeenCalledWith('object', 'account');
309+
});
310+
311+
it('should NOT delete from file: protocol loaders', async () => {
312+
const deleteFn = vi.fn();
313+
const fsLoader: MetadataLoader = {
314+
contract: { name: 'filesystem', protocol: 'file:' as const, capabilities: { read: true, write: true, watch: true, list: true } },
315+
load: vi.fn().mockResolvedValue({ data: null }),
316+
loadMany: vi.fn().mockResolvedValue([]),
317+
exists: vi.fn().mockResolvedValue(false),
318+
stat: vi.fn().mockResolvedValue(null),
319+
list: vi.fn().mockResolvedValue([]),
320+
save: vi.fn().mockResolvedValue({ success: true }),
321+
delete: deleteFn,
322+
} as any;
323+
324+
const m = new MetadataManager({ formats: ['json'], loaders: [fsLoader] });
325+
await m.register('object', 'account', { name: 'account' });
326+
await m.unregister('object', 'account');
327+
328+
expect(deleteFn).not.toHaveBeenCalled();
329+
});
330+
331+
it('should remove from in-memory registry', async () => {
332+
const m = new MetadataManager({ formats: ['json'], loaders: [] });
333+
await m.register('object', 'account', { name: 'account' });
334+
expect(await m.get('object', 'account')).toEqual({ name: 'account' });
335+
336+
await m.unregister('object', 'account');
337+
expect(await m.get('object', 'account')).toBeUndefined();
338+
});
339+
});
340+
229341
describe('registerLoader', () => {
230342
it('should register a new loader', async () => {
231343
const newLoader = new MemoryLoader();
@@ -340,6 +452,7 @@ describe('MetadataPlugin', () => {
340452
registerLoader = vi.fn();
341453
stopWatching = vi.fn();
342454
setTypeRegistry = vi.fn();
455+
setDatabaseDriver = vi.fn();
343456
register = vi.fn();
344457
};
345458
return { NodeMetadataManager: MockNodeMetadataManager };
@@ -382,6 +495,53 @@ describe('MetadataPlugin', () => {
382495
// start should call logger.info at least once
383496
expect(ctx.logger.info).toHaveBeenCalled();
384497
});
498+
499+
it('should bridge driver service to MetadataManager in start()', async () => {
500+
const { MetadataPlugin } = await import('./plugin.js');
501+
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });
502+
503+
const mockDriver = { name: 'mock-driver', find: vi.fn(), create: vi.fn() };
504+
const services = new Map<string, any>();
505+
services.set('driver.mock-driver', mockDriver);
506+
507+
const ctx = createMockPluginContext();
508+
ctx.getServices = vi.fn().mockReturnValue(services);
509+
510+
await plugin.init(ctx);
511+
await plugin.start(ctx);
512+
513+
// Verify setDatabaseDriver was called on the manager with the driver
514+
const manager = (plugin as any).manager;
515+
expect(manager.setDatabaseDriver).toHaveBeenCalledWith(mockDriver);
516+
});
517+
518+
it('should not fail when no driver service is available', async () => {
519+
const { MetadataPlugin } = await import('./plugin.js');
520+
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });
521+
522+
const ctx = createMockPluginContext();
523+
ctx.getServices = vi.fn().mockReturnValue(new Map());
524+
525+
await plugin.init(ctx);
526+
// Should not throw
527+
await expect(plugin.start(ctx)).resolves.not.toThrow();
528+
529+
// setDatabaseDriver should not have been called
530+
const manager = (plugin as any).manager;
531+
expect(manager.setDatabaseDriver).not.toHaveBeenCalled();
532+
});
533+
534+
it('should gracefully handle getServices errors', async () => {
535+
const { MetadataPlugin } = await import('./plugin.js');
536+
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });
537+
538+
const ctx = createMockPluginContext();
539+
ctx.getServices = vi.fn().mockImplementation(() => { throw new Error('services unavailable'); });
540+
541+
await plugin.init(ctx);
542+
// Should not throw even when getServices fails
543+
await expect(plugin.start(ctx)).resolves.not.toThrow();
544+
});
385545
});
386546

387547
// ---------- Helpers ----------

packages/metadata/src/plugin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,26 @@ export class MetadataPlugin implements Plugin {
6969
}
7070

7171
start = async (ctx: PluginContext) => {
72+
// Bridge database driver from kernel service registry to MetadataManager.
73+
// DriverPlugin registers drivers as 'driver.{name}' services during init().
74+
// We look them up here (start phase) to enable DatabaseLoader persistence.
75+
try {
76+
const services = ctx.getServices();
77+
for (const [serviceName, service] of services) {
78+
if (serviceName.startsWith('driver.') && service) {
79+
ctx.logger.info('[MetadataPlugin] Bridging driver to MetadataManager for database-backed persistence', {
80+
driverService: serviceName,
81+
});
82+
this.manager.setDatabaseDriver(service);
83+
break; // Use the first available driver
84+
}
85+
}
86+
} catch (e: any) {
87+
ctx.logger.debug('[MetadataPlugin] No driver service found — database metadata persistence not available', {
88+
error: e.message,
89+
});
90+
}
91+
7292
ctx.logger.info('Loading metadata from file system...');
7393

7494
// Use the type registry to discover metadata types (sorted by loadOrder)

0 commit comments

Comments
 (0)