@objectstack/objectql 1.0.11 → 1.1.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.
@@ -1,27 +1,458 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { SchemaRegistry } from './registry';
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { SchemaRegistry, computeFQN, parseFQN, RESERVED_NAMESPACES } from './registry';
3
3
 
4
4
  describe('SchemaRegistry', () => {
5
- it('should register and retrieve an item', () => {
6
- const item = { name: 'test_object', type: 'object' };
7
-
8
- SchemaRegistry.registerItem('object', item, 'name');
9
-
10
- const retrieved = SchemaRegistry.getItem('object', 'test_object');
11
- expect(retrieved).toEqual(item);
12
- });
13
-
14
- it('should list items by type', () => {
15
- const item1 = { name: 'obj1' };
16
- const item2 = { name: 'obj2' };
17
-
18
- SchemaRegistry.registerItem('object', item1, 'name');
19
- SchemaRegistry.registerItem('object', item2, 'name');
20
-
21
- const items = SchemaRegistry.listItems('object');
22
- // Note: Registry is singleton, so it might contain previous test items or other items
23
- expect(items.length).toBeGreaterThanOrEqual(2);
24
- expect(items).toContainEqual(item1);
25
- expect(items).toContainEqual(item2);
26
- });
27
- });
5
+ beforeEach(() => {
6
+ SchemaRegistry.reset();
7
+ });
8
+
9
+ // ==========================================
10
+ // FQN Computation Tests
11
+ // ==========================================
12
+ describe('computeFQN', () => {
13
+ it('should compute FQN with namespace prefix', () => {
14
+ expect(computeFQN('crm', 'account')).toBe('crm__account');
15
+ expect(computeFQN('todo', 'task')).toBe('todo__task');
16
+ });
17
+
18
+ it('should not prefix reserved namespaces', () => {
19
+ expect(computeFQN('base', 'user')).toBe('user');
20
+ expect(computeFQN('system', 'organization')).toBe('organization');
21
+ });
22
+
23
+ it('should not prefix undefined namespace', () => {
24
+ expect(computeFQN(undefined, 'task')).toBe('task');
25
+ });
26
+ });
27
+
28
+ describe('parseFQN', () => {
29
+ it('should parse FQN with namespace', () => {
30
+ expect(parseFQN('crm__account')).toEqual({ namespace: 'crm', shortName: 'account' });
31
+ expect(parseFQN('todo__task')).toEqual({ namespace: 'todo', shortName: 'task' });
32
+ });
33
+
34
+ it('should parse unprefixed names', () => {
35
+ expect(parseFQN('user')).toEqual({ namespace: undefined, shortName: 'user' });
36
+ expect(parseFQN('task')).toEqual({ namespace: undefined, shortName: 'task' });
37
+ });
38
+ });
39
+
40
+ // ==========================================
41
+ // Namespace Management Tests
42
+ // ==========================================
43
+ describe('Namespace Management', () => {
44
+ it('should register namespace', () => {
45
+ SchemaRegistry.registerNamespace('crm', 'com.example.crm');
46
+ expect(SchemaRegistry.getNamespaceOwner('crm')).toBe('com.example.crm');
47
+ });
48
+
49
+ it('should allow same package to re-register namespace', () => {
50
+ SchemaRegistry.registerNamespace('crm', 'com.example.crm');
51
+ expect(() => {
52
+ SchemaRegistry.registerNamespace('crm', 'com.example.crm');
53
+ }).not.toThrow();
54
+ });
55
+
56
+ it('should throw on namespace conflict', () => {
57
+ SchemaRegistry.registerNamespace('crm', 'com.example.crm');
58
+ expect(() => {
59
+ SchemaRegistry.registerNamespace('crm', 'com.other.crm');
60
+ }).toThrow(/already registered/);
61
+ });
62
+
63
+ it('should unregister namespace', () => {
64
+ SchemaRegistry.registerNamespace('crm', 'com.example.crm');
65
+ SchemaRegistry.unregisterNamespace('crm', 'com.example.crm');
66
+ expect(SchemaRegistry.getNamespaceOwner('crm')).toBeUndefined();
67
+ });
68
+ });
69
+
70
+ // ==========================================
71
+ // Object Ownership Tests
72
+ // ==========================================
73
+ describe('Object Ownership', () => {
74
+ it('should register owned object with FQN', () => {
75
+ const obj = { name: 'account', fields: { name: { type: 'text' } } };
76
+ const fqn = SchemaRegistry.registerObject(obj as any, 'com.example.crm', 'crm', 'own');
77
+
78
+ expect(fqn).toBe('crm__account');
79
+ const resolved = SchemaRegistry.getObject('crm__account');
80
+ expect(resolved).toBeDefined();
81
+ expect(resolved?.name).toBe('crm__account');
82
+ });
83
+
84
+ it('should register object without namespace (legacy)', () => {
85
+ const obj = { name: 'task', fields: {} };
86
+ const fqn = SchemaRegistry.registerObject(obj as any, 'com.example.app');
87
+
88
+ expect(fqn).toBe('task');
89
+ expect(SchemaRegistry.getObject('task')).toBeDefined();
90
+ });
91
+
92
+ it('should allow only one owner per FQN', () => {
93
+ // Register first owner
94
+ const obj = { name: 'shared', fields: {} };
95
+ SchemaRegistry.registerObject(obj as any, 'com.vendor.a', 'vendor_a', 'own');
96
+
97
+ // Second vendor tries to own the same FQN via extension targeting
98
+ // They cannot own an object that's already owned by another package
99
+ const obj2 = { name: 'vendor_a__shared', fields: {} };
100
+ expect(() => {
101
+ SchemaRegistry.registerObject(obj2 as any, 'com.vendor.b', undefined, 'own');
102
+ }).toThrow(/already owned/);
103
+ });
104
+
105
+ it('should allow re-registration by same owner', () => {
106
+ const obj = { name: 'account', fields: { v1: { type: 'text' } } };
107
+ SchemaRegistry.registerObject(obj as any, 'com.example.crm', 'crm', 'own');
108
+
109
+ const obj2 = { name: 'account', fields: { v2: { type: 'text' } } };
110
+ expect(() => {
111
+ SchemaRegistry.registerObject(obj2 as any, 'com.example.crm', 'crm', 'own');
112
+ }).not.toThrow();
113
+
114
+ // Should have new fields
115
+ const resolved = SchemaRegistry.getObject('crm__account');
116
+ expect(resolved?.fields).toHaveProperty('v2');
117
+ });
118
+ });
119
+
120
+ // ==========================================
121
+ // Object Extension Tests
122
+ // ==========================================
123
+ describe('Object Extension', () => {
124
+ it('should merge extension fields into owner', () => {
125
+ const owner = { name: 'contact', fields: { email: { type: 'text' } } };
126
+ SchemaRegistry.registerObject(owner as any, 'com.base', 'base', 'own');
127
+
128
+ const ext = { name: 'contact', fields: { phone: { type: 'text' } } };
129
+ SchemaRegistry.registerObject(ext as any, 'com.crm', undefined, 'extend', 200);
130
+
131
+ const resolved = SchemaRegistry.getObject('contact');
132
+ expect(resolved?.fields).toHaveProperty('email');
133
+ expect(resolved?.fields).toHaveProperty('phone');
134
+ });
135
+
136
+ it('should apply priority order (higher wins)', () => {
137
+ const owner = { name: 'task', label: 'Task', fields: {} };
138
+ SchemaRegistry.registerObject(owner as any, 'com.base', 'base', 'own', 100);
139
+
140
+ const ext1 = { name: 'task', label: 'Extended Task', fields: {} };
141
+ SchemaRegistry.registerObject(ext1 as any, 'com.ext1', undefined, 'extend', 150);
142
+
143
+ const ext2 = { name: 'task', label: 'Final Task', fields: {} };
144
+ SchemaRegistry.registerObject(ext2 as any, 'com.ext2', undefined, 'extend', 250);
145
+
146
+ const resolved = SchemaRegistry.getObject('task');
147
+ expect(resolved?.label).toBe('Final Task'); // Higher priority wins
148
+ });
149
+
150
+ it('should merge validations additively', () => {
151
+ const owner = { name: 'order', fields: {}, validations: [{ type: 'required', field: 'id' }] };
152
+ SchemaRegistry.registerObject(owner as any, 'com.base', 'base', 'own');
153
+
154
+ const ext = { name: 'order', fields: {}, validations: [{ type: 'required', field: 'status' }] };
155
+ SchemaRegistry.registerObject(ext as any, 'com.ext', undefined, 'extend');
156
+
157
+ const resolved = SchemaRegistry.getObject('order');
158
+ expect(resolved?.validations).toHaveLength(2);
159
+ });
160
+
161
+ it('should fail extension without owner', () => {
162
+ const ext = { name: 'phantom', fields: {} };
163
+ SchemaRegistry.registerObject(ext as any, 'com.ext', undefined, 'extend');
164
+
165
+ // Should not be resolvable (no owner)
166
+ const resolved = SchemaRegistry.getObject('phantom');
167
+ expect(resolved).toBeUndefined();
168
+ });
169
+ });
170
+
171
+ // ==========================================
172
+ // Object Resolution Tests
173
+ // ==========================================
174
+ describe('Object Resolution', () => {
175
+ it('should resolve by FQN', () => {
176
+ const obj = { name: 'deal', fields: {} };
177
+ SchemaRegistry.registerObject(obj as any, 'com.crm', 'crm', 'own');
178
+
179
+ expect(SchemaRegistry.resolveObject('crm__deal')).toBeDefined();
180
+ });
181
+
182
+ it('should resolve by short name (fallback)', () => {
183
+ const obj = { name: 'task', fields: {} };
184
+ SchemaRegistry.registerObject(obj as any, 'com.todo', 'todo', 'own');
185
+
186
+ // Should find via fallback scan
187
+ expect(SchemaRegistry.getObject('task')).toBeDefined();
188
+ });
189
+
190
+ it('should cache merged objects', () => {
191
+ const obj = { name: 'cached', fields: {} };
192
+ SchemaRegistry.registerObject(obj as any, 'com.test', 'test', 'own');
193
+
194
+ const first = SchemaRegistry.resolveObject('test__cached');
195
+ const second = SchemaRegistry.resolveObject('test__cached');
196
+ expect(first).toBe(second); // Same reference (cached)
197
+ });
198
+
199
+ it('should invalidate cache on re-registration', () => {
200
+ const obj = { name: 'evolve', fields: { v1: { type: 'text' } } };
201
+ SchemaRegistry.registerObject(obj as any, 'com.test', 'test', 'own');
202
+
203
+ const first = SchemaRegistry.resolveObject('test__evolve');
204
+
205
+ const obj2 = { name: 'evolve', fields: { v2: { type: 'text' } } };
206
+ SchemaRegistry.registerObject(obj2 as any, 'com.test', 'test', 'own');
207
+
208
+ const second = SchemaRegistry.resolveObject('test__evolve');
209
+ expect(first).not.toBe(second); // Different reference (cache invalidated)
210
+ expect(second?.fields).toHaveProperty('v2');
211
+ });
212
+ });
213
+
214
+ // ==========================================
215
+ // getAllObjects Tests
216
+ // ==========================================
217
+ describe('getAllObjects', () => {
218
+ it('should return all merged objects', () => {
219
+ SchemaRegistry.registerObject({ name: 'a', fields: {} } as any, 'com.pkg1', 'pkg1', 'own');
220
+ SchemaRegistry.registerObject({ name: 'b', fields: {} } as any, 'com.pkg2', 'pkg2', 'own');
221
+
222
+ const all = SchemaRegistry.getAllObjects();
223
+ expect(all).toHaveLength(2);
224
+ expect(all.map(o => o.name).sort()).toEqual(['pkg1__a', 'pkg2__b']);
225
+ });
226
+
227
+ it('should filter by packageId', () => {
228
+ SchemaRegistry.registerObject({ name: 'a', fields: {} } as any, 'com.pkg1', 'pkg1', 'own');
229
+ SchemaRegistry.registerObject({ name: 'b', fields: {} } as any, 'com.pkg2', 'pkg2', 'own');
230
+
231
+ const filtered = SchemaRegistry.getAllObjects('com.pkg1');
232
+ expect(filtered).toHaveLength(1);
233
+ expect(filtered[0].name).toBe('pkg1__a');
234
+ });
235
+
236
+ it('should include objects where package is extender', () => {
237
+ SchemaRegistry.registerObject({ name: 'base_obj', fields: {} } as any, 'com.owner', 'base', 'own');
238
+ SchemaRegistry.registerObject({ name: 'base_obj', fields: { ext: { type: 'text' } } } as any, 'com.extender', undefined, 'extend');
239
+
240
+ // Extender should see the object
241
+ const filtered = SchemaRegistry.getAllObjects('com.extender');
242
+ expect(filtered).toHaveLength(1);
243
+ });
244
+ });
245
+
246
+ // ==========================================
247
+ // Uninstall Tests
248
+ // ==========================================
249
+ describe('Uninstall', () => {
250
+ it('should remove owner contribution', () => {
251
+ SchemaRegistry.registerObject({ name: 'removable', fields: {} } as any, 'com.pkg', 'pkg', 'own');
252
+ expect(SchemaRegistry.getObject('pkg__removable')).toBeDefined();
253
+
254
+ SchemaRegistry.unregisterObjectsByPackage('com.pkg');
255
+ expect(SchemaRegistry.getObject('pkg__removable')).toBeUndefined();
256
+ });
257
+
258
+ it('should remove extension contribution', () => {
259
+ SchemaRegistry.registerObject({ name: 'target', fields: { base: { type: 'text' } } } as any, 'com.owner', 'base', 'own');
260
+ SchemaRegistry.registerObject({ name: 'target', fields: { ext: { type: 'text' } } } as any, 'com.ext', undefined, 'extend');
261
+
262
+ SchemaRegistry.unregisterObjectsByPackage('com.ext');
263
+
264
+ const resolved = SchemaRegistry.getObject('target');
265
+ expect(resolved?.fields).toHaveProperty('base');
266
+ expect(resolved?.fields).not.toHaveProperty('ext');
267
+ });
268
+
269
+ it('should prevent uninstall of owner with active extenders', () => {
270
+ SchemaRegistry.registerObject({ name: 'important', fields: {} } as any, 'com.owner', 'base', 'own');
271
+ SchemaRegistry.registerObject({ name: 'important', fields: {} } as any, 'com.ext', undefined, 'extend');
272
+
273
+ expect(() => {
274
+ SchemaRegistry.unregisterObjectsByPackage('com.owner');
275
+ }).toThrow(/extended by/);
276
+ });
277
+
278
+ it('should allow force uninstall of owner with extenders', () => {
279
+ SchemaRegistry.registerObject({ name: 'forced', fields: {} } as any, 'com.owner', 'base', 'own');
280
+ SchemaRegistry.registerObject({ name: 'forced', fields: {} } as any, 'com.ext', undefined, 'extend');
281
+
282
+ expect(() => {
283
+ SchemaRegistry.unregisterObjectsByPackage('com.owner', true);
284
+ }).not.toThrow();
285
+ });
286
+ });
287
+
288
+ // ==========================================
289
+ // Contributors API Tests
290
+ // ==========================================
291
+ describe('Contributors API', () => {
292
+ it('should return all contributors for object', () => {
293
+ SchemaRegistry.registerObject({ name: 'multi', fields: {} } as any, 'com.owner', 'pkg', 'own', 100);
294
+ SchemaRegistry.registerObject({ name: 'pkg__multi', fields: {} } as any, 'com.ext1', undefined, 'extend', 200);
295
+ SchemaRegistry.registerObject({ name: 'pkg__multi', fields: {} } as any, 'com.ext2', undefined, 'extend', 300);
296
+
297
+ const contribs = SchemaRegistry.getObjectContributors('pkg__multi');
298
+ expect(contribs).toHaveLength(3);
299
+ expect(contribs[0].priority).toBe(100); // Sorted by priority
300
+ expect(contribs[1].priority).toBe(200);
301
+ expect(contribs[2].priority).toBe(300);
302
+ });
303
+
304
+ it('should return owner contributor', () => {
305
+ SchemaRegistry.registerObject({ name: 'owned', fields: {} } as any, 'com.owner', 'pkg', 'own');
306
+
307
+ const owner = SchemaRegistry.getObjectOwner('pkg__owned');
308
+ expect(owner).toBeDefined();
309
+ expect(owner?.packageId).toBe('com.owner');
310
+ expect(owner?.ownership).toBe('own');
311
+ });
312
+ });
313
+
314
+ // ==========================================
315
+ // Generic Metadata Tests (Non-Object)
316
+ // ==========================================
317
+ describe('Generic Metadata', () => {
318
+ it('should register and retrieve generic items', () => {
319
+ const item = { name: 'test_action', type: 'custom' };
320
+ SchemaRegistry.registerItem('actions', item, 'name', 'com.pkg');
321
+
322
+ const retrieved = SchemaRegistry.getItem('actions', 'test_action');
323
+ expect(retrieved).toEqual(item);
324
+ });
325
+
326
+ it('should list items by type with package filter', () => {
327
+ SchemaRegistry.registerItem('actions', { name: 'a1' }, 'name', 'com.pkg1');
328
+ SchemaRegistry.registerItem('actions', { name: 'a2' }, 'name', 'com.pkg2');
329
+
330
+ const filtered = SchemaRegistry.listItems('actions', 'com.pkg1');
331
+ expect(filtered).toHaveLength(1);
332
+ });
333
+ });
334
+
335
+ // ==========================================
336
+ // Package Management Tests
337
+ // ==========================================
338
+ describe('Package Management', () => {
339
+ it('should install package with namespace', () => {
340
+ const manifest = { id: 'com.test', name: 'Test', namespace: 'test', version: '1.0.0' };
341
+ const pkg = SchemaRegistry.installPackage(manifest as any);
342
+
343
+ expect(pkg.status).toBe('installed');
344
+ expect(SchemaRegistry.getNamespaceOwner('test')).toBe('com.test');
345
+ });
346
+
347
+ it('should uninstall package and release namespace', () => {
348
+ const manifest = { id: 'com.test', name: 'Test', namespace: 'test', version: '1.0.0' };
349
+ SchemaRegistry.installPackage(manifest as any);
350
+
351
+ SchemaRegistry.uninstallPackage('com.test');
352
+ expect(SchemaRegistry.getPackage('com.test')).toBeUndefined();
353
+ expect(SchemaRegistry.getNamespaceOwner('test')).toBeUndefined();
354
+ });
355
+ });
356
+
357
+ // ==========================================
358
+ // Reset Tests
359
+ // ==========================================
360
+ describe('Reset', () => {
361
+ it('should clear all state', () => {
362
+ SchemaRegistry.registerObject({ name: 'obj', fields: {} } as any, 'com.pkg', 'pkg', 'own');
363
+ SchemaRegistry.registerItem('actions', { name: 'act' }, 'name');
364
+
365
+ SchemaRegistry.reset();
366
+
367
+ expect(SchemaRegistry.getAllObjects()).toHaveLength(0);
368
+ expect(SchemaRegistry.listItems('actions')).toHaveLength(0);
369
+ });
370
+ });
371
+
372
+ // ==========================================
373
+ // listItems/getItem for 'object' type Tests
374
+ // ==========================================
375
+ describe('listItems and getItem for object type', () => {
376
+ it('listItems("object") should return all registered objects', () => {
377
+ SchemaRegistry.registerObject(
378
+ { name: 'account', label: 'Account', fields: {} } as any,
379
+ 'com.crm',
380
+ 'crm',
381
+ 'own'
382
+ );
383
+ SchemaRegistry.registerObject(
384
+ { name: 'contact', label: 'Contact', fields: {} } as any,
385
+ 'com.crm',
386
+ 'crm',
387
+ 'own'
388
+ );
389
+
390
+ const objects = SchemaRegistry.listItems('object');
391
+ expect(objects).toHaveLength(2);
392
+ expect(objects.map((o: any) => o.name).sort()).toEqual(['crm__account', 'crm__contact']);
393
+ });
394
+
395
+ it('listItems("objects") should return all registered objects (plural alias)', () => {
396
+ SchemaRegistry.registerObject(
397
+ { name: 'task', label: 'Task', fields: {} } as any,
398
+ 'com.todo',
399
+ 'todo',
400
+ 'own'
401
+ );
402
+
403
+ const objects = SchemaRegistry.listItems('objects');
404
+ expect(objects).toHaveLength(1);
405
+ expect((objects[0] as any).name).toBe('todo__task');
406
+ });
407
+
408
+ it('getItem("object", fqn) should return object by FQN', () => {
409
+ SchemaRegistry.registerObject(
410
+ { name: 'lead', label: 'Lead', fields: { status: { type: 'text' } } } as any,
411
+ 'com.crm',
412
+ 'crm',
413
+ 'own'
414
+ );
415
+
416
+ const obj = SchemaRegistry.getItem('object', 'crm__lead');
417
+ expect(obj).toBeDefined();
418
+ expect((obj as any).name).toBe('crm__lead');
419
+ expect((obj as any).label).toBe('Lead');
420
+ });
421
+
422
+ it('getItem("object", shortName) should return object by short name fallback', () => {
423
+ SchemaRegistry.registerObject(
424
+ { name: 'opportunity', label: 'Opportunity', fields: {} } as any,
425
+ 'com.crm',
426
+ 'crm',
427
+ 'own'
428
+ );
429
+
430
+ const obj = SchemaRegistry.getItem('object', 'opportunity');
431
+ expect(obj).toBeDefined();
432
+ expect((obj as any).name).toBe('crm__opportunity');
433
+ });
434
+
435
+ it('listItems("object", packageId) should filter by package', () => {
436
+ SchemaRegistry.registerObject(
437
+ { name: 'account', fields: {} } as any,
438
+ 'com.crm',
439
+ 'crm',
440
+ 'own'
441
+ );
442
+ SchemaRegistry.registerObject(
443
+ { name: 'task', fields: {} } as any,
444
+ 'com.todo',
445
+ 'todo',
446
+ 'own'
447
+ );
448
+
449
+ const crmObjects = SchemaRegistry.listItems('object', 'com.crm');
450
+ expect(crmObjects).toHaveLength(1);
451
+ expect((crmObjects[0] as any).name).toBe('crm__account');
452
+
453
+ const todoObjects = SchemaRegistry.listItems('object', 'com.todo');
454
+ expect(todoObjects).toHaveLength(1);
455
+ expect((todoObjects[0] as any).name).toBe('todo__task');
456
+ });
457
+ });
458
+ });