Skip to content

Commit 3628a14

Browse files
Copilothotlong
andauthored
fix: address review feedback — defer driver bridging, check write capability, update diagram
1. Move driver bridging to AFTER filesystem metadata loading in MetadataPlugin.start() to prevent system metadata from being persisted into the database on every cold boot. 2. Gate register()/unregister() persistence on both protocol ('datasource:') AND contract.capabilities.write to respect the loader's declared write capability. 3. Update README architecture diagram to say "datasource-backed storage" instead of "planned" for DatabaseLoader consistency. 4. Added 3 new tests: read-only datasource loader filtering for register/unregister, and driver bridging ordering verification. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/856d4345-f537-4680-9c7e-e0ddd81cfa30 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 76761ab commit 3628a14

File tree

4 files changed

+94
-25
lines changed

4 files changed

+94
-25
lines changed

packages/metadata/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ It implements the **`IMetadataService`** contract from `@objectstack/spec` and a
2828
│ │ (files) │ │ (HTTP) │ │ (test) │ │
2929
│ └─────────────┘ └──────────────┘ └───────────────────┘ │
3030
│ ┌──────────────────────────────────────────────────────┐ │
31-
│ │ DatabaseLoader (planned — datasource-backed storage) │ │
31+
│ │ DatabaseLoader (datasource-backed storage) │ │
3232
│ └──────────────────────────────────────────────────────┘ │
3333
├─────────────────────────────────────────────────────────────┤
3434
│ Serializer Layer │

packages/metadata/src/metadata-manager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,11 @@ export class MetadataManager implements IMetadataService {
163163
}
164164
this.registry.get(type)!.set(name, data);
165165

166-
// Persist only to database-backed loaders (protocol 'datasource:')
166+
// Persist only to database-backed loaders that declare write capability.
167167
// FilesystemLoader is read-only at runtime — writing to it can crash in
168168
// read-only environments (e.g. serverless, containerized deployments).
169169
for (const loader of this.loaders.values()) {
170-
if (loader.save && loader.contract.protocol === 'datasource:') {
170+
if (loader.save && loader.contract.protocol === 'datasource:' && loader.contract.capabilities.write) {
171171
await loader.save(type, name, data);
172172
}
173173
}
@@ -235,9 +235,9 @@ export class MetadataManager implements IMetadataService {
235235
}
236236
}
237237

238-
// Delete only from database-backed loaders (protocol 'datasource:')
238+
// Delete only from database-backed loaders that declare write capability
239239
for (const loader of this.loaders.values()) {
240-
if (loader.contract.protocol !== 'datasource:') continue;
240+
if (loader.contract.protocol !== 'datasource:' || !loader.contract.capabilities.write) continue;
241241
if (typeof (loader as any).delete === 'function') {
242242
try {
243243
await (loader as any).delete(type, name);

packages/metadata/src/metadata.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,23 @@ describe('MetadataManager', () => {
278278
expect(memLoader.save).not.toHaveBeenCalled();
279279
});
280280

281+
it('should NOT persist to datasource: protocol loaders with write: false', async () => {
282+
const readOnlyDbLoader: MetadataLoader = {
283+
contract: { name: 'database-ro', protocol: 'datasource:' as const, capabilities: { read: true, write: false, watch: false, list: true } },
284+
load: vi.fn().mockResolvedValue({ data: null }),
285+
loadMany: vi.fn().mockResolvedValue([]),
286+
exists: vi.fn().mockResolvedValue(false),
287+
stat: vi.fn().mockResolvedValue(null),
288+
list: vi.fn().mockResolvedValue([]),
289+
save: vi.fn().mockResolvedValue({ success: true }),
290+
};
291+
292+
const m = new MetadataManager({ formats: ['json'], loaders: [readOnlyDbLoader] });
293+
await m.register('object', 'account', { name: 'account' });
294+
295+
expect(readOnlyDbLoader.save).not.toHaveBeenCalled();
296+
});
297+
281298
it('should still store in in-memory registry regardless of loaders', async () => {
282299
const m = new MetadataManager({ formats: ['json'], loaders: [] });
283300
await m.register('object', 'account', { name: 'account' });
@@ -328,6 +345,26 @@ describe('MetadataManager', () => {
328345
expect(deleteFn).not.toHaveBeenCalled();
329346
});
330347

348+
it('should NOT delete from datasource: protocol loaders with write: false', async () => {
349+
const deleteFn = vi.fn();
350+
const readOnlyDbLoader: MetadataLoader = {
351+
contract: { name: 'database-ro', protocol: 'datasource:' as const, capabilities: { read: true, write: false, watch: false, list: true } },
352+
load: vi.fn().mockResolvedValue({ data: null }),
353+
loadMany: vi.fn().mockResolvedValue([]),
354+
exists: vi.fn().mockResolvedValue(false),
355+
stat: vi.fn().mockResolvedValue(null),
356+
list: vi.fn().mockResolvedValue([]),
357+
save: vi.fn().mockResolvedValue({ success: true }),
358+
delete: deleteFn,
359+
} as any;
360+
361+
const m = new MetadataManager({ formats: ['json'], loaders: [readOnlyDbLoader] });
362+
await m.register('object', 'account', { name: 'account' });
363+
await m.unregister('object', 'account');
364+
365+
expect(deleteFn).not.toHaveBeenCalled();
366+
});
367+
331368
it('should remove from in-memory registry', async () => {
332369
const m = new MetadataManager({ formats: ['json'], loaders: [] });
333370
await m.register('object', 'account', { name: 'account' });
@@ -515,6 +552,36 @@ describe('MetadataPlugin', () => {
515552
expect(manager.setDatabaseDriver).toHaveBeenCalledWith(mockDriver);
516553
});
517554

555+
it('should bridge driver AFTER filesystem metadata loading', async () => {
556+
const { MetadataPlugin } = await import('./plugin.js');
557+
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });
558+
559+
const callOrder: string[] = [];
560+
const mockDriver = { name: 'mock-driver', find: vi.fn(), create: vi.fn() };
561+
const services = new Map<string, any>();
562+
services.set('driver.mock-driver', mockDriver);
563+
564+
const manager = (plugin as any).manager;
565+
manager.loadMany = vi.fn().mockImplementation(async () => {
566+
callOrder.push('loadMany');
567+
return [];
568+
});
569+
manager.setDatabaseDriver = vi.fn().mockImplementation(() => {
570+
callOrder.push('setDatabaseDriver');
571+
});
572+
573+
const ctx = createMockPluginContext();
574+
ctx.getServices = vi.fn().mockReturnValue(services);
575+
576+
await plugin.init(ctx);
577+
await plugin.start(ctx);
578+
579+
// setDatabaseDriver must be called after all loadMany calls
580+
const lastLoad = callOrder.lastIndexOf('loadMany');
581+
const driverIdx = callOrder.indexOf('setDatabaseDriver');
582+
expect(driverIdx).toBeGreaterThan(lastLoad);
583+
});
584+
518585
it('should not fail when no driver service is available', async () => {
519586
const { MetadataPlugin } = await import('./plugin.js');
520587
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });

packages/metadata/src/plugin.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,26 +69,6 @@ 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 discovered driver — typically only one driver is registered per deployment
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-
9272
ctx.logger.info('Loading metadata from file system...');
9373

9474
// Use the type registry to discover metadata types (sorted by loadOrder)
@@ -122,5 +102,27 @@ export class MetadataPlugin implements Plugin {
122102
totalItems: totalLoaded,
123103
registeredTypes: sortedTypes.length,
124104
});
105+
106+
// Bridge database driver from kernel service registry to MetadataManager.
107+
// DriverPlugin registers drivers as 'driver.{name}' services during init().
108+
// This runs AFTER filesystem loading so that system metadata populated via
109+
// register() above is stored in-memory only, without being persisted to the
110+
// database (which would create noisy DB state/history on every cold boot).
111+
try {
112+
const services = ctx.getServices();
113+
for (const [serviceName, service] of services) {
114+
if (serviceName.startsWith('driver.') && service) {
115+
ctx.logger.info('[MetadataPlugin] Bridging driver to MetadataManager for database-backed persistence', {
116+
driverService: serviceName,
117+
});
118+
this.manager.setDatabaseDriver(service);
119+
break; // Use the first discovered driver — typically only one driver is registered per deployment
120+
}
121+
}
122+
} catch (e: any) {
123+
ctx.logger.debug('[MetadataPlugin] No driver service found — database metadata persistence not available', {
124+
error: e.message,
125+
});
126+
}
125127
}
126128
}

0 commit comments

Comments
 (0)