@objectstack/runtime 4.0.2 → 4.0.4

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": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "ObjectStack Core Runtime & Query Engine",
6
6
  "type": "module",
@@ -15,14 +15,14 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "zod": "^4.3.6",
18
- "@objectstack/core": "4.0.2",
19
- "@objectstack/rest": "4.0.2",
20
- "@objectstack/spec": "4.0.2",
21
- "@objectstack/types": "4.0.2"
18
+ "@objectstack/core": "4.0.4",
19
+ "@objectstack/rest": "4.0.4",
20
+ "@objectstack/spec": "4.0.4",
21
+ "@objectstack/types": "4.0.4"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^6.0.2",
25
- "vitest": "^4.1.2"
25
+ "vitest": "^4.1.4"
26
26
  },
27
27
  "scripts": {
28
28
  "build": "tsup --config ../../tsup.config.ts",
package/src/app-plugin.ts CHANGED
@@ -75,6 +75,15 @@ export class AppPlugin implements Plugin {
75
75
 
76
76
  ctx.logger.debug('Retrieved ObjectQL engine service', { appId });
77
77
 
78
+ // Configure datasourceMapping if provided in the stack definition
79
+ if (this.bundle.datasourceMapping && Array.isArray(this.bundle.datasourceMapping)) {
80
+ ctx.logger.info('Configuring datasource mapping rules', {
81
+ appId,
82
+ ruleCount: this.bundle.datasourceMapping.length
83
+ });
84
+ ql.setDatasourceMapping(this.bundle.datasourceMapping);
85
+ }
86
+
78
87
  const runtime = this.bundle.default || this.bundle;
79
88
 
80
89
  if (runtime && typeof runtime.onEnable === 'function') {
@@ -22,6 +22,75 @@ interface RouteDefinition {
22
22
  handler: (req: any) => Promise<any>;
23
23
  }
24
24
 
25
+ /**
26
+ * Register a single RouteDefinition on the HTTP server.
27
+ * Returns true if the route was successfully registered.
28
+ */
29
+ function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePath: string): boolean {
30
+ const handler = async (req: any, res: any) => {
31
+ try {
32
+ const result = await route.handler({
33
+ body: req.body,
34
+ params: req.params,
35
+ query: req.query,
36
+ });
37
+
38
+ if (result.stream && result.events) {
39
+ // SSE streaming response
40
+ res.status(result.status);
41
+
42
+ // Apply headers from the route result if available
43
+ if (result.headers) {
44
+ for (const [k, v] of Object.entries(result.headers)) {
45
+ res.header(k, String(v));
46
+ }
47
+ } else {
48
+ res.header('Content-Type', 'text/event-stream');
49
+ res.header('Cache-Control', 'no-cache');
50
+ res.header('Connection', 'keep-alive');
51
+ }
52
+
53
+ // Write the stream — events are pre-encoded SSE strings
54
+ if (typeof res.write === 'function' && typeof res.end === 'function') {
55
+ for await (const event of result.events) {
56
+ res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
57
+ }
58
+ res.end();
59
+ } else {
60
+ // Fallback: collect events into array
61
+ const events = [];
62
+ for await (const event of result.events) {
63
+ events.push(event);
64
+ }
65
+ res.json({ events });
66
+ }
67
+ } else {
68
+ res.status(result.status);
69
+ if (result.body !== undefined) {
70
+ res.json(result.body);
71
+ } else {
72
+ res.end();
73
+ }
74
+ }
75
+ } catch (err: any) {
76
+ errorResponse(err, res);
77
+ }
78
+ };
79
+
80
+ const m = route.method.toLowerCase();
81
+ if (m === 'get' && typeof server.get === 'function') {
82
+ server.get(routePath, handler);
83
+ return true;
84
+ } else if (m === 'post' && typeof server.post === 'function') {
85
+ server.post(routePath, handler);
86
+ return true;
87
+ } else if (m === 'delete' && typeof server.delete === 'function') {
88
+ server.delete(routePath, handler);
89
+ return true;
90
+ }
91
+ return false;
92
+ }
93
+
25
94
  /**
26
95
  * Send an HttpDispatcherResult through IHttpResponse.
27
96
  * Differentiates between handled, unhandled (404), and special results.
@@ -402,68 +471,33 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
402
471
  const routePath = route.path.startsWith('/api/v1')
403
472
  ? route.path
404
473
  : `${prefix}${route.path}`;
405
-
406
- const handler = async (req: any, res: any) => {
407
- try {
408
- const result = await route.handler({
409
- body: req.body,
410
- params: req.params,
411
- query: req.query,
412
- });
413
-
414
- if (result.stream && result.events) {
415
- // SSE streaming response
416
- res.status(result.status);
417
-
418
- // Apply headers from the route result if available
419
- if (result.headers) {
420
- for (const [k, v] of Object.entries(result.headers)) {
421
- res.header(k, v);
422
- }
423
- } else {
424
- res.header('Content-Type', 'text/event-stream');
425
- res.header('Cache-Control', 'no-cache');
426
- res.header('Connection', 'keep-alive');
427
- }
428
-
429
- // Write the stream — events are pre-encoded SSE strings
430
- if (typeof res.write === 'function' && typeof res.end === 'function') {
431
- for await (const event of result.events) {
432
- res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
433
- }
434
- res.end();
435
- } else {
436
- // Fallback: collect events into array
437
- const events = [];
438
- for await (const event of result.events) {
439
- events.push(event);
440
- }
441
- res.json({ events });
442
- }
443
- } else {
444
- res.status(result.status);
445
- if (result.body !== undefined) {
446
- res.json(result.body);
447
- } else {
448
- res.end();
449
- }
450
- }
451
- } catch (err: any) {
452
- errorResponse(err, res);
453
- }
454
- };
455
-
456
- const m = route.method.toLowerCase();
457
- if (m === 'get' && typeof server.get === 'function') {
458
- server.get(routePath, handler);
459
- } else if (m === 'post' && typeof server.post === 'function') {
460
- server.post(routePath, handler);
461
- } else if (m === 'delete' && typeof server.delete === 'function') {
462
- server.delete(routePath, handler);
463
- }
474
+ mountRouteOnServer(route, server, routePath);
464
475
  }
465
476
  ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
466
477
  });
478
+
479
+ // ── Fallback: recover routes cached before hook was registered ──
480
+ // If AIServicePlugin.start() ran before DispatcherPlugin.start()
481
+ // (possible when plugin start order differs from registration order),
482
+ // the 'ai:routes' trigger fires with no listener. The AIServicePlugin
483
+ // caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start())
484
+ // as an internal cross-plugin protocol so we can recover them here.
485
+ // TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release.
486
+ const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined;
487
+ if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) {
488
+ let registered = 0;
489
+ for (const route of cachedRoutes) {
490
+ const routePath = route.path.startsWith('/api/v1')
491
+ ? route.path
492
+ : `${prefix}${route.path}`;
493
+ if (mountRouteOnServer(route, server, routePath)) {
494
+ registered++;
495
+ }
496
+ }
497
+ if (registered > 0) {
498
+ ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`);
499
+ }
500
+ }
467
501
  },
468
502
  };
469
503
  }
@@ -11,9 +11,6 @@ describe('HttpDispatcher Root Handling', () => {
11
11
  // Mock minimal Kernel structure
12
12
  kernel = {
13
13
  services: {},
14
- broker: {
15
- call: vi.fn(),
16
- },
17
14
  context: {
18
15
  getService: vi.fn(),
19
16
  }