@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.
@@ -0,0 +1,611 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { MetadataManager } from './metadata-manager';
5
+ import { MemoryLoader } from './loaders/memory-loader';
6
+ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
7
+ import type { MetadataOverlay } from '@objectstack/spec/kernel';
8
+
9
+ // Suppress logger output during tests
10
+ vi.mock('@objectstack/core', () => ({
11
+ createLogger: () => ({
12
+ info: vi.fn(),
13
+ warn: vi.fn(),
14
+ error: vi.fn(),
15
+ debug: vi.fn(),
16
+ }),
17
+ }));
18
+
19
+ describe('MetadataManager — IMetadataService Contract', () => {
20
+ let manager: MetadataManager;
21
+ let memoryLoader: MemoryLoader;
22
+
23
+ beforeEach(() => {
24
+ memoryLoader = new MemoryLoader();
25
+ manager = new MetadataManager({
26
+ formats: ['json'],
27
+ loaders: [memoryLoader],
28
+ });
29
+ manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
30
+ });
31
+
32
+ // ==========================================
33
+ // Core CRUD Operations
34
+ // ==========================================
35
+
36
+ describe('register / get', () => {
37
+ it('should register and retrieve a metadata item', async () => {
38
+ await manager.register('object', 'account', { name: 'account', label: 'Account' });
39
+ const result = await manager.get('object', 'account');
40
+ expect(result).toEqual({ name: 'account', label: 'Account' });
41
+ });
42
+
43
+ it('should return undefined for non-existent item', async () => {
44
+ const result = await manager.get('object', 'nonexistent');
45
+ expect(result).toBeUndefined();
46
+ });
47
+
48
+ it('should overwrite existing item on re-register', async () => {
49
+ await manager.register('object', 'account', { name: 'account', label: 'V1' });
50
+ await manager.register('object', 'account', { name: 'account', label: 'V2' });
51
+ const result = await manager.get('object', 'account');
52
+ expect(result).toEqual({ name: 'account', label: 'V2' });
53
+ });
54
+
55
+ it('should fall back to loaders when not in registry', async () => {
56
+ await memoryLoader.save('object', 'contact', { name: 'contact', label: 'Contact' });
57
+ const result = await manager.get('object', 'contact');
58
+ expect(result).toEqual({ name: 'contact', label: 'Contact' });
59
+ });
60
+ });
61
+
62
+ describe('list (IMetadataService)', () => {
63
+ it('should return all items from registry and loaders', async () => {
64
+ await manager.register('object', 'account', { name: 'account' });
65
+ await memoryLoader.save('object', 'contact', { name: 'contact' });
66
+ const items = await manager.list('object');
67
+ expect(items).toHaveLength(2);
68
+ });
69
+
70
+ it('should deduplicate items between registry and loaders', async () => {
71
+ await manager.register('object', 'account', { name: 'account', label: 'Registry' });
72
+ await memoryLoader.save('object', 'account', { name: 'account', label: 'Loader' });
73
+ const items = await manager.list('object');
74
+ expect(items).toHaveLength(1);
75
+ // Registry takes precedence
76
+ expect((items[0] as any).label).toBe('Registry');
77
+ });
78
+
79
+ it('should return empty array for unknown type', async () => {
80
+ const items = await manager.list('unknown_type');
81
+ expect(items).toEqual([]);
82
+ });
83
+ });
84
+
85
+ describe('unregister', () => {
86
+ it('should remove an item from the registry', async () => {
87
+ await manager.register('object', 'account', { name: 'account' });
88
+ await manager.unregister('object', 'account');
89
+ const result = await manager.get('object', 'account');
90
+ expect(result).toBeUndefined();
91
+ });
92
+
93
+ it('should not throw when unregistering non-existent item', async () => {
94
+ await expect(manager.unregister('object', 'nonexistent')).resolves.not.toThrow();
95
+ });
96
+ });
97
+
98
+ describe('exists', () => {
99
+ it('should find items in registry', async () => {
100
+ await manager.register('view', 'my_view', { name: 'my_view' });
101
+ expect(await manager.exists('view', 'my_view')).toBe(true);
102
+ });
103
+
104
+ it('should find items in loaders', async () => {
105
+ await memoryLoader.save('object', 'task', { name: 'task' });
106
+ expect(await manager.exists('object', 'task')).toBe(true);
107
+ });
108
+
109
+ it('should return false for non-existent items', async () => {
110
+ expect(await manager.exists('object', 'nope')).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe('listNames', () => {
115
+ it('should return names from both registry and loaders', async () => {
116
+ await manager.register('object', 'account', { name: 'account' });
117
+ await memoryLoader.save('object', 'contact', {});
118
+ const names = await manager.listNames('object');
119
+ expect(names).toContain('account');
120
+ expect(names).toContain('contact');
121
+ });
122
+
123
+ it('should deduplicate names', async () => {
124
+ await manager.register('object', 'account', { name: 'account' });
125
+ await memoryLoader.save('object', 'account', {});
126
+ const names = await manager.listNames('object');
127
+ expect(names.filter(n => n === 'account')).toHaveLength(1);
128
+ });
129
+ });
130
+
131
+ describe('getObject / listObjects', () => {
132
+ it('getObject should be shorthand for get("object", name)', async () => {
133
+ await manager.register('object', 'account', { name: 'account', label: 'Account' });
134
+ const result = await manager.getObject('account');
135
+ expect(result).toEqual({ name: 'account', label: 'Account' });
136
+ });
137
+
138
+ it('listObjects should be shorthand for list("object")', async () => {
139
+ await manager.register('object', 'account', { name: 'account' });
140
+ await manager.register('object', 'contact', { name: 'contact' });
141
+ const items = await manager.listObjects();
142
+ expect(items).toHaveLength(2);
143
+ });
144
+ });
145
+
146
+ // ==========================================
147
+ // Package Management
148
+ // ==========================================
149
+
150
+ describe('unregisterPackage', () => {
151
+ it('should remove all items from a package', async () => {
152
+ await manager.register('object', 'crm_account', { name: 'crm_account', packageId: 'com.acme.crm' });
153
+ await manager.register('object', 'crm_contact', { name: 'crm_contact', packageId: 'com.acme.crm' });
154
+ await manager.register('object', 'sys_user', { name: 'sys_user', packageId: 'com.objectstack.core' });
155
+
156
+ await manager.unregisterPackage('com.acme.crm');
157
+
158
+ expect(await manager.get('object', 'crm_account')).toBeUndefined();
159
+ expect(await manager.get('object', 'crm_contact')).toBeUndefined();
160
+ expect(await manager.get('object', 'sys_user')).toBeDefined();
161
+ });
162
+ });
163
+
164
+ // ==========================================
165
+ // Query / Search
166
+ // ==========================================
167
+
168
+ describe('query', () => {
169
+ beforeEach(async () => {
170
+ await manager.register('object', 'account', { name: 'account', label: 'Account' });
171
+ await manager.register('object', 'contact', { name: 'contact', label: 'Contact' });
172
+ await manager.register('view', 'account_list', { name: 'account_list', label: 'Account List' });
173
+ });
174
+
175
+ it('should return all items when no filters', async () => {
176
+ const result = await manager.query({});
177
+ expect(result.total).toBeGreaterThanOrEqual(3);
178
+ expect(result.page).toBe(1);
179
+ });
180
+
181
+ it('should filter by type', async () => {
182
+ const result = await manager.query({ types: ['object'] });
183
+ expect(result.total).toBe(2);
184
+ expect(result.items.every(i => i.type === 'object')).toBe(true);
185
+ });
186
+
187
+ it('should support search', async () => {
188
+ const result = await manager.query({ search: 'account' });
189
+ expect(result.total).toBeGreaterThanOrEqual(1);
190
+ expect(result.items.every(i =>
191
+ i.name.includes('account') || (i.label && i.label.toLowerCase().includes('account'))
192
+ )).toBe(true);
193
+ });
194
+
195
+ it('should support pagination', async () => {
196
+ const result = await manager.query({ types: ['object'], page: 1, pageSize: 1 });
197
+ expect(result.items).toHaveLength(1);
198
+ expect(result.total).toBe(2);
199
+ expect(result.page).toBe(1);
200
+ expect(result.pageSize).toBe(1);
201
+ });
202
+
203
+ it('should support sorting', async () => {
204
+ const asc = await manager.query({ types: ['object'], sortBy: 'name', sortOrder: 'asc' });
205
+ expect(asc.items[0].name).toBe('account');
206
+
207
+ const desc = await manager.query({ types: ['object'], sortBy: 'name', sortOrder: 'desc' });
208
+ expect(desc.items[0].name).toBe('contact');
209
+ });
210
+ });
211
+
212
+ // ==========================================
213
+ // Bulk Operations
214
+ // ==========================================
215
+
216
+ describe('bulkRegister', () => {
217
+ it('should register multiple items at once', async () => {
218
+ const result = await manager.bulkRegister([
219
+ { type: 'object', name: 'account', data: { name: 'account' } },
220
+ { type: 'object', name: 'contact', data: { name: 'contact' } },
221
+ { type: 'view', name: 'account_list', data: { name: 'account_list' } },
222
+ ]);
223
+
224
+ expect(result.total).toBe(3);
225
+ expect(result.succeeded).toBe(3);
226
+ expect(result.failed).toBe(0);
227
+
228
+ expect(await manager.get('object', 'account')).toBeDefined();
229
+ expect(await manager.get('view', 'account_list')).toBeDefined();
230
+ });
231
+ });
232
+
233
+ describe('bulkUnregister', () => {
234
+ it('should unregister multiple items at once', async () => {
235
+ await manager.register('object', 'account', { name: 'account' });
236
+ await manager.register('object', 'contact', { name: 'contact' });
237
+
238
+ const result = await manager.bulkUnregister([
239
+ { type: 'object', name: 'account' },
240
+ { type: 'object', name: 'contact' },
241
+ ]);
242
+
243
+ expect(result.total).toBe(2);
244
+ expect(result.succeeded).toBe(2);
245
+ expect(await manager.get('object', 'account')).toBeUndefined();
246
+ });
247
+ });
248
+
249
+ // ==========================================
250
+ // Overlay / Customization
251
+ // ==========================================
252
+
253
+ describe('overlay management', () => {
254
+ const testOverlay: MetadataOverlay = {
255
+ id: 'overlay-1',
256
+ baseType: 'object',
257
+ baseName: 'account',
258
+ scope: 'platform',
259
+ patch: { label: 'Custom Account' },
260
+ active: true,
261
+ };
262
+
263
+ it('should save and retrieve an overlay', async () => {
264
+ await manager.saveOverlay(testOverlay);
265
+ const result = await manager.getOverlay('object', 'account', 'platform');
266
+ expect(result).toEqual(testOverlay);
267
+ });
268
+
269
+ it('should return undefined for missing overlay', async () => {
270
+ const result = await manager.getOverlay('object', 'nonexistent');
271
+ expect(result).toBeUndefined();
272
+ });
273
+
274
+ it('should remove an overlay', async () => {
275
+ await manager.saveOverlay(testOverlay);
276
+ await manager.removeOverlay('object', 'account', 'platform');
277
+ const result = await manager.getOverlay('object', 'account', 'platform');
278
+ expect(result).toBeUndefined();
279
+ });
280
+
281
+ it('should get effective metadata with overlays applied', async () => {
282
+ await manager.register('object', 'account', { name: 'account', label: 'Account', type: 'object' });
283
+ await manager.saveOverlay(testOverlay);
284
+
285
+ const effective = await manager.getEffective('object', 'account') as any;
286
+ expect(effective.label).toBe('Custom Account');
287
+ expect(effective.name).toBe('account');
288
+ expect(effective.type).toBe('object');
289
+ });
290
+
291
+ it('should apply user overlay on top of platform overlay', async () => {
292
+ await manager.register('object', 'account', { name: 'account', label: 'Account' });
293
+
294
+ await manager.saveOverlay({
295
+ id: 'platform-1',
296
+ baseType: 'object',
297
+ baseName: 'account',
298
+ scope: 'platform',
299
+ patch: { label: 'Platform Label', description: 'Platform Desc' },
300
+ active: true,
301
+ });
302
+
303
+ await manager.saveOverlay({
304
+ id: 'user-1',
305
+ baseType: 'object',
306
+ baseName: 'account',
307
+ scope: 'user',
308
+ patch: { label: 'User Label' },
309
+ active: true,
310
+ });
311
+
312
+ const effective = await manager.getEffective('object', 'account') as any;
313
+ expect(effective.label).toBe('User Label');
314
+ expect(effective.description).toBe('Platform Desc');
315
+ });
316
+
317
+ it('should not apply inactive overlays', async () => {
318
+ await manager.register('object', 'account', { name: 'account', label: 'Original' });
319
+ await manager.saveOverlay({
320
+ id: 'inactive-1',
321
+ baseType: 'object',
322
+ baseName: 'account',
323
+ scope: 'platform',
324
+ patch: { label: 'Should Not Apply' },
325
+ active: false,
326
+ });
327
+
328
+ const effective = await manager.getEffective('object', 'account') as any;
329
+ expect(effective.label).toBe('Original');
330
+ });
331
+ });
332
+
333
+ // ==========================================
334
+ // Watch / Subscribe (IMetadataService)
335
+ // ==========================================
336
+
337
+ describe('watchService', () => {
338
+ it('should return a handle with unsubscribe', () => {
339
+ const callback = vi.fn();
340
+ const handle = manager.watchService('object', callback);
341
+ expect(handle).toBeDefined();
342
+ expect(typeof handle.unsubscribe).toBe('function');
343
+ });
344
+
345
+ it('should invoke callback on notification', () => {
346
+ const callback = vi.fn();
347
+ manager.watchService('object', callback);
348
+
349
+ // Trigger via internal method
350
+ (manager as any).notifyWatchers('object', {
351
+ type: 'changed',
352
+ metadataType: 'object',
353
+ name: 'account',
354
+ path: '/fake',
355
+ timestamp: new Date().toISOString(),
356
+ });
357
+
358
+ expect(callback).toHaveBeenCalledOnce();
359
+ expect(callback).toHaveBeenCalledWith(
360
+ expect.objectContaining({ type: 'updated', metadataType: 'object', name: 'account' })
361
+ );
362
+ });
363
+
364
+ it('should stop invoking after unsubscribe', () => {
365
+ const callback = vi.fn();
366
+ const handle = manager.watchService('object', callback);
367
+ handle.unsubscribe();
368
+
369
+ (manager as any).notifyWatchers('object', {
370
+ type: 'added',
371
+ metadataType: 'object',
372
+ name: 'account',
373
+ path: '/fake',
374
+ timestamp: new Date().toISOString(),
375
+ });
376
+
377
+ expect(callback).not.toHaveBeenCalled();
378
+ });
379
+ });
380
+
381
+ // ==========================================
382
+ // Import / Export
383
+ // ==========================================
384
+
385
+ describe('exportMetadata', () => {
386
+ it('should export all registered metadata', async () => {
387
+ await manager.register('object', 'account', { name: 'account' });
388
+ await manager.register('view', 'account_list', { name: 'account_list' });
389
+
390
+ const bundle = await manager.exportMetadata() as Record<string, unknown[]>;
391
+ expect(bundle.object).toHaveLength(1);
392
+ expect(bundle.view).toHaveLength(1);
393
+ });
394
+
395
+ it('should filter by types', async () => {
396
+ await manager.register('object', 'account', { name: 'account' });
397
+ await manager.register('view', 'account_list', { name: 'account_list' });
398
+
399
+ const bundle = await manager.exportMetadata({ types: ['object'] }) as Record<string, unknown[]>;
400
+ expect(bundle.object).toHaveLength(1);
401
+ expect(bundle.view).toBeUndefined();
402
+ });
403
+ });
404
+
405
+ describe('importMetadata', () => {
406
+ it('should import metadata from bundle', async () => {
407
+ const bundle = {
408
+ object: [{ name: 'account', label: 'Account' }, { name: 'contact', label: 'Contact' }],
409
+ view: [{ name: 'account_list', label: 'Account List' }],
410
+ };
411
+
412
+ const result = await manager.importMetadata(bundle);
413
+ expect(result.total).toBe(3);
414
+ expect(result.imported).toBe(3);
415
+ expect(result.failed).toBe(0);
416
+
417
+ expect(await manager.get('object', 'account')).toBeDefined();
418
+ });
419
+
420
+ it('should skip existing items with skip strategy', async () => {
421
+ await manager.register('object', 'account', { name: 'account', label: 'Original' });
422
+
423
+ const bundle = {
424
+ object: [{ name: 'account', label: 'Imported' }],
425
+ };
426
+
427
+ const result = await manager.importMetadata(bundle, { conflictResolution: 'skip' });
428
+ expect(result.skipped).toBe(1);
429
+ expect(result.imported).toBe(0);
430
+
431
+ const item = await manager.get('object', 'account') as any;
432
+ expect(item.label).toBe('Original');
433
+ });
434
+
435
+ it('should overwrite existing items with overwrite strategy', async () => {
436
+ await manager.register('object', 'account', { name: 'account', label: 'Original' });
437
+
438
+ const bundle = {
439
+ object: [{ name: 'account', label: 'Overwritten' }],
440
+ };
441
+
442
+ const result = await manager.importMetadata(bundle, { conflictResolution: 'overwrite' });
443
+ expect(result.imported).toBe(1);
444
+
445
+ const item = await manager.get('object', 'account') as any;
446
+ expect(item.label).toBe('Overwritten');
447
+ });
448
+
449
+ it('should merge existing items with merge strategy', async () => {
450
+ await manager.register('object', 'account', { name: 'account', label: 'Original', type: 'object' });
451
+
452
+ const bundle = {
453
+ object: [{ name: 'account', label: 'Merged', description: 'New desc' }],
454
+ };
455
+
456
+ const result = await manager.importMetadata(bundle, { conflictResolution: 'merge' });
457
+ expect(result.imported).toBe(1);
458
+
459
+ const item = await manager.get('object', 'account') as any;
460
+ expect(item.label).toBe('Merged');
461
+ expect(item.type).toBe('object');
462
+ expect(item.description).toBe('New desc');
463
+ });
464
+
465
+ it('should support dry run', async () => {
466
+ const bundle = {
467
+ object: [{ name: 'account', label: 'Account' }],
468
+ };
469
+
470
+ const result = await manager.importMetadata(bundle, { dryRun: true });
471
+ expect(result.imported).toBe(1);
472
+
473
+ // Should not actually register
474
+ expect(await manager.get('object', 'account')).toBeUndefined();
475
+ });
476
+ });
477
+
478
+ // ==========================================
479
+ // Validation
480
+ // ==========================================
481
+
482
+ describe('validate', () => {
483
+ it('should validate valid metadata', async () => {
484
+ const result = await manager.validate('object', { name: 'account', label: 'Account' });
485
+ expect(result.valid).toBe(true);
486
+ });
487
+
488
+ it('should reject null data', async () => {
489
+ const result = await manager.validate('object', null);
490
+ expect(result.valid).toBe(false);
491
+ expect(result.errors).toBeDefined();
492
+ });
493
+
494
+ it('should reject non-object data', async () => {
495
+ const result = await manager.validate('object', 'not-an-object');
496
+ expect(result.valid).toBe(false);
497
+ });
498
+
499
+ it('should reject data without name field', async () => {
500
+ const result = await manager.validate('object', { label: 'No Name' });
501
+ expect(result.valid).toBe(false);
502
+ expect(result.errors![0].path).toBe('name');
503
+ });
504
+
505
+ it('should warn about missing label', async () => {
506
+ const result = await manager.validate('object', { name: 'account' });
507
+ expect(result.valid).toBe(true);
508
+ expect(result.warnings).toBeDefined();
509
+ expect(result.warnings!.some(w => w.path === 'label')).toBe(true);
510
+ });
511
+ });
512
+
513
+ // ==========================================
514
+ // Type Registry
515
+ // ==========================================
516
+
517
+ describe('type registry', () => {
518
+ it('should return all registered types', async () => {
519
+ const types = await manager.getRegisteredTypes();
520
+ expect(types).toContain('object');
521
+ expect(types).toContain('view');
522
+ expect(types).toContain('flow');
523
+ expect(types).toContain('agent');
524
+ });
525
+
526
+ it('should include custom types from registry', async () => {
527
+ await manager.register('custom_type', 'item1', { name: 'item1' });
528
+ const types = await manager.getRegisteredTypes();
529
+ expect(types).toContain('custom_type');
530
+ });
531
+
532
+ it('should return type info for known types', async () => {
533
+ const info = await manager.getTypeInfo('object');
534
+ expect(info).toBeDefined();
535
+ expect(info!.type).toBe('object');
536
+ expect(info!.label).toBe('Object');
537
+ expect(info!.domain).toBe('data');
538
+ expect(info!.supportsOverlay).toBe(true);
539
+ expect(info!.filePatterns).toBeDefined();
540
+ });
541
+
542
+ it('should return undefined for unknown types', async () => {
543
+ const info = await manager.getTypeInfo('unknown_type');
544
+ expect(info).toBeUndefined();
545
+ });
546
+ });
547
+
548
+ // ==========================================
549
+ // Dependency Tracking
550
+ // ==========================================
551
+
552
+ describe('dependency tracking', () => {
553
+ it('should track and retrieve dependencies', async () => {
554
+ manager.addDependency({
555
+ sourceType: 'view',
556
+ sourceName: 'account_list',
557
+ targetType: 'object',
558
+ targetName: 'account',
559
+ kind: 'reference',
560
+ });
561
+
562
+ const deps = await manager.getDependencies('view', 'account_list');
563
+ expect(deps).toHaveLength(1);
564
+ expect(deps[0].targetType).toBe('object');
565
+ expect(deps[0].targetName).toBe('account');
566
+ });
567
+
568
+ it('should find dependents of a target', async () => {
569
+ manager.addDependency({
570
+ sourceType: 'view',
571
+ sourceName: 'account_list',
572
+ targetType: 'object',
573
+ targetName: 'account',
574
+ kind: 'reference',
575
+ });
576
+
577
+ manager.addDependency({
578
+ sourceType: 'flow',
579
+ sourceName: 'account_flow',
580
+ targetType: 'object',
581
+ targetName: 'account',
582
+ kind: 'triggers',
583
+ });
584
+
585
+ const dependents = await manager.getDependents('object', 'account');
586
+ expect(dependents).toHaveLength(2);
587
+ });
588
+
589
+ it('should return empty array when no dependencies', async () => {
590
+ expect(await manager.getDependencies('object', 'nonexistent')).toEqual([]);
591
+ expect(await manager.getDependents('object', 'nonexistent')).toEqual([]);
592
+ });
593
+
594
+ it('should not add duplicate dependencies', () => {
595
+ const dep = {
596
+ sourceType: 'view',
597
+ sourceName: 'account_list',
598
+ targetType: 'object',
599
+ targetName: 'account',
600
+ kind: 'reference' as const,
601
+ };
602
+
603
+ manager.addDependency(dep);
604
+ manager.addDependency(dep);
605
+
606
+ // Should only have one entry
607
+ const deps = manager.getDependencies('view', 'account_list');
608
+ return deps.then(result => expect(result).toHaveLength(1));
609
+ });
610
+ });
611
+ });
@@ -148,14 +148,14 @@ describe('MetadataManager', () => {
148
148
 
149
149
  describe('list', () => {
150
150
  it('should return empty array for empty type', async () => {
151
- const result = await manager.list('object');
151
+ const result = await manager.listNames('object');
152
152
  expect(result).toEqual([]);
153
153
  });
154
154
 
155
155
  it('should list all items of a type', async () => {
156
156
  await memoryLoader.save('object', 'account', {});
157
157
  await memoryLoader.save('object', 'contact', {});
158
- const result = await manager.list('object');
158
+ const result = await manager.listNames('object');
159
159
  expect(result).toHaveLength(2);
160
160
  expect(result).toContain('account');
161
161
  expect(result).toContain('contact');
@@ -180,7 +180,7 @@ describe('MetadataManager', () => {
180
180
  };
181
181
 
182
182
  const m = new MetadataManager({ formats: ['json'], loaders: [loader1, loader2] });
183
- const result = await m.list('object');
183
+ const result = await m.listNames('object');
184
184
  expect(result).toHaveLength(3);
185
185
  expect(result).toContain('account');
186
186
  expect(result).toContain('contact');
@@ -191,7 +191,7 @@ describe('MetadataManager', () => {
191
191
  describe('watch / unwatch', () => {
192
192
  it('should register and invoke watch callbacks', () => {
193
193
  const callback = vi.fn();
194
- manager.watch('object', callback);
194
+ (manager as any).addWatchCallback('object', callback);
195
195
 
196
196
  // Trigger via protected method — cast to access it
197
197
  (manager as any).notifyWatchers('object', {
@@ -207,8 +207,8 @@ describe('MetadataManager', () => {
207
207
 
208
208
  it('should unwatch callback', () => {
209
209
  const callback = vi.fn();
210
- manager.watch('object', callback);
211
- manager.unwatch('object', callback);
210
+ (manager as any).addWatchCallback('object', callback);
211
+ (manager as any).removeWatchCallback('object', callback);
212
212
 
213
213
  (manager as any).notifyWatchers('object', {
214
214
  type: 'changed',
@@ -222,7 +222,7 @@ describe('MetadataManager', () => {
222
222
  });
223
223
 
224
224
  it('should not throw when unwatching non-existent callback', () => {
225
- expect(() => manager.unwatch('object', vi.fn())).not.toThrow();
225
+ expect(() => (manager as any).removeWatchCallback('object', vi.fn())).not.toThrow();
226
226
  });
227
227
  });
228
228
 
@@ -339,20 +339,18 @@ describe('MetadataPlugin', () => {
339
339
  loadMany = vi.fn().mockResolvedValue([]);
340
340
  registerLoader = vi.fn();
341
341
  stopWatching = vi.fn();
342
+ setTypeRegistry = vi.fn();
343
+ register = vi.fn();
342
344
  };
343
345
  return { NodeMetadataManager: MockNodeMetadataManager };
344
346
  });
345
347
 
346
- // Mock the spec import
347
- vi.mock('@objectstack/spec', () => ({
348
- ObjectStackDefinitionSchema: {
349
- shape: {
350
- manifest: {},
351
- objects: {},
352
- apps: {},
353
- views: {},
354
- },
355
- },
348
+ // Mock the spec kernel import
349
+ vi.mock('@objectstack/spec/kernel', () => ({
350
+ DEFAULT_METADATA_TYPE_REGISTRY: [
351
+ { type: 'object', label: 'Object', filePatterns: ['**/*.object.ts'], supportsOverlay: true, allowRuntimeCreate: false, supportsVersioning: true, loadOrder: 10, domain: 'data' },
352
+ { type: 'view', label: 'View', filePatterns: ['**/*.view.ts'], supportsOverlay: true, allowRuntimeCreate: true, supportsVersioning: false, loadOrder: 50, domain: 'ui' },
353
+ ],
356
354
  }));
357
355
 
358
356
  it('should have correct plugin metadata', async () => {