Skip to content

Commit bebd18c

Browse files
authored
Merge pull request #1089 from objectstack-ai/copilot/fix-metadata-manager-driver-issue
2 parents d22209d + 3628a14 commit bebd18c

File tree

5 files changed

+273
-8
lines changed

5 files changed

+273
-8
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
`ObjectDef`/`FieldDef` type definitions in `data-tools.ts` are removed.
1818

1919
### Fixed
20+
- **MetadataPlugin: Driver bridging for database-backed persistence**`MetadataPlugin.start()`
21+
now discovers registered driver services (`driver.*`) from the kernel service registry and
22+
calls `manager.setDatabaseDriver()` to enable `DatabaseLoader`. Previously, no code bridged
23+
the kernel's database driver to the `MetadataManager`, leaving `DatabaseLoader` unconfigured
24+
and metadata persistence limited to the filesystem only.
25+
- **MetadataManager: register() no longer writes to FilesystemLoader**`register()` now
26+
persists metadata only to `datasource:` protocol loaders (i.e. `DatabaseLoader`), skipping
27+
`file:` protocol loaders (`FilesystemLoader`). Previously, `register()` broadcast writes to
28+
all loaders indiscriminately, causing crashes in read-only environments (e.g. serverless,
29+
containerized deployments) when `FilesystemLoader.save()` attempted to write to disk.
30+
The same protocol filter is applied to `unregister()` for consistency.
2031
- **Agent Chat: Vercel SSE Data Stream support** — The agent chat endpoint
2132
(`/api/v1/ai/agents/:agentName/chat`) now returns Vercel AI SDK v6 UI Message Stream Protocol
2233
(SSE) by default, matching the general chat endpoint behaviour. Previously, the agent chat route

packages/metadata/README.md

Lines changed: 2 additions & 2 deletions
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 │
@@ -61,7 +61,7 @@ Loaders are pluggable data sources that know how to read/write metadata from dif
6161
| `FilesystemLoader` | `file:` |||| Implemented |
6262
| `MemoryLoader` | `memory:` |||| Implemented |
6363
| `RemoteLoader` | `http:` |||| Implemented |
64-
| `DatabaseLoader` | `datasource:` ||| | Planned |
64+
| `DatabaseLoader` | `datasource:` ||| | Implemented |
6565

6666
### 3. Serializers
6767

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 that declare write capability.
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:' && loader.contract.capabilities.write) {
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 that declare write capability
234239
for (const loader of this.loaders.values()) {
235-
// Check if the loader has a delete method
240+
if (loader.contract.protocol !== 'datasource:' || !loader.contract.capabilities.write) 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: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,155 @@ 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 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+
298+
it('should still store in in-memory registry regardless of loaders', async () => {
299+
const m = new MetadataManager({ formats: ['json'], loaders: [] });
300+
await m.register('object', 'account', { name: 'account' });
301+
302+
const result = await m.get('object', 'account');
303+
expect(result).toEqual({ name: 'account' });
304+
});
305+
});
306+
307+
describe('unregister — loader protocol filtering', () => {
308+
it('should delete from datasource: protocol loaders', async () => {
309+
const deleteFn = vi.fn();
310+
const dbLoader: MetadataLoader = {
311+
contract: { name: 'database', protocol: 'datasource:' as const, capabilities: { read: true, write: true, watch: false, list: true } },
312+
load: vi.fn().mockResolvedValue({ data: null }),
313+
loadMany: vi.fn().mockResolvedValue([]),
314+
exists: vi.fn().mockResolvedValue(false),
315+
stat: vi.fn().mockResolvedValue(null),
316+
list: vi.fn().mockResolvedValue([]),
317+
save: vi.fn().mockResolvedValue({ success: true }),
318+
delete: deleteFn,
319+
} as any;
320+
321+
const m = new MetadataManager({ formats: ['json'], loaders: [dbLoader] });
322+
await m.register('object', 'account', { name: 'account' });
323+
await m.unregister('object', 'account');
324+
325+
expect(deleteFn).toHaveBeenCalledWith('object', 'account');
326+
});
327+
328+
it('should NOT delete from file: protocol loaders', async () => {
329+
const deleteFn = vi.fn();
330+
const fsLoader: MetadataLoader = {
331+
contract: { name: 'filesystem', protocol: 'file:' as const, capabilities: { read: true, write: true, watch: true, list: true } },
332+
load: vi.fn().mockResolvedValue({ data: null }),
333+
loadMany: vi.fn().mockResolvedValue([]),
334+
exists: vi.fn().mockResolvedValue(false),
335+
stat: vi.fn().mockResolvedValue(null),
336+
list: vi.fn().mockResolvedValue([]),
337+
save: vi.fn().mockResolvedValue({ success: true }),
338+
delete: deleteFn,
339+
} as any;
340+
341+
const m = new MetadataManager({ formats: ['json'], loaders: [fsLoader] });
342+
await m.register('object', 'account', { name: 'account' });
343+
await m.unregister('object', 'account');
344+
345+
expect(deleteFn).not.toHaveBeenCalled();
346+
});
347+
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+
368+
it('should remove from in-memory registry', async () => {
369+
const m = new MetadataManager({ formats: ['json'], loaders: [] });
370+
await m.register('object', 'account', { name: 'account' });
371+
expect(await m.get('object', 'account')).toEqual({ name: 'account' });
372+
373+
await m.unregister('object', 'account');
374+
expect(await m.get('object', 'account')).toBeUndefined();
375+
});
376+
});
377+
229378
describe('registerLoader', () => {
230379
it('should register a new loader', async () => {
231380
const newLoader = new MemoryLoader();
@@ -340,6 +489,7 @@ describe('MetadataPlugin', () => {
340489
registerLoader = vi.fn();
341490
stopWatching = vi.fn();
342491
setTypeRegistry = vi.fn();
492+
setDatabaseDriver = vi.fn();
343493
register = vi.fn();
344494
};
345495
return { NodeMetadataManager: MockNodeMetadataManager };
@@ -382,6 +532,83 @@ describe('MetadataPlugin', () => {
382532
// start should call logger.info at least once
383533
expect(ctx.logger.info).toHaveBeenCalled();
384534
});
535+
536+
it('should bridge driver service to MetadataManager in start()', async () => {
537+
const { MetadataPlugin } = await import('./plugin.js');
538+
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });
539+
540+
const mockDriver = { name: 'mock-driver', find: vi.fn(), create: vi.fn() };
541+
const services = new Map<string, any>();
542+
services.set('driver.mock-driver', mockDriver);
543+
544+
const ctx = createMockPluginContext();
545+
ctx.getServices = vi.fn().mockReturnValue(services);
546+
547+
await plugin.init(ctx);
548+
await plugin.start(ctx);
549+
550+
// Verify setDatabaseDriver was called on the manager with the driver
551+
const manager = (plugin as any).manager;
552+
expect(manager.setDatabaseDriver).toHaveBeenCalledWith(mockDriver);
553+
});
554+
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+
585+
it('should not fail when no driver service is available', async () => {
586+
const { MetadataPlugin } = await import('./plugin.js');
587+
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });
588+
589+
const ctx = createMockPluginContext();
590+
ctx.getServices = vi.fn().mockReturnValue(new Map());
591+
592+
await plugin.init(ctx);
593+
// Should not throw
594+
await expect(plugin.start(ctx)).resolves.not.toThrow();
595+
596+
// setDatabaseDriver should not have been called
597+
const manager = (plugin as any).manager;
598+
expect(manager.setDatabaseDriver).not.toHaveBeenCalled();
599+
});
600+
601+
it('should gracefully handle getServices errors', async () => {
602+
const { MetadataPlugin } = await import('./plugin.js');
603+
const plugin = new MetadataPlugin({ rootDir: '/tmp/test', watch: false });
604+
605+
const ctx = createMockPluginContext();
606+
ctx.getServices = vi.fn().mockImplementation(() => { throw new Error('services unavailable'); });
607+
608+
await plugin.init(ctx);
609+
// Should not throw even when getServices fails
610+
await expect(plugin.start(ctx)).resolves.not.toThrow();
611+
});
385612
});
386613

387614
// ---------- Helpers ----------

packages/metadata/src/plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,5 +102,27 @@ export class MetadataPlugin implements Plugin {
102102
totalItems: totalLoaded,
103103
registeredTypes: sortedTypes.length,
104104
});
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+
}
105127
}
106128
}

0 commit comments

Comments
 (0)