@objectstack/objectql 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,440 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { ObjectStackProtocolImplementation } from './protocol.js';
5
+ import { SchemaRegistry } from './registry.js';
6
+
7
+ /**
8
+ * Tests for the Protocol Implementation's metadata persistence methods.
9
+ * Validates dual-write strategy (SchemaRegistry + database), DB fallback for reads,
10
+ * graceful degradation when DB is unavailable, and the loadMetaFromDb() bootstrap method.
11
+ */
12
+ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
13
+ let protocol: ObjectStackProtocolImplementation;
14
+ let mockEngine: any;
15
+
16
+ const sampleApp = {
17
+ name: 'test_app',
18
+ label: 'Test App',
19
+ description: 'A test application',
20
+ };
21
+
22
+ beforeEach(() => {
23
+ // Reset SchemaRegistry state between tests
24
+ SchemaRegistry.reset();
25
+
26
+ mockEngine = {
27
+ find: vi.fn().mockResolvedValue([]),
28
+ findOne: vi.fn().mockResolvedValue(null),
29
+ insert: vi.fn().mockResolvedValue({ id: 'new-uuid' }),
30
+ update: vi.fn().mockResolvedValue({ id: 'existing-uuid' }),
31
+ delete: vi.fn().mockResolvedValue({ deleted: 1 }),
32
+ count: vi.fn().mockResolvedValue(0),
33
+ aggregate: vi.fn().mockResolvedValue([]),
34
+ };
35
+ protocol = new ObjectStackProtocolImplementation(mockEngine);
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.clearAllMocks();
40
+ SchemaRegistry.reset();
41
+ });
42
+
43
+ // ═══════════════════════════════════════════════════════════════
44
+ // saveMetaItem — dual-write (registry + database)
45
+ // ═══════════════════════════════════════════════════════════════
46
+
47
+ describe('saveMetaItem', () => {
48
+ it('should throw when item data is missing', async () => {
49
+ await expect(
50
+ protocol.saveMetaItem({ type: 'app', name: 'test_app' })
51
+ ).rejects.toThrow('Item data is required');
52
+ });
53
+
54
+ it('should register item in SchemaRegistry', async () => {
55
+ await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
56
+
57
+ const stored = SchemaRegistry.getItem('app', 'test_app');
58
+ expect(stored).toEqual(sampleApp);
59
+ });
60
+
61
+ it('should insert a new record in the database when item does not exist', async () => {
62
+ mockEngine.findOne.mockResolvedValue(null); // not existing
63
+
64
+ await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
65
+
66
+ expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
67
+ where: { type: 'app', name: 'test_app' }
68
+ });
69
+ expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
70
+ name: 'test_app',
71
+ type: 'app',
72
+ scope: 'platform',
73
+ state: 'active',
74
+ version: 1,
75
+ metadata: JSON.stringify(sampleApp),
76
+ }));
77
+ });
78
+
79
+ it('should update an existing record in the database and increment version', async () => {
80
+ const existingRecord = { id: 'existing-uuid', version: 2 };
81
+ mockEngine.findOne.mockResolvedValue(existingRecord);
82
+
83
+ await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
84
+
85
+ expect(mockEngine.update).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
86
+ metadata: JSON.stringify(sampleApp),
87
+ version: 3, // incremented from 2
88
+ }), {
89
+ where: { id: 'existing-uuid' }
90
+ });
91
+ expect(mockEngine.insert).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('should return success=true and "Saved to database and registry" on DB success', async () => {
95
+ const result = await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
96
+
97
+ expect(result.success).toBe(true);
98
+ expect(result.message).toBe('Saved to database and registry');
99
+ });
100
+
101
+ it('should degrade gracefully when DB is unavailable', async () => {
102
+ mockEngine.findOne.mockRejectedValue(new Error('Connection refused'));
103
+
104
+ const result = await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
105
+
106
+ expect(result.success).toBe(true);
107
+ expect(result.message).toContain('memory registry');
108
+ expect((result as any).warning).toContain('Connection refused');
109
+
110
+ // Registry should still be updated
111
+ const stored = SchemaRegistry.getItem('app', 'test_app');
112
+ expect(stored).toEqual(sampleApp);
113
+ });
114
+
115
+ it('should degrade gracefully when DB insert fails', async () => {
116
+ mockEngine.findOne.mockResolvedValue(null);
117
+ mockEngine.insert.mockRejectedValue(new Error('Table not found'));
118
+
119
+ const result = await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
120
+
121
+ expect(result.success).toBe(true);
122
+ expect(result.message).toContain('memory registry');
123
+ });
124
+
125
+ it('should use version=1 for initial insert when existing record has no version', async () => {
126
+ mockEngine.findOne.mockResolvedValue(null);
127
+
128
+ await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
129
+
130
+ expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
131
+ version: 1,
132
+ }));
133
+ });
134
+
135
+ it('should handle existing record with version=0 and increment to 1', async () => {
136
+ mockEngine.findOne.mockResolvedValue({ id: 'uuid', version: 0 });
137
+
138
+ await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
139
+
140
+ expect(mockEngine.update).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
141
+ version: 1,
142
+ }), expect.anything());
143
+ });
144
+ });
145
+
146
+ // ═══════════════════════════════════════════════════════════════
147
+ // getMetaItem — registry-first, DB fallback
148
+ // ═══════════════════════════════════════════════════════════════
149
+
150
+ describe('getMetaItem', () => {
151
+ it('should return item from SchemaRegistry when it exists', async () => {
152
+ SchemaRegistry.registerItem('app', sampleApp, 'name');
153
+
154
+ const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' });
155
+
156
+ expect(result.item).toEqual(sampleApp);
157
+ // DB should NOT be queried
158
+ expect(mockEngine.findOne).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('should fall back to DB when item is not in registry', async () => {
162
+ mockEngine.findOne.mockResolvedValue({
163
+ type: 'app',
164
+ name: 'test_app',
165
+ state: 'active',
166
+ metadata: JSON.stringify(sampleApp),
167
+ });
168
+
169
+ const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' });
170
+
171
+ expect(result.item).toEqual(sampleApp);
172
+ expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
173
+ where: { type: 'app', name: 'test_app', state: 'active' }
174
+ });
175
+ });
176
+
177
+ it('should hydrate registry after DB fallback', async () => {
178
+ mockEngine.findOne.mockResolvedValue({
179
+ type: 'app',
180
+ name: 'test_app',
181
+ state: 'active',
182
+ metadata: JSON.stringify(sampleApp),
183
+ });
184
+
185
+ await protocol.getMetaItem({ type: 'app', name: 'test_app' });
186
+
187
+ // Should now be in registry
188
+ const cached = SchemaRegistry.getItem('app', 'test_app');
189
+ expect(cached).toEqual(sampleApp);
190
+ });
191
+
192
+ it('should try alternate type name in DB when primary type not found', async () => {
193
+ // 'app' not found, try 'apps'
194
+ mockEngine.findOne
195
+ .mockResolvedValueOnce(null) // first call: type='app' not found
196
+ .mockResolvedValueOnce({ // second call: type='apps' found
197
+ type: 'apps',
198
+ name: 'test_app',
199
+ state: 'active',
200
+ metadata: JSON.stringify(sampleApp),
201
+ });
202
+
203
+ const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' });
204
+
205
+ expect(result.item).toEqual(sampleApp);
206
+ expect(mockEngine.findOne).toHaveBeenCalledTimes(2);
207
+ });
208
+
209
+ it('should return undefined item when not in registry or DB', async () => {
210
+ mockEngine.findOne.mockResolvedValue(null);
211
+
212
+ const result = await protocol.getMetaItem({ type: 'app', name: 'nonexistent' });
213
+
214
+ expect(result.item).toBeUndefined();
215
+ });
216
+
217
+ it('should handle DB errors gracefully and return undefined item', async () => {
218
+ mockEngine.findOne.mockRejectedValue(new Error('DB down'));
219
+
220
+ const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' });
221
+
222
+ expect(result.item).toBeUndefined();
223
+ expect(result.type).toBe('app');
224
+ expect(result.name).toBe('test_app');
225
+ });
226
+
227
+ it('should parse metadata JSON string from DB record', async () => {
228
+ const complexData = { name: 'complex', nested: { value: 42 } };
229
+ mockEngine.findOne.mockResolvedValue({
230
+ type: 'object',
231
+ name: 'complex',
232
+ state: 'active',
233
+ metadata: JSON.stringify(complexData),
234
+ });
235
+
236
+ const result = await protocol.getMetaItem({ type: 'object', name: 'complex' });
237
+
238
+ expect(result.item).toEqual(complexData);
239
+ });
240
+
241
+ it('should handle metadata already parsed as object from DB', async () => {
242
+ mockEngine.findOne.mockResolvedValue({
243
+ type: 'app',
244
+ name: 'test_app',
245
+ state: 'active',
246
+ metadata: sampleApp, // already an object, not a string
247
+ });
248
+
249
+ const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' });
250
+
251
+ expect(result.item).toEqual(sampleApp);
252
+ });
253
+ });
254
+
255
+ // ═══════════════════════════════════════════════════════════════
256
+ // getMetaItems — registry-first, DB fallback
257
+ // ═══════════════════════════════════════════════════════════════
258
+
259
+ describe('getMetaItems', () => {
260
+ it('should return items from SchemaRegistry when they exist', async () => {
261
+ SchemaRegistry.registerItem('app', sampleApp, 'name');
262
+ SchemaRegistry.registerItem('app', { name: 'app2', label: 'App 2' }, 'name');
263
+
264
+ const result = await protocol.getMetaItems({ type: 'app' });
265
+
266
+ expect(result.items).toHaveLength(2);
267
+ // DB should NOT be queried
268
+ expect(mockEngine.find).not.toHaveBeenCalled();
269
+ });
270
+
271
+ it('should fall back to DB when registry is empty for type', async () => {
272
+ mockEngine.find.mockResolvedValue([
273
+ {
274
+ type: 'app',
275
+ name: 'test_app',
276
+ state: 'active',
277
+ metadata: JSON.stringify(sampleApp),
278
+ }
279
+ ]);
280
+
281
+ const result = await protocol.getMetaItems({ type: 'app' });
282
+
283
+ expect(result.items).toHaveLength(1);
284
+ expect(result.items[0]).toEqual(sampleApp);
285
+ expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', {
286
+ where: { type: 'app', state: 'active' }
287
+ });
288
+ });
289
+
290
+ it('should hydrate registry after DB fallback for getMetaItems', async () => {
291
+ mockEngine.find.mockResolvedValue([
292
+ {
293
+ type: 'app',
294
+ name: 'test_app',
295
+ state: 'active',
296
+ metadata: JSON.stringify(sampleApp),
297
+ }
298
+ ]);
299
+
300
+ await protocol.getMetaItems({ type: 'app' });
301
+
302
+ // Should now be in registry
303
+ const cached = SchemaRegistry.getItem('app', 'test_app');
304
+ expect(cached).toEqual(sampleApp);
305
+ });
306
+
307
+ it('should try alternate type name in DB when primary type has no records', async () => {
308
+ mockEngine.find
309
+ .mockResolvedValueOnce([]) // 'app' returns nothing
310
+ .mockResolvedValueOnce([ // 'apps' returns results
311
+ { type: 'apps', name: 'test_app', state: 'active', metadata: JSON.stringify(sampleApp) }
312
+ ]);
313
+
314
+ const result = await protocol.getMetaItems({ type: 'app' });
315
+
316
+ expect(result.items).toHaveLength(1);
317
+ expect(mockEngine.find).toHaveBeenCalledTimes(2);
318
+ });
319
+
320
+ it('should return empty items array when DB also has no records', async () => {
321
+ mockEngine.find.mockResolvedValue([]);
322
+
323
+ const result = await protocol.getMetaItems({ type: 'app' });
324
+
325
+ expect(result.items).toHaveLength(0);
326
+ });
327
+
328
+ it('should handle DB errors gracefully and return empty items', async () => {
329
+ mockEngine.find.mockRejectedValue(new Error('DB down'));
330
+
331
+ const result = await protocol.getMetaItems({ type: 'app' });
332
+
333
+ expect(result.items).toHaveLength(0);
334
+ expect(result.type).toBe('app');
335
+ });
336
+ });
337
+
338
+ // ═══════════════════════════════════════════════════════════════
339
+ // loadMetaFromDb — startup hydration
340
+ // ═══════════════════════════════════════════════════════════════
341
+
342
+ describe('loadMetaFromDb', () => {
343
+ it('should load all active records from DB into SchemaRegistry', async () => {
344
+ const app2 = { name: 'app2', label: 'App 2' };
345
+ mockEngine.find.mockResolvedValue([
346
+ { type: 'app', name: 'test_app', state: 'active', metadata: JSON.stringify(sampleApp) },
347
+ { type: 'app', name: 'app2', state: 'active', metadata: JSON.stringify(app2) },
348
+ ]);
349
+
350
+ const result = await protocol.loadMetaFromDb();
351
+
352
+ expect(result.loaded).toBe(2);
353
+ expect(result.errors).toBe(0);
354
+
355
+ expect(SchemaRegistry.getItem('app', 'test_app')).toEqual(sampleApp);
356
+ expect(SchemaRegistry.getItem('app', 'app2')).toEqual(app2);
357
+ });
358
+
359
+ it('should query only active state records', async () => {
360
+ mockEngine.find.mockResolvedValue([]);
361
+
362
+ await protocol.loadMetaFromDb();
363
+
364
+ expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', {
365
+ where: { state: 'active' }
366
+ });
367
+ });
368
+
369
+ it('should count parse errors and continue loading other records', async () => {
370
+ mockEngine.find.mockResolvedValue([
371
+ { type: 'app', name: 'test_app', state: 'active', metadata: JSON.stringify(sampleApp) },
372
+ { type: 'object', name: 'bad', state: 'active', metadata: 'not-valid-json{{{' },
373
+ ]);
374
+
375
+ const result = await protocol.loadMetaFromDb();
376
+
377
+ expect(result.loaded).toBe(1);
378
+ expect(result.errors).toBe(1);
379
+ });
380
+
381
+ it('should return loaded=0 errors=0 when DB returns empty results', async () => {
382
+ mockEngine.find.mockResolvedValue([]);
383
+
384
+ const result = await protocol.loadMetaFromDb();
385
+
386
+ expect(result.loaded).toBe(0);
387
+ expect(result.errors).toBe(0);
388
+ });
389
+
390
+ it('should gracefully skip DB hydration when DB is unavailable', async () => {
391
+ mockEngine.find.mockRejectedValue(new Error('Connection refused'));
392
+
393
+ const result = await protocol.loadMetaFromDb();
394
+
395
+ expect(result.loaded).toBe(0);
396
+ expect(result.errors).toBe(0);
397
+ });
398
+
399
+ it('should handle metadata already parsed as an object (not string)', async () => {
400
+ mockEngine.find.mockResolvedValue([
401
+ { type: 'app', name: 'test_app', state: 'active', metadata: sampleApp }, // object, not string
402
+ ]);
403
+
404
+ const result = await protocol.loadMetaFromDb();
405
+
406
+ expect(result.loaded).toBe(1);
407
+ expect(result.errors).toBe(0);
408
+ expect(SchemaRegistry.getItem('app', 'test_app')).toEqual(sampleApp);
409
+ });
410
+
411
+ it('should load records of different types', async () => {
412
+ const objDef = { name: 'task', label: 'Task', fields: {} };
413
+ mockEngine.find.mockResolvedValue([
414
+ { type: 'app', name: 'test_app', state: 'active', metadata: JSON.stringify(sampleApp) },
415
+ { type: 'object', name: 'task', state: 'active', metadata: JSON.stringify(objDef) },
416
+ ]);
417
+
418
+ const result = await protocol.loadMetaFromDb();
419
+
420
+ expect(result.loaded).toBe(2);
421
+ expect(SchemaRegistry.getItem('app', 'test_app')).toEqual(sampleApp);
422
+ expect(SchemaRegistry.getItem('object', 'task')).toEqual(objDef);
423
+ });
424
+ });
425
+
426
+ // ═══════════════════════════════════════════════════════════════
427
+ // Discovery — metadata service status
428
+ // ═══════════════════════════════════════════════════════════════
429
+
430
+ describe('getDiscovery - metadata service status', () => {
431
+ it('should report metadata service as available (not degraded)', async () => {
432
+ const discovery = await protocol.getDiscovery();
433
+
434
+ expect(discovery.services.metadata).toBeDefined();
435
+ expect(discovery.services.metadata.enabled).toBe(true);
436
+ expect(discovery.services.metadata.status).toBe('available');
437
+ expect(discovery.services.metadata.message).toBeUndefined();
438
+ });
439
+ });
440
+ });