@objectstack/objectql 3.3.1 → 4.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 +25 -0
- package/dist/index.d.mts +27 -17
- package/dist/index.d.ts +27 -17
- package/dist/index.js +221 -109
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +221 -109
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/engine.test.ts +13 -13
- package/src/engine.ts +36 -77
- package/src/plugin.ts +2 -2
- package/src/protocol-data.test.ts +41 -38
- package/src/protocol-discovery.test.ts +25 -25
- package/src/protocol-meta.test.ts +440 -0
- package/src/protocol.ts +258 -68
- package/tsconfig.json +2 -1
|
@@ -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('
|
|
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
|
|
152
|
-
expect(discovery.capabilities!.feed).
|
|
153
|
-
expect(discovery.capabilities!.comments).
|
|
154
|
-
expect(discovery.capabilities!.automation).
|
|
155
|
-
expect(discovery.capabilities!.cron).
|
|
156
|
-
expect(discovery.capabilities!.search).
|
|
157
|
-
expect(discovery.capabilities!.export).
|
|
158
|
-
expect(discovery.capabilities!.chunkedUpload).
|
|
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).
|
|
167
|
-
expect(discovery.capabilities!.comments).
|
|
168
|
-
expect(discovery.capabilities!.automation).
|
|
169
|
-
expect(discovery.capabilities!.cron).
|
|
170
|
-
expect(discovery.capabilities!.search).
|
|
171
|
-
expect(discovery.capabilities!.export).
|
|
172
|
-
expect(discovery.capabilities!.chunkedUpload).
|
|
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).
|
|
186
|
-
expect(discovery.capabilities!.comments).
|
|
187
|
-
expect(discovery.capabilities!.automation).
|
|
188
|
-
expect(discovery.capabilities!.cron).
|
|
189
|
-
expect(discovery.capabilities!.search).
|
|
190
|
-
expect(discovery.capabilities!.export).
|
|
191
|
-
expect(discovery.capabilities!.chunkedUpload).
|
|
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).
|
|
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).
|
|
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
|
+
});
|