@objectstack/metadata 2.0.7 → 3.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +13 -0
- package/dist/index.d.mts +140 -20
- package/dist/index.d.ts +140 -20
- package/dist/index.js +542 -42
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +542 -42
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +24 -1
- package/src/metadata-manager.ts +680 -49
- package/src/metadata-service.test.ts +611 -0
- package/src/metadata.test.ts +15 -17
- package/src/plugin.ts +23 -14
- package/vitest.config.ts +2 -0
package/src/metadata-manager.ts
CHANGED
|
@@ -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
|
-
*
|
|
83
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
869
|
+
* Remove a watch callback for metadata changes
|
|
239
870
|
*/
|
|
240
|
-
|
|
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);
|