@objectstack/metadata 3.3.0 → 4.0.0

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.
Files changed (38) hide show
  1. package/dist/index.cjs +2197 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.js +42 -82
  4. package/dist/index.js.map +1 -1
  5. package/dist/node.cjs +2201 -0
  6. package/dist/node.cjs.map +1 -0
  7. package/dist/node.d.cts +65 -0
  8. package/dist/node.d.ts +65 -0
  9. package/dist/{index.mjs → node.js} +3 -1
  10. package/package.json +22 -17
  11. package/.turbo/turbo-build.log +0 -22
  12. package/CHANGELOG.md +0 -504
  13. package/ROADMAP.md +0 -224
  14. package/src/index.ts +0 -68
  15. package/src/loaders/database-loader.test.ts +0 -559
  16. package/src/loaders/database-loader.ts +0 -352
  17. package/src/loaders/filesystem-loader.ts +0 -420
  18. package/src/loaders/loader-interface.ts +0 -89
  19. package/src/loaders/memory-loader.ts +0 -103
  20. package/src/loaders/remote-loader.ts +0 -140
  21. package/src/metadata-manager.ts +0 -1168
  22. package/src/metadata-service.test.ts +0 -965
  23. package/src/metadata.test.ts +0 -431
  24. package/src/migration/executor.ts +0 -54
  25. package/src/migration/index.ts +0 -3
  26. package/src/node-metadata-manager.ts +0 -126
  27. package/src/node.ts +0 -11
  28. package/src/objects/sys-metadata.object.ts +0 -188
  29. package/src/plugin.ts +0 -102
  30. package/src/serializers/json-serializer.ts +0 -73
  31. package/src/serializers/serializer-interface.ts +0 -65
  32. package/src/serializers/serializers.test.ts +0 -74
  33. package/src/serializers/typescript-serializer.ts +0 -127
  34. package/src/serializers/yaml-serializer.ts +0 -49
  35. package/tsconfig.json +0 -9
  36. package/vitest.config.ts +0 -23
  37. /package/dist/{index.d.mts → index.d.cts} +0 -0
  38. /package/dist/{index.mjs.map → node.js.map} +0 -0
@@ -1,1168 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Metadata Manager
5
- *
6
- * Main orchestrator for metadata loading, saving, and persistence.
7
- * Implements the IMetadataService contract from @objectstack/spec.
8
- * Browser-compatible (Pure).
9
- */
10
-
11
- import type {
12
- MetadataManagerConfig,
13
- MetadataLoadOptions,
14
- MetadataSaveOptions,
15
- MetadataSaveResult,
16
- MetadataWatchEvent,
17
- MetadataFormat,
18
- PackagePublishResult,
19
- } from '@objectstack/spec/system';
20
- import type {
21
- IMetadataService,
22
- MetadataWatchCallback,
23
- MetadataWatchHandle,
24
- MetadataExportOptions,
25
- MetadataImportOptions,
26
- MetadataImportResult,
27
- MetadataTypeInfo,
28
- } from '@objectstack/spec/contracts';
29
- import type {
30
- MetadataQuery,
31
- MetadataQueryResult,
32
- MetadataValidationResult,
33
- MetadataBulkResult,
34
- MetadataDependency,
35
- MetadataTypeRegistryEntry,
36
- } from '@objectstack/spec/kernel';
37
- import type { MetadataOverlay } from '@objectstack/spec/kernel';
38
- import { createLogger, type Logger } from '@objectstack/core';
39
- import { JSONSerializer } from './serializers/json-serializer.js';
40
- import { YAMLSerializer } from './serializers/yaml-serializer.js';
41
- import { TypeScriptSerializer } from './serializers/typescript-serializer.js';
42
- import type { MetadataSerializer } from './serializers/serializer-interface.js';
43
- import type { IDataDriver } from '@objectstack/spec/contracts';
44
- import type { MetadataLoader } from './loaders/loader-interface.js';
45
- import { DatabaseLoader } from './loaders/database-loader.js';
46
-
47
- /**
48
- * Watch callback function (legacy)
49
- */
50
- export type WatchCallback = (event: MetadataWatchEvent) => void | Promise<void>;
51
-
52
- export interface MetadataManagerOptions extends MetadataManagerConfig {
53
- loaders?: MetadataLoader[];
54
- /** Optional IDataDriver instance. When provided alongside config.datasource, auto-configures DatabaseLoader. */
55
- driver?: IDataDriver;
56
- }
57
-
58
- /**
59
- * Main metadata manager class.
60
- * Implements IMetadataService contract for unified metadata management.
61
- */
62
- export class MetadataManager implements IMetadataService {
63
- private loaders: Map<string, MetadataLoader> = new Map();
64
- // Protected so subclasses can access serializers if needed
65
- protected serializers: Map<MetadataFormat, MetadataSerializer>;
66
- protected logger: Logger;
67
- protected watchCallbacks = new Map<string, Set<WatchCallback>>();
68
- protected config: MetadataManagerOptions;
69
-
70
- // In-memory metadata registry: type -> name -> data
71
- private registry = new Map<string, Map<string, unknown>>();
72
-
73
- // Overlay storage: "type:name:scope" -> MetadataOverlay
74
- private overlays = new Map<string, MetadataOverlay>();
75
-
76
- // Type registry for metadata type info
77
- private typeRegistry: MetadataTypeRegistryEntry[] = [];
78
-
79
- // Dependency tracking: "type:name" -> dependencies
80
- private dependencies = new Map<string, MetadataDependency[]>();
81
-
82
- constructor(config: MetadataManagerOptions) {
83
- this.config = config;
84
- this.logger = createLogger({ level: 'info', format: 'pretty' });
85
-
86
- // Initialize serializers
87
- this.serializers = new Map();
88
- const formats = config.formats || ['typescript', 'json', 'yaml'];
89
-
90
- if (formats.includes('json')) {
91
- this.serializers.set('json', new JSONSerializer());
92
- }
93
- if (formats.includes('yaml')) {
94
- this.serializers.set('yaml', new YAMLSerializer());
95
- }
96
- if (formats.includes('typescript')) {
97
- this.serializers.set('typescript', new TypeScriptSerializer('typescript'));
98
- }
99
- if (formats.includes('javascript')) {
100
- this.serializers.set('javascript', new TypeScriptSerializer('javascript'));
101
- }
102
-
103
- // Initialize Loaders
104
- if (config.loaders && config.loaders.length > 0) {
105
- config.loaders.forEach(loader => this.registerLoader(loader));
106
- }
107
-
108
- // Auto-configure DatabaseLoader when datasource + driver are provided
109
- if (config.datasource && config.driver) {
110
- this.setDatabaseDriver(config.driver);
111
- }
112
- // Note: No default loader in base class. Subclasses (NodeMetadataManager) or caller must provide one.
113
- }
114
-
115
- /**
116
- * Set the type registry for metadata type discovery.
117
- */
118
- setTypeRegistry(entries: MetadataTypeRegistryEntry[]): void {
119
- this.typeRegistry = entries;
120
- }
121
-
122
- /**
123
- * Configure and register a DatabaseLoader for database-backed metadata persistence.
124
- * Can be called at any time to enable database storage (e.g. after kernel resolves the driver).
125
- *
126
- * @param driver - An IDataDriver instance for database operations
127
- */
128
- setDatabaseDriver(driver: IDataDriver): void {
129
- const tableName = this.config.tableName ?? 'sys_metadata';
130
- const dbLoader = new DatabaseLoader({
131
- driver,
132
- tableName,
133
- });
134
- this.registerLoader(dbLoader);
135
- this.logger.info('DatabaseLoader configured', { datasource: this.config.datasource, tableName });
136
- }
137
-
138
- /**
139
- * Register a new metadata loader (data source)
140
- */
141
- registerLoader(loader: MetadataLoader) {
142
- this.loaders.set(loader.contract.name, loader);
143
- this.logger.info(`Registered metadata loader: ${loader.contract.name} (${loader.contract.protocol})`);
144
- }
145
-
146
- // ==========================================
147
- // IMetadataService — Core CRUD Operations
148
- // ==========================================
149
-
150
- /**
151
- * Register/save a metadata item by type
152
- */
153
- async register(type: string, name: string, data: unknown): Promise<void> {
154
- if (!this.registry.has(type)) {
155
- this.registry.set(type, new Map());
156
- }
157
- this.registry.get(type)!.set(name, data);
158
- }
159
-
160
- /**
161
- * Get a metadata item by type and name.
162
- * Checks in-memory registry first, then falls back to loaders.
163
- */
164
- async get(type: string, name: string): Promise<unknown | undefined> {
165
- // Check in-memory registry first
166
- const typeStore = this.registry.get(type);
167
- if (typeStore?.has(name)) {
168
- return typeStore.get(name);
169
- }
170
-
171
- // Fallback to loaders
172
- const result = await this.load(type, name);
173
- return result ?? undefined;
174
- }
175
-
176
- /**
177
- * List all metadata items of a given type
178
- */
179
- async list(type: string): Promise<unknown[]> {
180
- const items = new Map<string, unknown>();
181
-
182
- // From in-memory registry
183
- const typeStore = this.registry.get(type);
184
- if (typeStore) {
185
- for (const [name, data] of typeStore) {
186
- items.set(name, data);
187
- }
188
- }
189
-
190
- // From loaders (deduplicate)
191
- for (const loader of this.loaders.values()) {
192
- try {
193
- const loaderItems = await loader.loadMany(type);
194
- for (const item of loaderItems) {
195
- const itemAny = item as any;
196
- if (itemAny && typeof itemAny.name === 'string' && !items.has(itemAny.name)) {
197
- items.set(itemAny.name, item);
198
- }
199
- }
200
- } catch (e) {
201
- this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
202
- }
203
- }
204
-
205
- return Array.from(items.values());
206
- }
207
-
208
- /**
209
- * Unregister/remove a metadata item by type and name
210
- */
211
- async unregister(type: string, name: string): Promise<void> {
212
- const typeStore = this.registry.get(type);
213
- if (typeStore) {
214
- typeStore.delete(name);
215
- if (typeStore.size === 0) {
216
- this.registry.delete(type);
217
- }
218
- }
219
- }
220
-
221
- /**
222
- * Check if a metadata item exists
223
- */
224
- async exists(type: string, name: string): Promise<boolean> {
225
- // Check in-memory registry
226
- if (this.registry.get(type)?.has(name)) {
227
- return true;
228
- }
229
-
230
- // Check loaders
231
- for (const loader of this.loaders.values()) {
232
- if (await loader.exists(type, name)) {
233
- return true;
234
- }
235
- }
236
- return false;
237
- }
238
-
239
- /**
240
- * List all names of metadata items of a given type
241
- */
242
- async listNames(type: string): Promise<string[]> {
243
- const names = new Set<string>();
244
-
245
- // From in-memory registry
246
- const typeStore = this.registry.get(type);
247
- if (typeStore) {
248
- for (const name of typeStore.keys()) {
249
- names.add(name);
250
- }
251
- }
252
-
253
- // From loaders
254
- for (const loader of this.loaders.values()) {
255
- const result = await loader.list(type);
256
- result.forEach(item => names.add(item));
257
- }
258
-
259
- return Array.from(names);
260
- }
261
-
262
- /**
263
- * Convenience: get an object definition by name
264
- */
265
- async getObject(name: string): Promise<unknown | undefined> {
266
- return this.get('object', name);
267
- }
268
-
269
- /**
270
- * Convenience: list all object definitions
271
- */
272
- async listObjects(): Promise<unknown[]> {
273
- return this.list('object');
274
- }
275
-
276
- // ==========================================
277
- // Convenience: UI Metadata
278
- // ==========================================
279
-
280
- /**
281
- * Convenience: get a view definition by name
282
- */
283
- async getView(name: string): Promise<unknown | undefined> {
284
- return this.get('view', name);
285
- }
286
-
287
- /**
288
- * Convenience: list view definitions, optionally filtered by object
289
- */
290
- async listViews(object?: string): Promise<unknown[]> {
291
- const views = await this.list('view');
292
- if (object) {
293
- return views.filter((v: any) => v?.object === object);
294
- }
295
- return views;
296
- }
297
-
298
- /**
299
- * Convenience: get a dashboard definition by name
300
- */
301
- async getDashboard(name: string): Promise<unknown | undefined> {
302
- return this.get('dashboard', name);
303
- }
304
-
305
- /**
306
- * Convenience: list all dashboard definitions
307
- */
308
- async listDashboards(): Promise<unknown[]> {
309
- return this.list('dashboard');
310
- }
311
-
312
- // ==========================================
313
- // Package Management
314
- // ==========================================
315
-
316
- /**
317
- * Unregister all metadata items from a specific package
318
- */
319
- async unregisterPackage(packageName: string): Promise<void> {
320
- for (const [type, typeStore] of this.registry) {
321
- const toDelete: string[] = [];
322
- for (const [name, data] of typeStore) {
323
- const meta = data as any;
324
- if (meta?.packageId === packageName || meta?.package === packageName) {
325
- toDelete.push(name);
326
- }
327
- }
328
- for (const name of toDelete) {
329
- typeStore.delete(name);
330
- }
331
- if (typeStore.size === 0) {
332
- this.registry.delete(type);
333
- }
334
- }
335
- }
336
-
337
- /**
338
- * Publish an entire package:
339
- * 1. Validate all draft items
340
- * 2. Snapshot all items in the package (publishedDefinition = clone(metadata))
341
- * 3. Increment version
342
- * 4. Set all items state → active
343
- */
344
- async publishPackage(packageId: string, options?: {
345
- changeNote?: string;
346
- publishedBy?: string;
347
- validate?: boolean;
348
- }): Promise<PackagePublishResult> {
349
- const now = new Date().toISOString();
350
- const shouldValidate = options?.validate !== false;
351
- const publishedBy = options?.publishedBy;
352
-
353
- // Collect all items belonging to this package
354
- const packageItems: Array<{ type: string; name: string; data: any }> = [];
355
- for (const [type, typeStore] of this.registry) {
356
- for (const [name, data] of typeStore) {
357
- const meta = data as any;
358
- if (meta?.packageId === packageId || meta?.package === packageId) {
359
- packageItems.push({ type, name, data: meta });
360
- }
361
- }
362
- }
363
-
364
- if (packageItems.length === 0) {
365
- return {
366
- success: false,
367
- packageId,
368
- version: 0,
369
- publishedAt: now,
370
- itemsPublished: 0,
371
- validationErrors: [{ type: '', name: '', message: `No metadata items found for package '${packageId}'` }],
372
- };
373
- }
374
-
375
- // Validation pass
376
- if (shouldValidate) {
377
- const validationErrors: Array<{ type: string; name: string; message: string }> = [];
378
-
379
- // Schema validation
380
- for (const item of packageItems) {
381
- const result = await this.validate(item.type, item.data);
382
- if (!result.valid && result.errors) {
383
- for (const err of result.errors) {
384
- validationErrors.push({
385
- type: item.type,
386
- name: item.name,
387
- message: err.message,
388
- });
389
- }
390
- }
391
- }
392
-
393
- // Dependency validation: referenced items must be in the same package or already published
394
- const packageItemKeys = new Set(packageItems.map(i => `${i.type}:${i.name}`));
395
- for (const item of packageItems) {
396
- const deps = await this.getDependencies(item.type, item.name);
397
- for (const dep of deps) {
398
- const depKey = `${dep.targetType}:${dep.targetName}`;
399
- // Skip if the dependency is within this package
400
- if (packageItemKeys.has(depKey)) continue;
401
- // Check if the dependency exists and has been published
402
- const depItem = await this.get(dep.targetType, dep.targetName);
403
- if (!depItem) {
404
- validationErrors.push({
405
- type: item.type,
406
- name: item.name,
407
- message: `Dependency '${dep.targetType}:${dep.targetName}' not found`,
408
- });
409
- } else {
410
- const depMeta = depItem as any;
411
- if (depMeta.publishedDefinition === undefined && depMeta.state !== 'active') {
412
- validationErrors.push({
413
- type: item.type,
414
- name: item.name,
415
- message: `Dependency '${dep.targetType}:${dep.targetName}' is not published`,
416
- });
417
- }
418
- }
419
- }
420
- }
421
-
422
- if (validationErrors.length > 0) {
423
- return {
424
- success: false,
425
- packageId,
426
- version: 0,
427
- publishedAt: now,
428
- itemsPublished: 0,
429
- validationErrors,
430
- };
431
- }
432
- }
433
-
434
- // Determine the next version by finding the max current version across items
435
- let maxVersion = 0;
436
- for (const item of packageItems) {
437
- const v = typeof item.data.version === 'number' ? item.data.version : 0;
438
- if (v > maxVersion) maxVersion = v;
439
- }
440
- const newVersion = maxVersion + 1;
441
-
442
- // Snapshot and update all items
443
- for (const item of packageItems) {
444
- const updated = {
445
- ...item.data,
446
- publishedDefinition: structuredClone(item.data.metadata ?? item.data),
447
- publishedAt: now,
448
- publishedBy: publishedBy ?? item.data.publishedBy,
449
- version: newVersion,
450
- state: 'active',
451
- };
452
- await this.register(item.type, item.name, updated);
453
- }
454
-
455
- return {
456
- success: true,
457
- packageId,
458
- version: newVersion,
459
- publishedAt: now,
460
- itemsPublished: packageItems.length,
461
- };
462
- }
463
-
464
- /**
465
- * Revert entire package to last published state.
466
- * Restores all metadata definitions from their published snapshots.
467
- */
468
- async revertPackage(packageId: string): Promise<void> {
469
- const packageItems: Array<{ type: string; name: string; data: any }> = [];
470
- for (const [type, typeStore] of this.registry) {
471
- for (const [name, data] of typeStore) {
472
- const meta = data as any;
473
- if (meta?.packageId === packageId || meta?.package === packageId) {
474
- packageItems.push({ type, name, data: meta });
475
- }
476
- }
477
- }
478
-
479
- if (packageItems.length === 0) {
480
- throw new Error(`No metadata items found for package '${packageId}'`);
481
- }
482
-
483
- // Check that at least one item has a published snapshot
484
- const hasPublished = packageItems.some(item => item.data.publishedDefinition !== undefined);
485
- if (!hasPublished) {
486
- throw new Error(`Package '${packageId}' has never been published`);
487
- }
488
-
489
- for (const item of packageItems) {
490
- if (item.data.publishedDefinition !== undefined) {
491
- const reverted = {
492
- ...item.data,
493
- metadata: structuredClone(item.data.publishedDefinition),
494
- state: 'active',
495
- };
496
- await this.register(item.type, item.name, reverted);
497
- }
498
- }
499
- }
500
-
501
- /**
502
- * Get the published version of any metadata item (for runtime serving).
503
- * Returns publishedDefinition if exists, else current definition.
504
- */
505
- async getPublished(type: string, name: string): Promise<unknown | undefined> {
506
- const item = await this.get(type, name);
507
- if (!item) return undefined;
508
-
509
- const meta = item as any;
510
- if (meta.publishedDefinition !== undefined) {
511
- return meta.publishedDefinition;
512
- }
513
-
514
- // Fall back to current definition (metadata field or the item itself)
515
- return meta.metadata ?? item;
516
- }
517
-
518
- // ==========================================
519
- // Query / Search
520
- // ==========================================
521
-
522
- /**
523
- * Query metadata items with filtering, sorting, and pagination
524
- */
525
- async query(query: MetadataQuery): Promise<MetadataQueryResult> {
526
- const { types, search, page = 1, pageSize = 50, sortBy = 'name', sortOrder = 'asc' } = query;
527
-
528
- // Collect all items
529
- const allItems: Array<{
530
- type: string;
531
- name: string;
532
- namespace?: string;
533
- label?: string;
534
- scope?: 'system' | 'platform' | 'user';
535
- state?: 'draft' | 'active' | 'archived' | 'deprecated';
536
- packageId?: string;
537
- updatedAt?: string;
538
- }> = [];
539
-
540
- // Determine which types to scan
541
- const targetTypes = types && types.length > 0
542
- ? types
543
- : Array.from(this.registry.keys());
544
-
545
- for (const type of targetTypes) {
546
- const items = await this.list(type);
547
- for (const item of items) {
548
- const meta = item as any;
549
- allItems.push({
550
- type,
551
- name: meta?.name ?? '',
552
- namespace: meta?.namespace,
553
- label: meta?.label,
554
- scope: meta?.scope,
555
- state: meta?.state,
556
- packageId: meta?.packageId,
557
- updatedAt: meta?.updatedAt,
558
- });
559
- }
560
- }
561
-
562
- // Apply search filter
563
- let filtered = allItems;
564
- if (search) {
565
- const searchLower = search.toLowerCase();
566
- filtered = filtered.filter(item =>
567
- item.name.toLowerCase().includes(searchLower) ||
568
- (item.label && item.label.toLowerCase().includes(searchLower))
569
- );
570
- }
571
-
572
- // Apply scope filter
573
- if (query.scope) {
574
- filtered = filtered.filter(item => item.scope === query.scope);
575
- }
576
-
577
- // Apply state filter
578
- if (query.state) {
579
- filtered = filtered.filter(item => item.state === query.state);
580
- }
581
-
582
- // Apply namespace filter
583
- if (query.namespaces && query.namespaces.length > 0) {
584
- filtered = filtered.filter(item => item.namespace && query.namespaces!.includes(item.namespace));
585
- }
586
-
587
- // Apply packageId filter
588
- if (query.packageId) {
589
- filtered = filtered.filter(item => item.packageId === query.packageId);
590
- }
591
-
592
- // Apply tags filter
593
- if (query.tags && query.tags.length > 0) {
594
- filtered = filtered.filter(item => {
595
- const meta = item as any;
596
- return meta?.tags && query.tags!.some((t: string) => meta.tags.includes(t));
597
- });
598
- }
599
-
600
- // Sort
601
- filtered.sort((a, b) => {
602
- const aVal = (a as any)[sortBy] ?? '';
603
- const bVal = (b as any)[sortBy] ?? '';
604
- const cmp = String(aVal).localeCompare(String(bVal));
605
- return sortOrder === 'desc' ? -cmp : cmp;
606
- });
607
-
608
- // Paginate
609
- const total = filtered.length;
610
- const start = (page - 1) * pageSize;
611
- const paged = filtered.slice(start, start + pageSize);
612
-
613
- return {
614
- items: paged,
615
- total,
616
- page,
617
- pageSize,
618
- };
619
- }
620
-
621
- // ==========================================
622
- // Bulk Operations
623
- // ==========================================
624
-
625
- /**
626
- * Register multiple metadata items in a single batch
627
- */
628
- async bulkRegister(
629
- items: Array<{ type: string; name: string; data: unknown }>,
630
- options?: { continueOnError?: boolean; validate?: boolean }
631
- ): Promise<MetadataBulkResult> {
632
- const { continueOnError = false } = options ?? {};
633
- let succeeded = 0;
634
- let failed = 0;
635
- const errors: Array<{ type: string; name: string; error: string }> = [];
636
-
637
- for (const item of items) {
638
- try {
639
- await this.register(item.type, item.name, item.data);
640
- succeeded++;
641
- } catch (e) {
642
- failed++;
643
- errors.push({
644
- type: item.type,
645
- name: item.name,
646
- error: e instanceof Error ? e.message : String(e),
647
- });
648
- if (!continueOnError) break;
649
- }
650
- }
651
-
652
- return {
653
- total: items.length,
654
- succeeded,
655
- failed,
656
- errors: errors.length > 0 ? errors : undefined,
657
- };
658
- }
659
-
660
- /**
661
- * Unregister multiple metadata items in a single batch
662
- */
663
- async bulkUnregister(items: Array<{ type: string; name: string }>): Promise<MetadataBulkResult> {
664
- let succeeded = 0;
665
- let failed = 0;
666
- const errors: Array<{ type: string; name: string; error: string }> = [];
667
-
668
- for (const item of items) {
669
- try {
670
- await this.unregister(item.type, item.name);
671
- succeeded++;
672
- } catch (e) {
673
- failed++;
674
- errors.push({
675
- type: item.type,
676
- name: item.name,
677
- error: e instanceof Error ? e.message : String(e),
678
- });
679
- }
680
- }
681
-
682
- return {
683
- total: items.length,
684
- succeeded,
685
- failed,
686
- errors: errors.length > 0 ? errors : undefined,
687
- };
688
- }
689
-
690
- // ==========================================
691
- // Overlay / Customization Management
692
- // ==========================================
693
-
694
- private overlayKey(type: string, name: string, scope: string = 'platform'): string {
695
- return `${encodeURIComponent(type)}:${encodeURIComponent(name)}:${scope}`;
696
- }
697
-
698
- /**
699
- * Get the active overlay for a metadata item
700
- */
701
- async getOverlay(type: string, name: string, scope?: 'platform' | 'user'): Promise<MetadataOverlay | undefined> {
702
- return this.overlays.get(this.overlayKey(type, name, scope ?? 'platform'));
703
- }
704
-
705
- /**
706
- * Save/update an overlay for a metadata item
707
- */
708
- async saveOverlay(overlay: MetadataOverlay): Promise<void> {
709
- const key = this.overlayKey(overlay.baseType, overlay.baseName, overlay.scope);
710
- this.overlays.set(key, overlay);
711
- }
712
-
713
- /**
714
- * Remove an overlay, reverting to the base definition
715
- */
716
- async removeOverlay(type: string, name: string, scope?: 'platform' | 'user'): Promise<void> {
717
- this.overlays.delete(this.overlayKey(type, name, scope ?? 'platform'));
718
- }
719
-
720
- /**
721
- * Get the effective (merged) metadata after applying all overlays.
722
- * Resolution order: system ← merge(platform) ← merge(user)
723
- */
724
- async getEffective(type: string, name: string, context?: {
725
- userId?: string;
726
- tenantId?: string;
727
- roles?: string[];
728
- permissions?: string[];
729
- }): Promise<unknown | undefined> {
730
- const base = await this.get(type, name);
731
- if (!base) return undefined;
732
-
733
- let effective = { ...(base as Record<string, unknown>) };
734
-
735
- // Apply platform overlay
736
- const platformOverlay = await this.getOverlay(type, name, 'platform');
737
- if (platformOverlay?.active && platformOverlay.patch) {
738
- effective = { ...effective, ...platformOverlay.patch };
739
- }
740
-
741
- // Apply user overlay (scoped to specific user if context provided)
742
- if (context?.userId) {
743
- // Try user-specific key first, then fall back to generic user overlay.
744
- // The owner check below ensures we never apply another user's overlay.
745
- const userOverlayKey = this.overlayKey(type, name, 'user') + `:${context.userId}`;
746
- const userOverlay = this.overlays.get(userOverlayKey)
747
- ?? await this.getOverlay(type, name, 'user');
748
- if (userOverlay?.active && userOverlay.patch) {
749
- // Apply if: overlay has no owner (generic user-level), or owner matches current user
750
- if (!userOverlay.owner || userOverlay.owner === context.userId) {
751
- effective = { ...effective, ...userOverlay.patch };
752
- }
753
- }
754
- } else {
755
- // No user context — only apply user overlays without an owner restriction
756
- // (owner-scoped overlays require a userId to resolve)
757
- const userOverlay = await this.getOverlay(type, name, 'user');
758
- if (userOverlay?.active && userOverlay.patch && !userOverlay.owner) {
759
- effective = { ...effective, ...userOverlay.patch };
760
- }
761
- }
762
-
763
- return effective;
764
- }
765
-
766
- // ==========================================
767
- // Watch / Subscribe (IMetadataService)
768
- // ==========================================
769
-
770
- /**
771
- * Watch for metadata changes (IMetadataService contract).
772
- * Returns a handle for unsubscribing.
773
- */
774
- watchService(type: string, callback: MetadataWatchCallback): MetadataWatchHandle {
775
- const wrappedCallback: WatchCallback = (event) => {
776
- const mappedType = event.type === 'added' ? 'registered'
777
- : event.type === 'deleted' ? 'unregistered'
778
- : 'updated';
779
- callback({
780
- type: mappedType,
781
- metadataType: event.metadataType ?? type,
782
- name: event.name ?? '',
783
- data: event.data,
784
- });
785
- };
786
- this.addWatchCallback(type, wrappedCallback);
787
- return {
788
- unsubscribe: () => this.removeWatchCallback(type, wrappedCallback),
789
- };
790
- }
791
-
792
- // ==========================================
793
- // Import / Export
794
- // ==========================================
795
-
796
- /**
797
- * Export metadata as a portable bundle
798
- */
799
- async exportMetadata(options?: MetadataExportOptions): Promise<unknown> {
800
- const bundle: Record<string, unknown[]> = {};
801
- const targetTypes = options?.types ?? Array.from(this.registry.keys());
802
-
803
- for (const type of targetTypes) {
804
- const items = await this.list(type);
805
- if (items.length > 0) {
806
- bundle[type] = items;
807
- }
808
- }
809
-
810
- return bundle;
811
- }
812
-
813
- /**
814
- * Import metadata from a portable bundle
815
- */
816
- async importMetadata(data: unknown, options?: MetadataImportOptions): Promise<MetadataImportResult> {
817
- const {
818
- conflictResolution = 'skip',
819
- validate: _validate = true,
820
- dryRun = false,
821
- } = options ?? {};
822
-
823
- const bundle = data as Record<string, unknown[]>;
824
- let total = 0;
825
- let imported = 0;
826
- let skipped = 0;
827
- let failed = 0;
828
- const errors: Array<{ type: string; name: string; error: string }> = [];
829
-
830
- for (const [type, items] of Object.entries(bundle)) {
831
- if (!Array.isArray(items)) continue;
832
-
833
- for (const item of items) {
834
- total++;
835
- const meta = item as any;
836
- const name = meta?.name;
837
-
838
- if (!name) {
839
- failed++;
840
- errors.push({ type, name: '(unknown)', error: 'Item missing name field' });
841
- continue;
842
- }
843
-
844
- try {
845
- const itemExists = await this.exists(type, name);
846
-
847
- if (itemExists && conflictResolution === 'skip') {
848
- skipped++;
849
- continue;
850
- }
851
-
852
- if (!dryRun) {
853
- if (itemExists && conflictResolution === 'merge') {
854
- const existing = await this.get(type, name);
855
- const merged = { ...(existing as any), ...(item as any) };
856
- await this.register(type, name, merged);
857
- } else {
858
- await this.register(type, name, item);
859
- }
860
- }
861
- imported++;
862
- } catch (e) {
863
- failed++;
864
- errors.push({
865
- type,
866
- name,
867
- error: e instanceof Error ? e.message : String(e),
868
- });
869
- }
870
- }
871
- }
872
-
873
- return {
874
- total,
875
- imported,
876
- skipped,
877
- failed,
878
- errors: errors.length > 0 ? errors : undefined,
879
- };
880
- }
881
-
882
- // ==========================================
883
- // Validation
884
- // ==========================================
885
-
886
- /**
887
- * Validate a metadata item against its type schema.
888
- * Returns validation result with errors and warnings.
889
- */
890
- async validate(_type: string, data: unknown): Promise<MetadataValidationResult> {
891
- // Basic structural validation
892
- if (data === null || data === undefined) {
893
- return {
894
- valid: false,
895
- errors: [{ path: '', message: 'Metadata data cannot be null or undefined' }],
896
- };
897
- }
898
-
899
- if (typeof data !== 'object') {
900
- return {
901
- valid: false,
902
- errors: [{ path: '', message: 'Metadata data must be an object' }],
903
- };
904
- }
905
-
906
- const meta = data as any;
907
- const warnings: Array<{ path: string; message: string }> = [];
908
-
909
- if (!meta.name) {
910
- return {
911
- valid: false,
912
- errors: [{ path: 'name', message: 'Metadata item must have a name field' }],
913
- };
914
- }
915
-
916
- if (!meta.label) {
917
- warnings.push({ path: 'label', message: 'Missing label field (recommended)' });
918
- }
919
-
920
- return { valid: true, warnings: warnings.length > 0 ? warnings : undefined };
921
- }
922
-
923
- // ==========================================
924
- // Type Registry
925
- // ==========================================
926
-
927
- /**
928
- * Get all registered metadata types
929
- */
930
- async getRegisteredTypes(): Promise<string[]> {
931
- const types = new Set<string>();
932
-
933
- // From type registry
934
- for (const entry of this.typeRegistry) {
935
- types.add(entry.type);
936
- }
937
-
938
- // From in-memory registry (custom types)
939
- for (const type of this.registry.keys()) {
940
- types.add(type);
941
- }
942
-
943
- return Array.from(types);
944
- }
945
-
946
- /**
947
- * Get detailed information about a metadata type
948
- */
949
- async getTypeInfo(type: string): Promise<MetadataTypeInfo | undefined> {
950
- const entry = this.typeRegistry.find(e => e.type === type);
951
- if (!entry) return undefined;
952
-
953
- return {
954
- type: entry.type,
955
- label: entry.label,
956
- description: entry.description,
957
- filePatterns: entry.filePatterns,
958
- supportsOverlay: entry.supportsOverlay,
959
- domain: entry.domain,
960
- };
961
- }
962
-
963
- // ==========================================
964
- // Dependency Tracking
965
- // ==========================================
966
-
967
- /**
968
- * Get metadata items that this item depends on
969
- */
970
- async getDependencies(type: string, name: string): Promise<MetadataDependency[]> {
971
- return this.dependencies.get(`${encodeURIComponent(type)}:${encodeURIComponent(name)}`) ?? [];
972
- }
973
-
974
- /**
975
- * Get metadata items that depend on this item
976
- */
977
- async getDependents(type: string, name: string): Promise<MetadataDependency[]> {
978
- const dependents: MetadataDependency[] = [];
979
- for (const deps of this.dependencies.values()) {
980
- for (const dep of deps) {
981
- if (dep.targetType === type && dep.targetName === name) {
982
- dependents.push(dep);
983
- }
984
- }
985
- }
986
- return dependents;
987
- }
988
-
989
- /**
990
- * Register a dependency between two metadata items.
991
- * Used internally to track cross-references.
992
- * Duplicate dependencies (same source, target, and kind) are ignored.
993
- */
994
- addDependency(dep: MetadataDependency): void {
995
- const key = `${encodeURIComponent(dep.sourceType)}:${encodeURIComponent(dep.sourceName)}`;
996
- if (!this.dependencies.has(key)) {
997
- this.dependencies.set(key, []);
998
- }
999
- const existing = this.dependencies.get(key)!;
1000
- const isDuplicate = existing.some(
1001
- d => d.targetType === dep.targetType && d.targetName === dep.targetName && d.kind === dep.kind
1002
- );
1003
- if (!isDuplicate) {
1004
- existing.push(dep);
1005
- }
1006
- }
1007
-
1008
- // ==========================================
1009
- // Legacy Loader API (backward compatible)
1010
- // ==========================================
1011
-
1012
- /**
1013
- * Load a single metadata item from loaders.
1014
- * Iterates through registered loaders until found.
1015
- */
1016
- async load<T = any>(
1017
- type: string,
1018
- name: string,
1019
- options?: MetadataLoadOptions
1020
- ): Promise<T | null> {
1021
- for (const loader of this.loaders.values()) {
1022
- try {
1023
- const result = await loader.load(type, name, options);
1024
- if (result.data) {
1025
- return result.data as T;
1026
- }
1027
- } catch (e) {
1028
- this.logger.warn(`Loader ${loader.contract.name} failed to load ${type}:${name}`, { error: e });
1029
- }
1030
- }
1031
- return null;
1032
- }
1033
-
1034
- /**
1035
- * Load multiple metadata items from loaders.
1036
- * Aggregates results from all loaders.
1037
- */
1038
- async loadMany<T = any>(
1039
- type: string,
1040
- options?: MetadataLoadOptions
1041
- ): Promise<T[]> {
1042
- const results: T[] = [];
1043
-
1044
- for (const loader of this.loaders.values()) {
1045
- try {
1046
- const items = await loader.loadMany<T>(type, options);
1047
- for (const item of items) {
1048
- const itemAny = item as any;
1049
- if (itemAny && typeof itemAny.name === 'string') {
1050
- const exists = results.some((r: any) => r && r.name === itemAny.name);
1051
- if (exists) continue;
1052
- }
1053
- results.push(item);
1054
- }
1055
- } catch (e) {
1056
- this.logger.warn(`Loader ${loader.contract.name} failed to loadMany ${type}`, { error: e });
1057
- }
1058
- }
1059
- return results;
1060
- }
1061
-
1062
- /**
1063
- * Save metadata item to a loader
1064
- */
1065
- async save<T = any>(
1066
- type: string,
1067
- name: string,
1068
- data: T,
1069
- options?: MetadataSaveOptions
1070
- ): Promise<MetadataSaveResult> {
1071
- const targetLoader = (options as any)?.loader;
1072
-
1073
- let loader: MetadataLoader | undefined;
1074
-
1075
- if (targetLoader) {
1076
- loader = this.loaders.get(targetLoader);
1077
- if (!loader) {
1078
- throw new Error(`Loader not found: ${targetLoader}`);
1079
- }
1080
- } else {
1081
- for (const l of this.loaders.values()) {
1082
- if (!l.save) continue;
1083
- try {
1084
- if (await l.exists(type, name)) {
1085
- loader = l;
1086
- this.logger.info(`Updating existing metadata in loader: ${l.contract.name}`);
1087
- break;
1088
- }
1089
- } catch (e) {
1090
- // Ignore existence check errors
1091
- }
1092
- }
1093
-
1094
- if (!loader) {
1095
- const fsLoader = this.loaders.get('filesystem');
1096
- if (fsLoader && fsLoader.save) {
1097
- loader = fsLoader;
1098
- }
1099
- }
1100
-
1101
- if (!loader) {
1102
- for (const l of this.loaders.values()) {
1103
- if (l.save) {
1104
- loader = l;
1105
- break;
1106
- }
1107
- }
1108
- }
1109
- }
1110
-
1111
- if (!loader) {
1112
- throw new Error(`No loader available for saving type: ${type}`);
1113
- }
1114
-
1115
- if (!loader.save) {
1116
- throw new Error(`Loader '${loader.contract?.name}' does not support saving`);
1117
- }
1118
-
1119
- return loader.save(type, name, data, options);
1120
- }
1121
-
1122
- /**
1123
- * Register a watch callback for metadata changes
1124
- */
1125
- protected addWatchCallback(type: string, callback: WatchCallback): void {
1126
- if (!this.watchCallbacks.has(type)) {
1127
- this.watchCallbacks.set(type, new Set());
1128
- }
1129
- this.watchCallbacks.get(type)!.add(callback);
1130
- }
1131
-
1132
- /**
1133
- * Remove a watch callback for metadata changes
1134
- */
1135
- protected removeWatchCallback(type: string, callback: WatchCallback): void {
1136
- const callbacks = this.watchCallbacks.get(type);
1137
- if (callbacks) {
1138
- callbacks.delete(callback);
1139
- if (callbacks.size === 0) {
1140
- this.watchCallbacks.delete(type);
1141
- }
1142
- }
1143
- }
1144
-
1145
- /**
1146
- * Stop all watching
1147
- */
1148
- async stopWatching(): Promise<void> {
1149
- // Override in subclass
1150
- }
1151
-
1152
- protected notifyWatchers(type: string, event: MetadataWatchEvent) {
1153
- const callbacks = this.watchCallbacks.get(type);
1154
- if (!callbacks) return;
1155
-
1156
- for (const callback of callbacks) {
1157
- try {
1158
- void callback(event);
1159
- } catch (error) {
1160
- this.logger.error('Watch callback error', undefined, {
1161
- type,
1162
- error: error instanceof Error ? error.message : String(error),
1163
- });
1164
- }
1165
- }
1166
- }
1167
- }
1168
-