@objectstack/metadata 2.0.7 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +22 -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
|
@@ -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
|
+
});
|
package/src/metadata.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
211
|
-
manager.
|
|
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.
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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 () => {
|