@objectstack/objectql 3.3.1 → 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.
@@ -92,7 +92,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
92
92
 
93
93
  // Core services should always be available
94
94
  expect(discovery.services.metadata.enabled).toBe(true);
95
- expect(discovery.services.metadata.status).toBe('degraded');
95
+ expect(discovery.services.metadata.status).toBe('available');
96
96
  expect(discovery.services.data.enabled).toBe(true);
97
97
  expect(discovery.services.data.status).toBe('available');
98
98
  expect(discovery.services.analytics.enabled).toBe(true);
@@ -148,14 +148,14 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
148
148
  expect(discovery.capabilities).toBeDefined();
149
149
  // workflow is registered but doesn't map to a well-known capability directly
150
150
  expect(discovery.services.workflow.enabled).toBe(true);
151
- // All well-known capabilities should be false since workflow doesn't map to any
152
- expect(discovery.capabilities!.feed).toBe(false);
153
- expect(discovery.capabilities!.comments).toBe(false);
154
- expect(discovery.capabilities!.automation).toBe(false);
155
- expect(discovery.capabilities!.cron).toBe(false);
156
- expect(discovery.capabilities!.search).toBe(false);
157
- expect(discovery.capabilities!.export).toBe(false);
158
- expect(discovery.capabilities!.chunkedUpload).toBe(false);
151
+ // All well-known capabilities should be disabled since workflow doesn't map to any
152
+ expect(discovery.capabilities!.feed).toEqual({ enabled: false });
153
+ expect(discovery.capabilities!.comments).toEqual({ enabled: false });
154
+ expect(discovery.capabilities!.automation).toEqual({ enabled: false });
155
+ expect(discovery.capabilities!.cron).toEqual({ enabled: false });
156
+ expect(discovery.capabilities!.search).toEqual({ enabled: false });
157
+ expect(discovery.capabilities!.export).toEqual({ enabled: false });
158
+ expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: false });
159
159
  });
160
160
 
161
161
  it('should set all capabilities to false when no services are registered', async () => {
@@ -163,13 +163,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
163
163
  const discovery = await protocol.getDiscovery();
164
164
 
165
165
  expect(discovery.capabilities).toBeDefined();
166
- expect(discovery.capabilities!.feed).toBe(false);
167
- expect(discovery.capabilities!.comments).toBe(false);
168
- expect(discovery.capabilities!.automation).toBe(false);
169
- expect(discovery.capabilities!.cron).toBe(false);
170
- expect(discovery.capabilities!.search).toBe(false);
171
- expect(discovery.capabilities!.export).toBe(false);
172
- expect(discovery.capabilities!.chunkedUpload).toBe(false);
166
+ expect(discovery.capabilities!.feed).toEqual({ enabled: false });
167
+ expect(discovery.capabilities!.comments).toEqual({ enabled: false });
168
+ expect(discovery.capabilities!.automation).toEqual({ enabled: false });
169
+ expect(discovery.capabilities!.cron).toEqual({ enabled: false });
170
+ expect(discovery.capabilities!.search).toEqual({ enabled: false });
171
+ expect(discovery.capabilities!.export).toEqual({ enabled: false });
172
+ expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: false });
173
173
  });
174
174
 
175
175
  it('should dynamically set capabilities based on registered services', async () => {
@@ -182,13 +182,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
182
182
  protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
183
183
  const discovery = await protocol.getDiscovery();
184
184
 
185
- expect(discovery.capabilities!.feed).toBe(true);
186
- expect(discovery.capabilities!.comments).toBe(true);
187
- expect(discovery.capabilities!.automation).toBe(true);
188
- expect(discovery.capabilities!.cron).toBe(false);
189
- expect(discovery.capabilities!.search).toBe(true);
190
- expect(discovery.capabilities!.export).toBe(true);
191
- expect(discovery.capabilities!.chunkedUpload).toBe(true);
185
+ expect(discovery.capabilities!.feed).toEqual({ enabled: true });
186
+ expect(discovery.capabilities!.comments).toEqual({ enabled: true });
187
+ expect(discovery.capabilities!.automation).toEqual({ enabled: true });
188
+ expect(discovery.capabilities!.cron).toEqual({ enabled: false });
189
+ expect(discovery.capabilities!.search).toEqual({ enabled: true });
190
+ expect(discovery.capabilities!.export).toEqual({ enabled: true });
191
+ expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: true });
192
192
  });
193
193
 
194
194
  it('should enable cron capability when job service is registered', async () => {
@@ -198,7 +198,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
198
198
  protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
199
199
  const discovery = await protocol.getDiscovery();
200
200
 
201
- expect(discovery.capabilities!.cron).toBe(true);
201
+ expect(discovery.capabilities!.cron).toEqual({ enabled: true });
202
202
  });
203
203
 
204
204
  it('should enable export capability when queue service is registered', async () => {
@@ -208,6 +208,6 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
208
208
  protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
209
209
  const discovery = await protocol.getDiscovery();
210
210
 
211
- expect(discovery.capabilities!.export).toBe(true);
211
+ expect(discovery.capabilities!.export).toEqual({ enabled: true });
212
212
  });
213
213
  });
@@ -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
+ });