@objectstack/runtime 3.0.10 → 3.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/runtime",
3
- "version": "3.0.10",
3
+ "version": "3.0.11",
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.10",
19
- "@objectstack/rest": "3.0.10",
20
- "@objectstack/spec": "3.0.10",
21
- "@objectstack/types": "3.0.10"
18
+ "@objectstack/core": "3.0.11",
19
+ "@objectstack/rest": "3.0.11",
20
+ "@objectstack/spec": "3.0.11",
21
+ "@objectstack/types": "3.0.11"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^5.0.0",
@@ -204,4 +204,269 @@ 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
+ });
207
472
  });
@@ -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) };
@@ -224,7 +224,7 @@ export class HttpDispatcher {
224
224
  // PUT /metadata/:type/:name (Save)
225
225
  if (method === 'PUT' && body) {
226
226
  // Try to get the protocol service directly
227
- const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
227
+ const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
228
228
 
229
229
  if (protocol && typeof protocol.saveMetaItem === 'function') {
230
230
  try {
@@ -257,7 +257,7 @@ export class HttpDispatcher {
257
257
  const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
258
258
 
259
259
  // Try Protocol Service First (Preferred)
260
- const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
260
+ const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
261
261
  if (protocol && typeof protocol.getMetaItem === 'function') {
262
262
  try {
263
263
  const data = await protocol.getMetaItem({ type: singularType, name });
@@ -286,7 +286,7 @@ export class HttpDispatcher {
286
286
  const packageId = query?.package || undefined;
287
287
 
288
288
  // Try protocol service first for any type
289
- const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
289
+ const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
290
290
  if (protocol && typeof protocol.getMetaItems === 'function') {
291
291
  try {
292
292
  const data = await protocol.getMetaItems({ type: typeOrName, packageId });
@@ -325,7 +325,7 @@ export class HttpDispatcher {
325
325
  // GET /metadata — return available metadata types
326
326
  if (parts.length === 0) {
327
327
  // Try protocol service for dynamic types
328
- const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
328
+ const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
329
329
  if (protocol && typeof protocol.getMetaTypes === 'function') {
330
330
  const result = await protocol.getMetaTypes({});
331
331
  return { handled: true, response: this.success(result) };
@@ -429,7 +429,7 @@ export class HttpDispatcher {
429
429
  * path: sub-path after /analytics/
430
430
  */
431
431
  async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
432
- const analyticsService = this.getService(CoreServiceName.enum.analytics);
432
+ const analyticsService = await this.getService(CoreServiceName.enum.analytics);
433
433
  if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
434
434
 
435
435
  const m = method.toUpperCase();
@@ -476,7 +476,7 @@ export class HttpDispatcher {
476
476
  const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
477
477
 
478
478
  // Try to get SchemaRegistry from the ObjectQL service
479
- const qlService = this.getObjectQLService();
479
+ const qlService = await this.getObjectQLService();
480
480
  const registry = qlService?.registry;
481
481
 
482
482
  // If no registry available, try broker as fallback
@@ -594,7 +594,7 @@ export class HttpDispatcher {
594
594
  * path: sub-path after /storage/
595
595
  */
596
596
  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'];
597
+ const storageService = await this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage'];
598
598
  if (!storageService) {
599
599
  return { handled: true, response: this.error('File storage not configured', 501) };
600
600
  }
@@ -656,7 +656,7 @@ export class HttpDispatcher {
656
656
  // Support both path param /view/obj/list AND query param /view/obj?type=list
657
657
  const type = parts[2] || query?.type || 'list';
658
658
 
659
- const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
659
+ const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
660
660
 
661
661
  if (protocol && typeof protocol.getUiView === 'function') {
662
662
  try {
@@ -689,7 +689,7 @@ export class HttpDispatcher {
689
689
  * GET /:name/runs/:runId → getRun
690
690
  */
691
691
  async handleAutomation(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
692
- const automationService = this.getService(CoreServiceName.enum.automation);
692
+ const automationService = await this.getService(CoreServiceName.enum.automation);
693
693
  if (!automationService) return { handled: false };
694
694
 
695
695
  const m = method.toUpperCase();
@@ -799,9 +799,9 @@ export class HttpDispatcher {
799
799
  return this.kernel.services || {};
800
800
  }
801
801
 
802
- private getService(name: CoreServiceName) {
802
+ private async getService(name: CoreServiceName) {
803
803
  if (typeof this.kernel.getService === 'function') {
804
- return this.kernel.getService(name);
804
+ return await this.kernel.getService(name);
805
805
  }
806
806
  const services = this.getServicesMap();
807
807
  return services[name];
@@ -811,18 +811,18 @@ export class HttpDispatcher {
811
811
  * Get the ObjectQL service which provides access to SchemaRegistry.
812
812
  * Tries multiple access patterns since kernel structure varies.
813
813
  */
814
- private getObjectQLService(): any {
814
+ private async getObjectQLService(): Promise<any> {
815
815
  // 1. Try via kernel.getService
816
816
  if (typeof this.kernel.getService === 'function') {
817
817
  try {
818
- const svc = this.kernel.getService('objectql');
818
+ const svc = await this.kernel.getService('objectql');
819
819
  if (svc?.registry) return svc;
820
820
  } catch { /* ignore */ }
821
821
  }
822
822
  // 2. Try via kernel context
823
823
  if (this.kernel?.context?.getService) {
824
824
  try {
825
- const svc = this.kernel.context.getService('objectql');
825
+ const svc = await this.kernel.context.getService('objectql');
826
826
  if (svc?.registry) return svc;
827
827
  } catch { /* ignore */ }
828
828
  }