@objectstack/metadata 2.0.7 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
  * Metadata Manager
5
5
  *
6
6
  * Main orchestrator for metadata loading, saving, and persistence.
7
+ * Implements the IMetadataService contract from @objectstack/spec.
7
8
  * Browser-compatible (Pure).
8
9
  */
9
10
 
@@ -15,6 +16,24 @@ import type {
15
16
  MetadataWatchEvent,
16
17
  MetadataFormat,
17
18
  } from '@objectstack/spec/system';
19
+ import type {
20
+ IMetadataService,
21
+ MetadataWatchCallback,
22
+ MetadataWatchHandle,
23
+ MetadataExportOptions,
24
+ MetadataImportOptions,
25
+ MetadataImportResult,
26
+ MetadataTypeInfo,
27
+ } from '@objectstack/spec/contracts';
28
+ import type {
29
+ MetadataQuery,
30
+ MetadataQueryResult,
31
+ MetadataValidationResult,
32
+ MetadataBulkResult,
33
+ MetadataDependency,
34
+ MetadataTypeRegistryEntry,
35
+ } from '@objectstack/spec/kernel';
36
+ import type { MetadataOverlay } from '@objectstack/spec/kernel';
18
37
  import { createLogger, type Logger } from '@objectstack/core';
19
38
  import { JSONSerializer } from './serializers/json-serializer.js';
20
39
  import { YAMLSerializer } from './serializers/yaml-serializer.js';
@@ -23,7 +42,7 @@ import type { MetadataSerializer } from './serializers/serializer-interface.js';
23
42
  import type { MetadataLoader } from './loaders/loader-interface.js';
24
43
 
25
44
  /**
26
- * Watch callback function
45
+ * Watch callback function (legacy)
27
46
  */
28
47
  export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;
29
48
 
@@ -32,9 +51,10 @@ export interface MetadataManagerOptions extends MetadataManagerConfig {
32
51
  }
33
52
 
34
53
  /**
35
- * Main metadata manager class
54
+ * Main metadata manager class.
55
+ * Implements IMetadataService contract for unified metadata management.
36
56
  */
37
- export class MetadataManager {
57
+ export class MetadataManager implements IMetadataService {
38
58
  private loaders: Map<string, MetadataLoader> = new Map();
39
59
  // Protected so subclasses can access serializers if needed
40
60
  protected serializers: Map<MetadataFormat, MetadataSerializer>;
@@ -42,6 +62,18 @@ export class MetadataManager {
42
62
  protected watchCallbacks = new Map<string, Set<WatchCallback>>();
43
63
  protected config: MetadataManagerOptions;
44
64
 
65
+ // In-memory metadata registry: type -> name -> data
66
+ private registry = new Map<string, Map<string, unknown>>();
67
+
68
+ // Overlay storage: "type:name:scope" -> MetadataOverlay
69
+ private overlays = new Map<string, MetadataOverlay>();
70
+
71
+ // Type registry for metadata type info
72
+ private typeRegistry: MetadataTypeRegistryEntry[] = [];
73
+
74
+ // Dependency tracking: "type:name" -> dependencies
75
+ private dependencies = new Map<string, MetadataDependency[]>();
76
+
45
77
  constructor(config: MetadataManagerOptions) {
46
78
  this.config = config;
47
79
  this.logger = createLogger({ level: 'info', format: 'pretty' });
@@ -70,6 +102,13 @@ export class MetadataManager {
70
102
  // Note: No default loader in base class. Subclasses (NodeMetadataManager) or caller must provide one.
71
103
  }
72
104
 
105
+ /**
106
+ * Set the type registry for metadata type discovery.
107
+ */
108
+ setTypeRegistry(entries: MetadataTypeRegistryEntry[]): void {
109
+ this.typeRegistry = entries;
110
+ }
111
+
73
112
  /**
74
113
  * Register a new metadata loader (data source)
75
114
  */
@@ -78,17 +117,643 @@ export class MetadataManager {
78
117
  this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`);
79
118
  }
80
119
 
120
+ // ==========================================
121
+ // IMetadataService — Core CRUD Operations
122
+ // ==========================================
123
+
124
+ /**
125
+ * Register/save a metadata item by type
126
+ */
127
+ async register(type: string, name: string, data: unknown): Promise<void> {
128
+ if (!this.registry.has(type)) {
129
+ this.registry.set(type, new Map());
130
+ }
131
+ this.registry.get(type)!.set(name, data);
132
+ }
133
+
134
+ /**
135
+ * Get a metadata item by type and name.
136
+ * Checks in-memory registry first, then falls back to loaders.
137
+ */
138
+ async get(type: string, name: string): Promise<unknown | undefined> {
139
+ // Check in-memory registry first
140
+ const typeStore = this.registry.get(type);
141
+ if (typeStore?.has(name)) {
142
+ return typeStore.get(name);
143
+ }
144
+
145
+ // Fallback to loaders
146
+ const result = await this.load(type, name);
147
+ return result ?? undefined;
148
+ }
149
+
150
+ /**
151
+ * List all metadata items of a given type
152
+ */
153
+ async list(type: string): Promise<unknown[]> {
154
+ const items = new Map<string, unknown>();
155
+
156
+ // From in-memory registry
157
+ const typeStore = this.registry.get(type);
158
+ if (typeStore) {
159
+ for (const [name, data] of typeStore) {
160
+ items.set(name, data);
161
+ }
162
+ }
163
+
164
+ // From loaders (deduplicate)
165
+ for (const loader of this.loaders.values()) {
166
+ try {
167
+ const loaderItems = await loader.loadMany(type);
168
+ for (const item of loaderItems) {
169
+ const itemAny = item as any;
170
+ if (itemAny && typeof itemAny.name === 'string' && !items.has(itemAny.name)) {
171
+ items.set(itemAny.name, item);
172
+ }
173
+ }
174
+ } catch (e) {
175
+ this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
176
+ }
177
+ }
178
+
179
+ return Array.from(items.values());
180
+ }
181
+
182
+ /**
183
+ * Unregister/remove a metadata item by type and name
184
+ */
185
+ async unregister(type: string, name: string): Promise<void> {
186
+ const typeStore = this.registry.get(type);
187
+ if (typeStore) {
188
+ typeStore.delete(name);
189
+ if (typeStore.size === 0) {
190
+ this.registry.delete(type);
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Check if a metadata item exists
197
+ */
198
+ async exists(type: string, name: string): Promise<boolean> {
199
+ // Check in-memory registry
200
+ if (this.registry.get(type)?.has(name)) {
201
+ return true;
202
+ }
203
+
204
+ // Check loaders
205
+ for (const loader of this.loaders.values()) {
206
+ if (await loader.exists(type, name)) {
207
+ return true;
208
+ }
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * List all names of metadata items of a given type
215
+ */
216
+ async listNames(type: string): Promise<string[]> {
217
+ const names = new Set<string>();
218
+
219
+ // From in-memory registry
220
+ const typeStore = this.registry.get(type);
221
+ if (typeStore) {
222
+ for (const name of typeStore.keys()) {
223
+ names.add(name);
224
+ }
225
+ }
226
+
227
+ // From loaders
228
+ for (const loader of this.loaders.values()) {
229
+ const result = await loader.list(type);
230
+ result.forEach(item => names.add(item));
231
+ }
232
+
233
+ return Array.from(names);
234
+ }
235
+
236
+ /**
237
+ * Convenience: get an object definition by name
238
+ */
239
+ async getObject(name: string): Promise<unknown | undefined> {
240
+ return this.get('object', name);
241
+ }
242
+
243
+ /**
244
+ * Convenience: list all object definitions
245
+ */
246
+ async listObjects(): Promise<unknown[]> {
247
+ return this.list('object');
248
+ }
249
+
250
+ // ==========================================
251
+ // Package Management
252
+ // ==========================================
253
+
254
+ /**
255
+ * Unregister all metadata items from a specific package
256
+ */
257
+ async unregisterPackage(packageName: string): Promise<void> {
258
+ for (const [type, typeStore] of this.registry) {
259
+ const toDelete: string[] = [];
260
+ for (const [name, data] of typeStore) {
261
+ const meta = data as any;
262
+ if (meta?.packageId === packageName || meta?.package === packageName) {
263
+ toDelete.push(name);
264
+ }
265
+ }
266
+ for (const name of toDelete) {
267
+ typeStore.delete(name);
268
+ }
269
+ if (typeStore.size === 0) {
270
+ this.registry.delete(type);
271
+ }
272
+ }
273
+ }
274
+
275
+ // ==========================================
276
+ // Query / Search
277
+ // ==========================================
278
+
279
+ /**
280
+ * Query metadata items with filtering, sorting, and pagination
281
+ */
282
+ async query(query: MetadataQuery): Promise<MetadataQueryResult> {
283
+ const { types, search, page = 1, pageSize = 50, sortBy = 'name', sortOrder = 'asc' } = query;
284
+
285
+ // Collect all items
286
+ const allItems: Array<{
287
+ type: string;
288
+ name: string;
289
+ namespace?: string;
290
+ label?: string;
291
+ scope?: 'system' | 'platform' | 'user';
292
+ state?: 'draft' | 'active' | 'archived' | 'deprecated';
293
+ packageId?: string;
294
+ updatedAt?: string;
295
+ }> = [];
296
+
297
+ // Determine which types to scan
298
+ const targetTypes = types && types.length > 0
299
+ ? types
300
+ : Array.from(this.registry.keys());
301
+
302
+ for (const type of targetTypes) {
303
+ const items = await this.list(type);
304
+ for (const item of items) {
305
+ const meta = item as any;
306
+ allItems.push({
307
+ type,
308
+ name: meta?.name ?? '',
309
+ namespace: meta?.namespace,
310
+ label: meta?.label,
311
+ scope: meta?.scope,
312
+ state: meta?.state,
313
+ packageId: meta?.packageId,
314
+ updatedAt: meta?.updatedAt,
315
+ });
316
+ }
317
+ }
318
+
319
+ // Apply search filter
320
+ let filtered = allItems;
321
+ if (search) {
322
+ const searchLower = search.toLowerCase();
323
+ filtered = filtered.filter(item =>
324
+ item.name.toLowerCase().includes(searchLower) ||
325
+ (item.label && item.label.toLowerCase().includes(searchLower))
326
+ );
327
+ }
328
+
329
+ // Apply scope filter
330
+ if (query.scope) {
331
+ filtered = filtered.filter(item => item.scope === query.scope);
332
+ }
333
+
334
+ // Apply state filter
335
+ if (query.state) {
336
+ filtered = filtered.filter(item => item.state === query.state);
337
+ }
338
+
339
+ // Apply namespace filter
340
+ if (query.namespaces && query.namespaces.length > 0) {
341
+ filtered = filtered.filter(item => item.namespace && query.namespaces!.includes(item.namespace));
342
+ }
343
+
344
+ // Apply packageId filter
345
+ if (query.packageId) {
346
+ filtered = filtered.filter(item => item.packageId === query.packageId);
347
+ }
348
+
349
+ // Apply tags filter
350
+ if (query.tags && query.tags.length > 0) {
351
+ filtered = filtered.filter(item => {
352
+ const meta = item as any;
353
+ return meta?.tags && query.tags!.some((t: string) => meta.tags.includes(t));
354
+ });
355
+ }
356
+
357
+ // Sort
358
+ filtered.sort((a, b) => {
359
+ const aVal = (a as any)[sortBy] ?? '';
360
+ const bVal = (b as any)[sortBy] ?? '';
361
+ const cmp = String(aVal).localeCompare(String(bVal));
362
+ return sortOrder === 'desc' ? -cmp : cmp;
363
+ });
364
+
365
+ // Paginate
366
+ const total = filtered.length;
367
+ const start = (page - 1) * pageSize;
368
+ const paged = filtered.slice(start, start + pageSize);
369
+
370
+ return {
371
+ items: paged,
372
+ total,
373
+ page,
374
+ pageSize,
375
+ };
376
+ }
377
+
378
+ // ==========================================
379
+ // Bulk Operations
380
+ // ==========================================
381
+
382
+ /**
383
+ * Register multiple metadata items in a single batch
384
+ */
385
+ async bulkRegister(
386
+ items: Array<{ type: string; name: string; data: unknown }>,
387
+ options?: { continueOnError?: boolean; validate?: boolean }
388
+ ): Promise<MetadataBulkResult> {
389
+ const { continueOnError = false } = options ?? {};
390
+ let succeeded = 0;
391
+ let failed = 0;
392
+ const errors: Array<{ type: string; name: string; error: string }> = [];
393
+
394
+ for (const item of items) {
395
+ try {
396
+ await this.register(item.type, item.name, item.data);
397
+ succeeded++;
398
+ } catch (e) {
399
+ failed++;
400
+ errors.push({
401
+ type: item.type,
402
+ name: item.name,
403
+ error: e instanceof Error ? e.message : String(e),
404
+ });
405
+ if (!continueOnError) break;
406
+ }
407
+ }
408
+
409
+ return {
410
+ total: items.length,
411
+ succeeded,
412
+ failed,
413
+ errors: errors.length > 0 ? errors : undefined,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Unregister multiple metadata items in a single batch
419
+ */
420
+ async bulkUnregister(items: Array<{ type: string; name: string }>): Promise<MetadataBulkResult> {
421
+ let succeeded = 0;
422
+ let failed = 0;
423
+ const errors: Array<{ type: string; name: string; error: string }> = [];
424
+
425
+ for (const item of items) {
426
+ try {
427
+ await this.unregister(item.type, item.name);
428
+ succeeded++;
429
+ } catch (e) {
430
+ failed++;
431
+ errors.push({
432
+ type: item.type,
433
+ name: item.name,
434
+ error: e instanceof Error ? e.message : String(e),
435
+ });
436
+ }
437
+ }
438
+
439
+ return {
440
+ total: items.length,
441
+ succeeded,
442
+ failed,
443
+ errors: errors.length > 0 ? errors : undefined,
444
+ };
445
+ }
446
+
447
+ // ==========================================
448
+ // Overlay / Customization Management
449
+ // ==========================================
450
+
451
+ private overlayKey(type: string, name: string, scope: string = 'platform'): string {
452
+ return `${encodeURIComponent(type)}:${encodeURIComponent(name)}:${scope}`;
453
+ }
454
+
455
+ /**
456
+ * Get the active overlay for a metadata item
457
+ */
458
+ async getOverlay(type: string, name: string, scope?: 'platform' | 'user'): Promise<MetadataOverlay | undefined> {
459
+ return this.overlays.get(this.overlayKey(type, name, scope ?? 'platform'));
460
+ }
461
+
462
+ /**
463
+ * Save/update an overlay for a metadata item
464
+ */
465
+ async saveOverlay(overlay: MetadataOverlay): Promise<void> {
466
+ const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
467
+ this.overlays.set(key, overlay);
468
+ }
469
+
470
+ /**
471
+ * Remove an overlay, reverting to the base definition
472
+ */
473
+ async removeOverlay(type: string, name: string, scope?: 'platform' | 'user'): Promise<void> {
474
+ this.overlays.delete(this.overlayKey(type, name, scope ?? 'platform'));
475
+ }
476
+
477
+ /**
478
+ * Get the effective (merged) metadata after applying all overlays.
479
+ * Resolution order: system ← merge(platform) ← merge(user)
480
+ */
481
+ async getEffective(type: string, name: string): Promise<unknown | undefined> {
482
+ const base = await this.get(type, name);
483
+ if (!base) return undefined;
484
+
485
+ let effective = { ...(base as Record<string, unknown>) };
486
+
487
+ // Apply platform overlay
488
+ const platformOverlay = await this.getOverlay(type, name, 'platform');
489
+ if (platformOverlay?.active && platformOverlay.patch) {
490
+ effective = { ...effective, ...platformOverlay.patch };
491
+ }
492
+
493
+ // Apply user overlay
494
+ const userOverlay = await this.getOverlay(type, name, 'user');
495
+ if (userOverlay?.active && userOverlay.patch) {
496
+ effective = { ...effective, ...userOverlay.patch };
497
+ }
498
+
499
+ return effective;
500
+ }
501
+
502
+ // ==========================================
503
+ // Watch / Subscribe (IMetadataService)
504
+ // ==========================================
505
+
506
+ /**
507
+ * Watch for metadata changes (IMetadataService contract).
508
+ * Returns a handle for unsubscribing.
509
+ */
510
+ watchService(type: string, callback: MetadataWatchCallback): MetadataWatchHandle {
511
+ const wrappedCallback: WatchCallback = (event) => {
512
+ const mappedType = event.type === 'added' ? 'registered'
513
+ : event.type === 'deleted' ? 'unregistered'
514
+ : 'updated';
515
+ callback({
516
+ type: mappedType,
517
+ metadataType: event.metadataType ?? type,
518
+ name: event.name ?? '',
519
+ data: event.data,
520
+ });
521
+ };
522
+ this.addWatchCallback(type, wrappedCallback);
523
+ return {
524
+ unsubscribe: () => this.removeWatchCallback(type, wrappedCallback),
525
+ };
526
+ }
527
+
528
+ // ==========================================
529
+ // Import / Export
530
+ // ==========================================
531
+
532
+ /**
533
+ * Export metadata as a portable bundle
534
+ */
535
+ async exportMetadata(options?: MetadataExportOptions): Promise<unknown> {
536
+ const bundle: Record<string, unknown[]> = {};
537
+ const targetTypes = options?.types ?? Array.from(this.registry.keys());
538
+
539
+ for (const type of targetTypes) {
540
+ const items = await this.list(type);
541
+ if (items.length > 0) {
542
+ bundle[type] = items;
543
+ }
544
+ }
545
+
546
+ return bundle;
547
+ }
548
+
81
549
  /**
82
- * Load a single metadata item
83
- * Iterates through registered loaders until found
550
+ * Import metadata from a portable bundle
551
+ */
552
+ async importMetadata(data: unknown, options?: MetadataImportOptions): Promise<MetadataImportResult> {
553
+ const {
554
+ conflictResolution = 'skip',
555
+ validate: _validate = true,
556
+ dryRun = false,
557
+ } = options ?? {};
558
+
559
+ const bundle = data as Record<string, unknown[]>;
560
+ let total = 0;
561
+ let imported = 0;
562
+ let skipped = 0;
563
+ let failed = 0;
564
+ const errors: Array<{ type: string; name: string; error: string }> = [];
565
+
566
+ for (const [type, items] of Object.entries(bundle)) {
567
+ if (!Array.isArray(items)) continue;
568
+
569
+ for (const item of items) {
570
+ total++;
571
+ const meta = item as any;
572
+ const name = meta?.name;
573
+
574
+ if (!name) {
575
+ failed++;
576
+ errors.push({ type, name: '(unknown)', error: 'Item missing name field' });
577
+ continue;
578
+ }
579
+
580
+ try {
581
+ const itemExists = await this.exists(type, name);
582
+
583
+ if (itemExists && conflictResolution === 'skip') {
584
+ skipped++;
585
+ continue;
586
+ }
587
+
588
+ if (!dryRun) {
589
+ if (itemExists && conflictResolution === 'merge') {
590
+ const existing = await this.get(type, name);
591
+ const merged = { ...(existing as any), ...(item as any) };
592
+ await this.register(type, name, merged);
593
+ } else {
594
+ await this.register(type, name, item);
595
+ }
596
+ }
597
+ imported++;
598
+ } catch (e) {
599
+ failed++;
600
+ errors.push({
601
+ type,
602
+ name,
603
+ error: e instanceof Error ? e.message : String(e),
604
+ });
605
+ }
606
+ }
607
+ }
608
+
609
+ return {
610
+ total,
611
+ imported,
612
+ skipped,
613
+ failed,
614
+ errors: errors.length > 0 ? errors : undefined,
615
+ };
616
+ }
617
+
618
+ // ==========================================
619
+ // Validation
620
+ // ==========================================
621
+
622
+ /**
623
+ * Validate a metadata item against its type schema.
624
+ * Returns validation result with errors and warnings.
625
+ */
626
+ async validate(_type: string, data: unknown): Promise<MetadataValidationResult> {
627
+ // Basic structural validation
628
+ if (data === null || data === undefined) {
629
+ return {
630
+ valid: false,
631
+ errors: [{ path: '', message: 'Metadata data cannot be null or undefined' }],
632
+ };
633
+ }
634
+
635
+ if (typeof data !== 'object') {
636
+ return {
637
+ valid: false,
638
+ errors: [{ path: '', message: 'Metadata data must be an object' }],
639
+ };
640
+ }
641
+
642
+ const meta = data as any;
643
+ const warnings: Array<{ path: string; message: string }> = [];
644
+
645
+ if (!meta.name) {
646
+ return {
647
+ valid: false,
648
+ errors: [{ path: 'name', message: 'Metadata item must have a name field' }],
649
+ };
650
+ }
651
+
652
+ if (!meta.label) {
653
+ warnings.push({ path: 'label', message: 'Missing label field (recommended)' });
654
+ }
655
+
656
+ return { valid: true, warnings: warnings.length > 0 ? warnings : undefined };
657
+ }
658
+
659
+ // ==========================================
660
+ // Type Registry
661
+ // ==========================================
662
+
663
+ /**
664
+ * Get all registered metadata types
665
+ */
666
+ async getRegisteredTypes(): Promise<string[]> {
667
+ const types = new Set<string>();
668
+
669
+ // From type registry
670
+ for (const entry of this.typeRegistry) {
671
+ types.add(entry.type);
672
+ }
673
+
674
+ // From in-memory registry (custom types)
675
+ for (const type of this.registry.keys()) {
676
+ types.add(type);
677
+ }
678
+
679
+ return Array.from(types);
680
+ }
681
+
682
+ /**
683
+ * Get detailed information about a metadata type
684
+ */
685
+ async getTypeInfo(type: string): Promise<MetadataTypeInfo | undefined> {
686
+ const entry = this.typeRegistry.find(e => e.type === type);
687
+ if (!entry) return undefined;
688
+
689
+ return {
690
+ type: entry.type,
691
+ label: entry.label,
692
+ description: entry.description,
693
+ filePatterns: entry.filePatterns,
694
+ supportsOverlay: entry.supportsOverlay,
695
+ domain: entry.domain,
696
+ };
697
+ }
698
+
699
+ // ==========================================
700
+ // Dependency Tracking
701
+ // ==========================================
702
+
703
+ /**
704
+ * Get metadata items that this item depends on
705
+ */
706
+ async getDependencies(type: string, name: string): Promise<MetadataDependency[]> {
707
+ return this.dependencies.get(`${encodeURIComponent(type)}:${encodeURIComponent(name)}`) ?? [];
708
+ }
709
+
710
+ /**
711
+ * Get metadata items that depend on this item
712
+ */
713
+ async getDependents(type: string, name: string): Promise<MetadataDependency[]> {
714
+ const dependents: MetadataDependency[] = [];
715
+ for (const deps of this.dependencies.values()) {
716
+ for (const dep of deps) {
717
+ if (dep.targetType === type && dep.targetName === name) {
718
+ dependents.push(dep);
719
+ }
720
+ }
721
+ }
722
+ return dependents;
723
+ }
724
+
725
+ /**
726
+ * Register a dependency between two metadata items.
727
+ * Used internally to track cross-references.
728
+ * Duplicate dependencies (same source, target, and kind) are ignored.
729
+ */
730
+ addDependency(dep: MetadataDependency): void {
731
+ const key = `${encodeURIComponent(dep.sourceType)}:${encodeURIComponent(dep.sourceName)}`;
732
+ if (!this.dependencies.has(key)) {
733
+ this.dependencies.set(key, []);
734
+ }
735
+ const existing = this.dependencies.get(key)!;
736
+ const isDuplicate = existing.some(
737
+ d => d.targetType === dep.targetType && d.targetName === dep.targetName && d.kind === dep.kind
738
+ );
739
+ if (!isDuplicate) {
740
+ existing.push(dep);
741
+ }
742
+ }
743
+
744
+ // ==========================================
745
+ // Legacy Loader API (backward compatible)
746
+ // ==========================================
747
+
748
+ /**
749
+ * Load a single metadata item from loaders.
750
+ * Iterates through registered loaders until found.
84
751
  */
85
752
  async load<T = any>(
86
753
  type: string,
87
754
  name: string,
88
755
  options?: MetadataLoadOptions
89
756
  ): Promise<T | null> {
90
- // Priority: Database > Filesystem (Implementation-dependent)
91
- // For now, we just iterate.
92
757
  for (const loader of this.loaders.values()) {
93
758
  try {
94
759
  const result = await loader.load(type, name, options);
@@ -103,8 +768,8 @@ export class MetadataManager {
103
768
  }
104
769
 
105
770
  /**
106
- * Load multiple metadata items
107
- * Aggregates results from all loaders
771
+ * Load multiple metadata items from loaders.
772
+ * Aggregates results from all loaders.
108
773
  */
109
774
  async loadMany<T = any>(
110
775
  type: string,
@@ -116,7 +781,6 @@ export class MetadataManager {
116
781
  try {
117
782
  const items = await loader.loadMany<T>(type, options);
118
783
  for (const item of items) {
119
- // Deduplicate: skip items whose 'name' already exists in results
120
784
  const itemAny = item as any;
121
785
  if (itemAny && typeof itemAny.name === 'string') {
122
786
  const exists = results.some((r: any) => r && r.name === itemAny.name);
@@ -132,10 +796,7 @@ export class MetadataManager {
132
796
  }
133
797
 
134
798
  /**
135
- * Save metadata to disk
136
- */
137
- /**
138
- * Save metadata item
799
+ * Save metadata item to a loader
139
800
  */
140
801
  async save<T = any>(
141
802
  type: string,
@@ -145,7 +806,6 @@ export class MetadataManager {
145
806
  ): Promise<MetadataSaveResult> {
146
807
  const targetLoader = (options as any)?.loader;
147
808
 
148
- // Find suitable loader
149
809
  let loader: MetadataLoader | undefined;
150
810
 
151
811
  if (targetLoader) {
@@ -154,11 +814,8 @@ export class MetadataManager {
154
814
  throw new Error(`Loader not found: ${targetLoader}`);
155
815
  }
156
816
  } else {
157
- // 1. Try to find existing writable loader containing this item (Update existing)
158
817
  for (const l of this.loaders.values()) {
159
- // Skip if loader is strictly read-only
160
818
  if (!l.save) continue;
161
-
162
819
  try {
163
820
  if (await l.exists(type, name)) {
164
821
  loader = l;
@@ -166,11 +823,10 @@ export class MetadataManager {
166
823
  break;
167
824
  }
168
825
  } catch (e) {
169
- // Ignore existence check errors (e.g. network down)
826
+ // Ignore existence check errors
170
827
  }
171
828
  }
172
829
 
173
- // 2. Default to 'filesystem' if available (Create new)
174
830
  if (!loader) {
175
831
  const fsLoader = this.loaders.get('filesystem');
176
832
  if (fsLoader && fsLoader.save) {
@@ -178,7 +834,6 @@ export class MetadataManager {
178
834
  }
179
835
  }
180
836
 
181
- // 3. Fallback to any writable loader
182
837
  if (!loader) {
183
838
  for (const l of this.loaders.values()) {
184
839
  if (l.save) {
@@ -201,33 +856,9 @@ export class MetadataManager {
201
856
  }
202
857
 
203
858
  /**
204
- * Check if metadata item exists
205
- */
206
- async exists(type: string, name: string): Promise<boolean> {
207
- for (const loader of this.loaders.values()) {
208
- if (await loader.exists(type, name)) {
209
- return true;
210
- }
211
- }
212
- return false;
213
- }
214
-
215
- /**
216
- * List all items of a type
217
- */
218
- async list(type: string): Promise<string[]> {
219
- const items = new Set<string>();
220
- for (const loader of this.loaders.values()) {
221
- const result = await loader.list(type);
222
- result.forEach(item => items.add(item));
223
- }
224
- return Array.from(items);
225
- }
226
-
227
- /**
228
- * Watch for metadata changes
859
+ * Register a watch callback for metadata changes
229
860
  */
230
- watch(type: string, callback: WatchCallback): void {
861
+ protected addWatchCallback(type: string, callback: WatchCallback): void {
231
862
  if (!this.watchCallbacks.has(type)) {
232
863
  this.watchCallbacks.set(type, new Set());
233
864
  }
@@ -235,9 +866,9 @@ export class MetadataManager {
235
866
  }
236
867
 
237
868
  /**
238
- * Unwatch metadata changes
869
+ * Remove a watch callback for metadata changes
239
870
  */
240
- unwatch(type: string, callback: WatchCallback): void {
871
+ protected removeWatchCallback(type: string, callback: WatchCallback): void {
241
872
  const callbacks = this.watchCallbacks.get(type);
242
873
  if (callbacks) {
243
874
  callbacks.delete(callback);