@objectstack/runtime 3.0.10 → 3.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.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +75 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +75 -16
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/dispatcher-plugin.ts +18 -0
- package/src/http-dispatcher.test.ts +385 -0
- package/src/http-dispatcher.ts +68 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/runtime",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "ObjectStack Core Runtime & Query Engine",
|
|
6
6
|
"type": "module",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"zod": "^4.3.6",
|
|
18
|
-
"@objectstack/core": "3.0
|
|
19
|
-
"@objectstack/rest": "3.0
|
|
20
|
-
"@objectstack/spec": "3.0
|
|
21
|
-
"@objectstack/types": "3.0
|
|
18
|
+
"@objectstack/core": "3.1.0",
|
|
19
|
+
"@objectstack/rest": "3.1.0",
|
|
20
|
+
"@objectstack/spec": "3.1.0",
|
|
21
|
+
"@objectstack/types": "3.1.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"typescript": "^5.0.0",
|
package/src/dispatcher-plugin.ts
CHANGED
|
@@ -199,6 +199,24 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
|
|
|
199
199
|
}
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
server.post(`${prefix}/packages/:id/publish`, async (req: any, res: any) => {
|
|
203
|
+
try {
|
|
204
|
+
const result = await dispatcher.handlePackages(`/${req.params.id}/publish`, 'POST', req.body, {}, { request: req });
|
|
205
|
+
sendResult(result, res);
|
|
206
|
+
} catch (err: any) {
|
|
207
|
+
errorResponse(err, res);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
server.post(`${prefix}/packages/:id/revert`, async (req: any, res: any) => {
|
|
212
|
+
try {
|
|
213
|
+
const result = await dispatcher.handlePackages(`/${req.params.id}/revert`, 'POST', req.body, {}, { request: req });
|
|
214
|
+
sendResult(result, res);
|
|
215
|
+
} catch (err: any) {
|
|
216
|
+
errorResponse(err, res);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
202
220
|
// ── Storage ─────────────────────────────────────────────────
|
|
203
221
|
server.post(`${prefix}/storage/upload`, async (req: any, res: any) => {
|
|
204
222
|
try {
|
|
@@ -204,4 +204,389 @@ describe('HttpDispatcher', () => {
|
|
|
204
204
|
expect(mockAutomationService.trigger).toHaveBeenCalledWith('flow_a', { data: 1 }, { request: {} });
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
|
+
|
|
208
|
+
// ═══════════════════════════════════════════════════════════════
|
|
209
|
+
// Async Service Resolution Tests
|
|
210
|
+
// Covers: getService awaits Promise-based (async factory) services
|
|
211
|
+
// ═══════════════════════════════════════════════════════════════
|
|
212
|
+
|
|
213
|
+
describe('Async service resolution (Promise-based injection)', () => {
|
|
214
|
+
|
|
215
|
+
describe('handleAnalytics with async service', () => {
|
|
216
|
+
it('should resolve analytics service from Promise (async factory)', async () => {
|
|
217
|
+
const mockAnalytics = {
|
|
218
|
+
query: vi.fn().mockResolvedValue({ rows: [{ id: 1 }], total: 1 }),
|
|
219
|
+
getMetadata: vi.fn().mockResolvedValue({ tables: ['t1'] }),
|
|
220
|
+
generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT 1' }),
|
|
221
|
+
};
|
|
222
|
+
// Inject as Promise (simulates async factory registration)
|
|
223
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
224
|
+
if (name === 'analytics') return Promise.resolve(mockAnalytics);
|
|
225
|
+
return null;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const result = await dispatcher.handleAnalytics('query', 'POST', { sql: 'SELECT 1' }, { request: {} });
|
|
229
|
+
expect(result.handled).toBe(true);
|
|
230
|
+
expect(result.response?.status).toBe(200);
|
|
231
|
+
expect(mockAnalytics.query).toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should handle POST /analytics/sql with async service', async () => {
|
|
235
|
+
const mockAnalytics = {
|
|
236
|
+
generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT * FROM t' }),
|
|
237
|
+
};
|
|
238
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(mockAnalytics);
|
|
239
|
+
|
|
240
|
+
const result = await dispatcher.handleAnalytics('sql', 'POST', { object: 'test' }, { request: {} });
|
|
241
|
+
expect(result.handled).toBe(true);
|
|
242
|
+
expect(result.response?.status).toBe(200);
|
|
243
|
+
expect(mockAnalytics.generateSql).toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should handle GET /analytics/meta with async service', async () => {
|
|
247
|
+
const mockAnalytics = {
|
|
248
|
+
getMetadata: vi.fn().mockResolvedValue({ tables: ['users', 'orders'] }),
|
|
249
|
+
};
|
|
250
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(mockAnalytics);
|
|
251
|
+
|
|
252
|
+
const result = await dispatcher.handleAnalytics('meta', 'GET', {}, { request: {} });
|
|
253
|
+
expect(result.handled).toBe(true);
|
|
254
|
+
expect(result.response?.status).toBe(200);
|
|
255
|
+
expect(result.response?.body?.data?.tables).toEqual(['users', 'orders']);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should return unhandled when analytics service is not registered', async () => {
|
|
259
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(null);
|
|
260
|
+
(kernel as any).services = new Map();
|
|
261
|
+
|
|
262
|
+
const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
|
|
263
|
+
expect(result.handled).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should return unhandled for unknown analytics sub-path', async () => {
|
|
267
|
+
const mockAnalytics = { query: vi.fn() };
|
|
268
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(mockAnalytics);
|
|
269
|
+
|
|
270
|
+
const result = await dispatcher.handleAnalytics('unknown', 'POST', {}, { request: {} });
|
|
271
|
+
expect(result.handled).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('handleAuth with async service', () => {
|
|
276
|
+
it('should resolve auth service from Promise', async () => {
|
|
277
|
+
const mockAuth = {
|
|
278
|
+
handler: vi.fn().mockResolvedValue({ user: { id: '1' } }),
|
|
279
|
+
};
|
|
280
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
281
|
+
if (name === 'auth') return Promise.resolve(mockAuth);
|
|
282
|
+
return null;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const result = await dispatcher.handleAuth('', 'POST', {}, { request: {}, response: {} });
|
|
286
|
+
expect(result.handled).toBe(true);
|
|
287
|
+
expect(mockAuth.handler).toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should fallback to legacy login when async auth service has no handler', async () => {
|
|
291
|
+
(kernel as any).getService = vi.fn().mockResolvedValue({});
|
|
292
|
+
mockBroker.call.mockResolvedValue({ token: 'abc' });
|
|
293
|
+
|
|
294
|
+
const result = await dispatcher.handleAuth('/login', 'POST', { user: 'a' }, { request: {} });
|
|
295
|
+
expect(result.handled).toBe(true);
|
|
296
|
+
expect(mockBroker.call).toHaveBeenCalledWith('auth.login', { user: 'a' }, { request: {} });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should return unhandled when auth service not registered and no legacy match', async () => {
|
|
300
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(null);
|
|
301
|
+
(kernel as any).services = new Map();
|
|
302
|
+
|
|
303
|
+
const result = await dispatcher.handleAuth('/profile', 'GET', {}, { request: {} });
|
|
304
|
+
expect(result.handled).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('handleStorage with async service', () => {
|
|
309
|
+
it('should resolve storage service from Promise', async () => {
|
|
310
|
+
const mockStorage = {
|
|
311
|
+
upload: vi.fn().mockResolvedValue({ id: 'file_1', url: '/files/1' }),
|
|
312
|
+
};
|
|
313
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
314
|
+
if (name === 'file-storage') return Promise.resolve(mockStorage);
|
|
315
|
+
return null;
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const result = await dispatcher.handleStorage('/upload', 'POST', { name: 'test.txt' }, { request: {} });
|
|
319
|
+
expect(result.handled).toBe(true);
|
|
320
|
+
expect(result.response?.status).toBe(200);
|
|
321
|
+
expect(mockStorage.upload).toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should return 501 when storage service is not registered (async null)', async () => {
|
|
325
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(null);
|
|
326
|
+
(kernel as any).services = new Map();
|
|
327
|
+
|
|
328
|
+
const result = await dispatcher.handleStorage('/upload', 'POST', {}, { request: {} });
|
|
329
|
+
expect(result.handled).toBe(true);
|
|
330
|
+
expect(result.response?.status).toBe(501);
|
|
331
|
+
expect(result.response?.body?.error?.message).toBe('File storage not configured');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should handle GET /storage/file/:id with async service', async () => {
|
|
335
|
+
const mockStorage = {
|
|
336
|
+
download: vi.fn().mockResolvedValue({ data: 'content', mimeType: 'text/plain' }),
|
|
337
|
+
};
|
|
338
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
339
|
+
if (name === 'file-storage') return Promise.resolve(mockStorage);
|
|
340
|
+
return null;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const result = await dispatcher.handleStorage('/file/abc123', 'GET', null, { request: {} });
|
|
344
|
+
expect(result.handled).toBe(true);
|
|
345
|
+
expect(mockStorage.download).toHaveBeenCalledWith('abc123', { request: {} });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should return 400 when upload has no file', async () => {
|
|
349
|
+
const mockStorage = { upload: vi.fn() };
|
|
350
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(mockStorage);
|
|
351
|
+
|
|
352
|
+
const result = await dispatcher.handleStorage('/upload', 'POST', null, { request: {} });
|
|
353
|
+
expect(result.handled).toBe(true);
|
|
354
|
+
expect(result.response?.status).toBe(400);
|
|
355
|
+
expect(result.response?.body?.error?.message).toBe('No file provided');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('handleAutomation with async service', () => {
|
|
360
|
+
it('should resolve automation service from Promise (async factory)', async () => {
|
|
361
|
+
const mockAuto = {
|
|
362
|
+
listFlows: vi.fn().mockResolvedValue(['f1']),
|
|
363
|
+
};
|
|
364
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
365
|
+
if (name === 'automation') return Promise.resolve(mockAuto);
|
|
366
|
+
return null;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} });
|
|
370
|
+
expect(result.handled).toBe(true);
|
|
371
|
+
expect(result.response?.body?.data?.flows).toEqual(['f1']);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should return unhandled when automation service not registered', async () => {
|
|
375
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(null);
|
|
376
|
+
(kernel as any).services = new Map();
|
|
377
|
+
|
|
378
|
+
const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} });
|
|
379
|
+
expect(result.handled).toBe(false);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('handleMetadata with async protocol service', () => {
|
|
384
|
+
it('should resolve protocol service from async getService', async () => {
|
|
385
|
+
const asyncProtocol = {
|
|
386
|
+
saveMetaItem: vi.fn().mockResolvedValue({ success: true }),
|
|
387
|
+
};
|
|
388
|
+
(kernel as any).context.getService = vi.fn().mockImplementation((name: string) => {
|
|
389
|
+
if (name === 'protocol') return Promise.resolve(asyncProtocol);
|
|
390
|
+
return null;
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {} }, 'PUT', { label: 'Test' });
|
|
394
|
+
expect(result.handled).toBe(true);
|
|
395
|
+
expect(result.response?.status).toBe(200);
|
|
396
|
+
expect(asyncProtocol.saveMetaItem).toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should fallback to broker when async protocol returns null', async () => {
|
|
400
|
+
(kernel as any).context.getService = vi.fn().mockResolvedValue(null);
|
|
401
|
+
mockBroker.call.mockResolvedValue({ name: 'my_obj' });
|
|
402
|
+
|
|
403
|
+
const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {} }, 'GET');
|
|
404
|
+
expect(result.handled).toBe(true);
|
|
405
|
+
expect(mockBroker.call).toHaveBeenCalledWith(
|
|
406
|
+
'metadata.getObject',
|
|
407
|
+
{ objectName: 'my_obj' },
|
|
408
|
+
{ request: {} }
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ═══════════════════════════════════════════════════════════════
|
|
415
|
+
// Synchronous service resolution (backward compatibility)
|
|
416
|
+
// ═══════════════════════════════════════════════════════════════
|
|
417
|
+
|
|
418
|
+
describe('Synchronous service resolution (backward compat)', () => {
|
|
419
|
+
it('should work with synchronous service from services Map', async () => {
|
|
420
|
+
const syncAnalytics = {
|
|
421
|
+
query: vi.fn().mockResolvedValue({ rows: [], total: 0 }),
|
|
422
|
+
};
|
|
423
|
+
(kernel as any).services = new Map([['analytics', syncAnalytics]]);
|
|
424
|
+
|
|
425
|
+
const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
|
|
426
|
+
expect(result.handled).toBe(true);
|
|
427
|
+
expect(syncAnalytics.query).toHaveBeenCalled();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should work with synchronous getService returning service directly', async () => {
|
|
431
|
+
const syncAuto = {
|
|
432
|
+
listFlows: vi.fn().mockResolvedValue(['flow_x']),
|
|
433
|
+
};
|
|
434
|
+
(kernel as any).getService = vi.fn().mockReturnValue(syncAuto);
|
|
435
|
+
|
|
436
|
+
const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} });
|
|
437
|
+
expect(result.handled).toBe(true);
|
|
438
|
+
expect(result.response?.body?.data?.flows).toEqual(['flow_x']);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════
|
|
443
|
+
// Error handling for service method failures
|
|
444
|
+
// ═══════════════════════════════════════════════════════════════
|
|
445
|
+
|
|
446
|
+
describe('Service method error handling', () => {
|
|
447
|
+
it('should propagate analytics query error', async () => {
|
|
448
|
+
const badAnalytics = {
|
|
449
|
+
query: vi.fn().mockRejectedValue(new Error('Query timeout')),
|
|
450
|
+
};
|
|
451
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(badAnalytics);
|
|
452
|
+
|
|
453
|
+
await expect(
|
|
454
|
+
dispatcher.handleAnalytics('query', 'POST', {}, { request: {} })
|
|
455
|
+
).rejects.toThrow('Query timeout');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should propagate storage upload error', async () => {
|
|
459
|
+
const badStorage = {
|
|
460
|
+
upload: vi.fn().mockRejectedValue(new Error('Disk full')),
|
|
461
|
+
};
|
|
462
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
463
|
+
if (name === 'file-storage') return Promise.resolve(badStorage);
|
|
464
|
+
return null;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await expect(
|
|
468
|
+
dispatcher.handleStorage('/upload', 'POST', { data: 'file' }, { request: {} })
|
|
469
|
+
).rejects.toThrow('Disk full');
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ═══════════════════════════════════════════════════════════════
|
|
474
|
+
// Package Publish / Revert Endpoints
|
|
475
|
+
// ═══════════════════════════════════════════════════════════════
|
|
476
|
+
|
|
477
|
+
describe('Package publish/revert endpoints', () => {
|
|
478
|
+
it('should handle POST /packages/:id/publish via metadata service', async () => {
|
|
479
|
+
const mockMetadata = {
|
|
480
|
+
publishPackage: vi.fn().mockResolvedValue({
|
|
481
|
+
success: true,
|
|
482
|
+
packageId: 'com.acme.crm',
|
|
483
|
+
version: 2,
|
|
484
|
+
publishedAt: '2025-06-01T00:00:00Z',
|
|
485
|
+
itemsPublished: 3,
|
|
486
|
+
}),
|
|
487
|
+
};
|
|
488
|
+
const mockRegistry = {
|
|
489
|
+
getAllPackages: vi.fn().mockReturnValue([]),
|
|
490
|
+
enablePackage: vi.fn(),
|
|
491
|
+
disablePackage: vi.fn(),
|
|
492
|
+
};
|
|
493
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
494
|
+
if (name === 'metadata') return Promise.resolve(mockMetadata);
|
|
495
|
+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
|
|
496
|
+
return null;
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const result = await dispatcher.handlePackages('/com.acme.crm/publish', 'POST', { publishedBy: 'admin' }, {}, { request: {} });
|
|
500
|
+
expect(result.handled).toBe(true);
|
|
501
|
+
expect(result.response?.status).toBe(200);
|
|
502
|
+
expect(mockMetadata.publishPackage).toHaveBeenCalledWith('com.acme.crm', { publishedBy: 'admin' });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should handle POST /packages/:id/revert via metadata service', async () => {
|
|
506
|
+
const mockMetadata = {
|
|
507
|
+
revertPackage: vi.fn().mockResolvedValue(undefined),
|
|
508
|
+
};
|
|
509
|
+
const mockRegistry = {
|
|
510
|
+
getAllPackages: vi.fn().mockReturnValue([]),
|
|
511
|
+
enablePackage: vi.fn(),
|
|
512
|
+
disablePackage: vi.fn(),
|
|
513
|
+
};
|
|
514
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
515
|
+
if (name === 'metadata') return Promise.resolve(mockMetadata);
|
|
516
|
+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
|
|
517
|
+
return null;
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const result = await dispatcher.handlePackages('/com.acme.crm/revert', 'POST', {}, {}, { request: {} });
|
|
521
|
+
expect(result.handled).toBe(true);
|
|
522
|
+
expect(result.response?.status).toBe(200);
|
|
523
|
+
expect(mockMetadata.revertPackage).toHaveBeenCalledWith('com.acme.crm');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should fallback to broker for publish when metadata service unavailable', async () => {
|
|
527
|
+
const mockRegistry = {
|
|
528
|
+
getAllPackages: vi.fn().mockReturnValue([]),
|
|
529
|
+
};
|
|
530
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
531
|
+
if (name === 'metadata') return Promise.resolve(null);
|
|
532
|
+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
|
|
533
|
+
return null;
|
|
534
|
+
});
|
|
535
|
+
mockBroker.call.mockResolvedValue({ success: true, packageId: 'crm', version: 1, publishedAt: '2025-01-01T00:00:00Z', itemsPublished: 2 });
|
|
536
|
+
|
|
537
|
+
const result = await dispatcher.handlePackages('/crm/publish', 'POST', {}, {}, { request: {} });
|
|
538
|
+
expect(result.handled).toBe(true);
|
|
539
|
+
expect(mockBroker.call).toHaveBeenCalledWith('metadata.publishPackage', { packageId: 'crm' }, { request: {} });
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// ═══════════════════════════════════════════════════════════════
|
|
544
|
+
// Metadata getPublished Endpoint
|
|
545
|
+
// ═══════════════════════════════════════════════════════════════
|
|
546
|
+
|
|
547
|
+
describe('Metadata getPublished endpoint', () => {
|
|
548
|
+
it('should handle GET /metadata/:type/:name/published via metadata service', async () => {
|
|
549
|
+
const mockMetadata = {
|
|
550
|
+
getPublished: vi.fn().mockResolvedValue({ name: 'account', label: 'Account' }),
|
|
551
|
+
};
|
|
552
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
553
|
+
if (name === 'metadata') return Promise.resolve(mockMetadata);
|
|
554
|
+
return null;
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET');
|
|
558
|
+
expect(result.handled).toBe(true);
|
|
559
|
+
expect(result.response?.status).toBe(200);
|
|
560
|
+
expect(result.response?.body?.data).toEqual({ name: 'account', label: 'Account' });
|
|
561
|
+
expect(mockMetadata.getPublished).toHaveBeenCalledWith('object', 'account');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('should return 404 when published item not found', async () => {
|
|
565
|
+
const mockMetadata = {
|
|
566
|
+
getPublished: vi.fn().mockResolvedValue(undefined),
|
|
567
|
+
};
|
|
568
|
+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
|
|
569
|
+
if (name === 'metadata') return Promise.resolve(mockMetadata);
|
|
570
|
+
return null;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const result = await dispatcher.handleMetadata('/object/nonexistent/published', { request: {} }, 'GET');
|
|
574
|
+
expect(result.handled).toBe(true);
|
|
575
|
+
expect(result.response?.status).toBe(404);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should fallback to broker for getPublished when metadata service unavailable', async () => {
|
|
579
|
+
(kernel as any).getService = vi.fn().mockResolvedValue(null);
|
|
580
|
+
mockBroker.call.mockResolvedValue({ name: 'account', fields: ['name'] });
|
|
581
|
+
|
|
582
|
+
const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET');
|
|
583
|
+
expect(result.handled).toBe(true);
|
|
584
|
+
expect(result.response?.status).toBe(200);
|
|
585
|
+
expect(mockBroker.call).toHaveBeenCalledWith(
|
|
586
|
+
'metadata.getPublished',
|
|
587
|
+
{ type: 'object', name: 'account' },
|
|
588
|
+
{ request: {} }
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
207
592
|
});
|
package/src/http-dispatcher.ts
CHANGED
|
@@ -173,7 +173,7 @@ export class HttpDispatcher {
|
|
|
173
173
|
*/
|
|
174
174
|
async handleAuth(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
175
175
|
// 1. Try generic Auth Service
|
|
176
|
-
const authService = this.getService(CoreServiceName.enum.auth);
|
|
176
|
+
const authService = await this.getService(CoreServiceName.enum.auth);
|
|
177
177
|
if (authService && typeof authService.handler === 'function') {
|
|
178
178
|
const response = await authService.handler(context.request, context.response);
|
|
179
179
|
return { handled: true, result: response };
|
|
@@ -202,7 +202,7 @@ export class HttpDispatcher {
|
|
|
202
202
|
// GET /metadata/types
|
|
203
203
|
if (parts[0] === 'types') {
|
|
204
204
|
// Try protocol service for dynamic types
|
|
205
|
-
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
205
|
+
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
|
|
206
206
|
if (protocol && typeof protocol.getMetaTypes === 'function') {
|
|
207
207
|
const result = await protocol.getMetaTypes({});
|
|
208
208
|
return { handled: true, response: this.success(result) };
|
|
@@ -217,6 +217,24 @@ export class HttpDispatcher {
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// GET /metadata/:type/:name/published → get published version
|
|
221
|
+
if (parts.length === 3 && parts[2] === 'published' && (!method || method === 'GET')) {
|
|
222
|
+
const [type, name] = parts;
|
|
223
|
+
const metadataService = await this.getService(CoreServiceName.enum.metadata);
|
|
224
|
+
if (metadataService && typeof (metadataService as any).getPublished === 'function') {
|
|
225
|
+
const data = await (metadataService as any).getPublished(type, name);
|
|
226
|
+
if (data === undefined) return { handled: true, response: this.error('Not found', 404) };
|
|
227
|
+
return { handled: true, response: this.success(data) };
|
|
228
|
+
}
|
|
229
|
+
// Broker fallback
|
|
230
|
+
try {
|
|
231
|
+
const data = await broker.call('metadata.getPublished', { type, name }, { request: context.request });
|
|
232
|
+
return { handled: true, response: this.success(data) };
|
|
233
|
+
} catch (e: any) {
|
|
234
|
+
return { handled: true, response: this.error(e.message, 404) };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
220
238
|
// /metadata/:type/:name
|
|
221
239
|
if (parts.length === 2) {
|
|
222
240
|
const [type, name] = parts;
|
|
@@ -224,7 +242,7 @@ export class HttpDispatcher {
|
|
|
224
242
|
// PUT /metadata/:type/:name (Save)
|
|
225
243
|
if (method === 'PUT' && body) {
|
|
226
244
|
// Try to get the protocol service directly
|
|
227
|
-
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
245
|
+
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
|
|
228
246
|
|
|
229
247
|
if (protocol && typeof protocol.saveMetaItem === 'function') {
|
|
230
248
|
try {
|
|
@@ -257,7 +275,7 @@ export class HttpDispatcher {
|
|
|
257
275
|
const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
|
|
258
276
|
|
|
259
277
|
// Try Protocol Service First (Preferred)
|
|
260
|
-
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
278
|
+
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
|
|
261
279
|
if (protocol && typeof protocol.getMetaItem === 'function') {
|
|
262
280
|
try {
|
|
263
281
|
const data = await protocol.getMetaItem({ type: singularType, name });
|
|
@@ -286,7 +304,7 @@ export class HttpDispatcher {
|
|
|
286
304
|
const packageId = query?.package || undefined;
|
|
287
305
|
|
|
288
306
|
// Try protocol service first for any type
|
|
289
|
-
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
307
|
+
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
|
|
290
308
|
if (protocol && typeof protocol.getMetaItems === 'function') {
|
|
291
309
|
try {
|
|
292
310
|
const data = await protocol.getMetaItems({ type: typeOrName, packageId });
|
|
@@ -325,7 +343,7 @@ export class HttpDispatcher {
|
|
|
325
343
|
// GET /metadata — return available metadata types
|
|
326
344
|
if (parts.length === 0) {
|
|
327
345
|
// Try protocol service for dynamic types
|
|
328
|
-
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
346
|
+
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
|
|
329
347
|
if (protocol && typeof protocol.getMetaTypes === 'function') {
|
|
330
348
|
const result = await protocol.getMetaTypes({});
|
|
331
349
|
return { handled: true, response: this.success(result) };
|
|
@@ -429,7 +447,7 @@ export class HttpDispatcher {
|
|
|
429
447
|
* path: sub-path after /analytics/
|
|
430
448
|
*/
|
|
431
449
|
async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
432
|
-
const analyticsService = this.getService(CoreServiceName.enum.analytics);
|
|
450
|
+
const analyticsService = await this.getService(CoreServiceName.enum.analytics);
|
|
433
451
|
if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
|
|
434
452
|
|
|
435
453
|
const m = method.toUpperCase();
|
|
@@ -467,6 +485,8 @@ export class HttpDispatcher {
|
|
|
467
485
|
* - DELETE /packages/:id → uninstall a package
|
|
468
486
|
* - PATCH /packages/:id/enable → enable a package
|
|
469
487
|
* - PATCH /packages/:id/disable → disable a package
|
|
488
|
+
* - POST /packages/:id/publish → publish a package (metadata snapshot)
|
|
489
|
+
* - POST /packages/:id/revert → revert a package to last published state
|
|
470
490
|
*
|
|
471
491
|
* Uses ObjectQL SchemaRegistry directly (via the 'objectql' service)
|
|
472
492
|
* with broker fallback for backward compatibility.
|
|
@@ -476,7 +496,7 @@ export class HttpDispatcher {
|
|
|
476
496
|
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
477
497
|
|
|
478
498
|
// Try to get SchemaRegistry from the ObjectQL service
|
|
479
|
-
const qlService = this.getObjectQLService();
|
|
499
|
+
const qlService = await this.getObjectQLService();
|
|
480
500
|
const registry = qlService?.registry;
|
|
481
501
|
|
|
482
502
|
// If no registry available, try broker as fallback
|
|
@@ -525,6 +545,38 @@ export class HttpDispatcher {
|
|
|
525
545
|
return { handled: true, response: this.success(pkg) };
|
|
526
546
|
}
|
|
527
547
|
|
|
548
|
+
// POST /packages/:id/publish → publish package metadata
|
|
549
|
+
if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') {
|
|
550
|
+
const id = decodeURIComponent(parts[0]);
|
|
551
|
+
const metadataService = await this.getService(CoreServiceName.enum.metadata);
|
|
552
|
+
if (metadataService && typeof (metadataService as any).publishPackage === 'function') {
|
|
553
|
+
const result = await (metadataService as any).publishPackage(id, body || {});
|
|
554
|
+
return { handled: true, response: this.success(result) };
|
|
555
|
+
}
|
|
556
|
+
// Broker fallback
|
|
557
|
+
if (this.kernel.broker) {
|
|
558
|
+
const result = await this.kernel.broker.call('metadata.publishPackage', { packageId: id, ...body }, { request: context.request });
|
|
559
|
+
return { handled: true, response: this.success(result) };
|
|
560
|
+
}
|
|
561
|
+
return { handled: true, response: this.error('Metadata service not available', 503) };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// POST /packages/:id/revert → revert package to last published state
|
|
565
|
+
if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') {
|
|
566
|
+
const id = decodeURIComponent(parts[0]);
|
|
567
|
+
const metadataService = await this.getService(CoreServiceName.enum.metadata);
|
|
568
|
+
if (metadataService && typeof (metadataService as any).revertPackage === 'function') {
|
|
569
|
+
await (metadataService as any).revertPackage(id);
|
|
570
|
+
return { handled: true, response: this.success({ success: true }) };
|
|
571
|
+
}
|
|
572
|
+
// Broker fallback
|
|
573
|
+
if (this.kernel.broker) {
|
|
574
|
+
await this.kernel.broker.call('metadata.revertPackage', { packageId: id }, { request: context.request });
|
|
575
|
+
return { handled: true, response: this.success({ success: true }) };
|
|
576
|
+
}
|
|
577
|
+
return { handled: true, response: this.error('Metadata service not available', 503) };
|
|
578
|
+
}
|
|
579
|
+
|
|
528
580
|
// GET /packages/:id → get package
|
|
529
581
|
if (parts.length === 1 && m === 'GET') {
|
|
530
582
|
const id = decodeURIComponent(parts[0]);
|
|
@@ -594,7 +646,7 @@ export class HttpDispatcher {
|
|
|
594
646
|
* path: sub-path after /storage/
|
|
595
647
|
*/
|
|
596
648
|
async handleStorage(path: string, method: string, file: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
597
|
-
const storageService = this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage'];
|
|
649
|
+
const storageService = await this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage'];
|
|
598
650
|
if (!storageService) {
|
|
599
651
|
return { handled: true, response: this.error('File storage not configured', 501) };
|
|
600
652
|
}
|
|
@@ -656,7 +708,7 @@ export class HttpDispatcher {
|
|
|
656
708
|
// Support both path param /view/obj/list AND query param /view/obj?type=list
|
|
657
709
|
const type = parts[2] || query?.type || 'list';
|
|
658
710
|
|
|
659
|
-
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
711
|
+
const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
|
|
660
712
|
|
|
661
713
|
if (protocol && typeof protocol.getUiView === 'function') {
|
|
662
714
|
try {
|
|
@@ -689,7 +741,7 @@ export class HttpDispatcher {
|
|
|
689
741
|
* GET /:name/runs/:runId → getRun
|
|
690
742
|
*/
|
|
691
743
|
async handleAutomation(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
|
|
692
|
-
const automationService = this.getService(CoreServiceName.enum.automation);
|
|
744
|
+
const automationService = await this.getService(CoreServiceName.enum.automation);
|
|
693
745
|
if (!automationService) return { handled: false };
|
|
694
746
|
|
|
695
747
|
const m = method.toUpperCase();
|
|
@@ -799,9 +851,9 @@ export class HttpDispatcher {
|
|
|
799
851
|
return this.kernel.services || {};
|
|
800
852
|
}
|
|
801
853
|
|
|
802
|
-
private getService(name: CoreServiceName) {
|
|
854
|
+
private async getService(name: CoreServiceName) {
|
|
803
855
|
if (typeof this.kernel.getService === 'function') {
|
|
804
|
-
return this.kernel.getService(name);
|
|
856
|
+
return await this.kernel.getService(name);
|
|
805
857
|
}
|
|
806
858
|
const services = this.getServicesMap();
|
|
807
859
|
return services[name];
|
|
@@ -811,18 +863,18 @@ export class HttpDispatcher {
|
|
|
811
863
|
* Get the ObjectQL service which provides access to SchemaRegistry.
|
|
812
864
|
* Tries multiple access patterns since kernel structure varies.
|
|
813
865
|
*/
|
|
814
|
-
private getObjectQLService(): any {
|
|
866
|
+
private async getObjectQLService(): Promise<any> {
|
|
815
867
|
// 1. Try via kernel.getService
|
|
816
868
|
if (typeof this.kernel.getService === 'function') {
|
|
817
869
|
try {
|
|
818
|
-
const svc = this.kernel.getService('objectql');
|
|
870
|
+
const svc = await this.kernel.getService('objectql');
|
|
819
871
|
if (svc?.registry) return svc;
|
|
820
872
|
} catch { /* ignore */ }
|
|
821
873
|
}
|
|
822
874
|
// 2. Try via kernel context
|
|
823
875
|
if (this.kernel?.context?.getService) {
|
|
824
876
|
try {
|
|
825
|
-
const svc = this.kernel.context.getService('objectql');
|
|
877
|
+
const svc = await this.kernel.context.getService('objectql');
|
|
826
878
|
if (svc?.registry) return svc;
|
|
827
879
|
} catch { /* ignore */ }
|
|
828
880
|
}
|