@objectstack/runtime 4.0.0 → 4.0.2

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.0",
3
+ "version": "4.0.2",
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": "4.0.0",
19
- "@objectstack/rest": "4.0.0",
20
- "@objectstack/spec": "4.0.0",
21
- "@objectstack/types": "4.0.0"
18
+ "@objectstack/core": "4.0.2",
19
+ "@objectstack/rest": "4.0.2",
20
+ "@objectstack/spec": "4.0.2",
21
+ "@objectstack/types": "4.0.2"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^6.0.2",
@@ -49,10 +49,15 @@ describe('AppPlugin', () => {
49
49
  objects: []
50
50
  };
51
51
  const plugin = new AppPlugin(bundle);
52
-
52
+
53
+ // Mock the manifest service
54
+ const mockManifestService = { register: vi.fn() };
55
+ vi.mocked(mockContext.getService).mockReturnValue(mockManifestService);
56
+
53
57
  await plugin.init(mockContext);
54
-
55
- expect(mockContext.registerService).toHaveBeenCalledWith('app.com.test.simple', bundle);
58
+
59
+ expect(mockContext.getService).toHaveBeenCalledWith('manifest');
60
+ expect(mockManifestService.register).toHaveBeenCalledWith(bundle);
56
61
  });
57
62
 
58
63
  it('start should do nothing if no runtime hooks', async () => {
package/src/app-plugin.ts CHANGED
@@ -41,17 +41,13 @@ export class AppPlugin implements Plugin {
41
41
  version: this.version
42
42
  });
43
43
 
44
- // Register the app manifest as a service
45
- // ObjectQLPlugin will discover this and call ql.registerApp()
46
- const serviceName = `app.${appId}`;
47
-
48
- // Merge manifest with the bundle to ensure objects/apps are accessible at root
49
- // This supports both Legacy Manifests and new Stack Definitions
50
- const servicePayload = this.bundle.manifest
44
+ // Register the app manifest directly via the manifest service.
45
+ // This immediately decomposes the manifest into SchemaRegistry entries.
46
+ const servicePayload = this.bundle.manifest
51
47
  ? { ...this.bundle.manifest, ...this.bundle }
52
48
  : this.bundle;
53
49
 
54
- ctx.registerService(serviceName, servicePayload);
50
+ ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
55
51
  }
56
52
 
57
53
  start = async (ctx: PluginContext) => {
@@ -12,7 +12,19 @@ export interface DispatcherPluginConfig {
12
12
  }
13
13
 
14
14
  /**
15
- * Send an HttpDispatcherResult through IHttpResponse
15
+ * Route definition emitted by service plugins (e.g. AIServicePlugin) via hooks.
16
+ * Minimal interface — matches the shape produced by `buildAIRoutes()`.
17
+ */
18
+ interface RouteDefinition {
19
+ method: 'GET' | 'POST' | 'DELETE';
20
+ path: string;
21
+ description: string;
22
+ handler: (req: any) => Promise<any>;
23
+ }
24
+
25
+ /**
26
+ * Send an HttpDispatcherResult through IHttpResponse.
27
+ * Differentiates between handled, unhandled (404), and special results.
16
28
  */
17
29
  function sendResult(result: HttpDispatcherResult, res: any): void {
18
30
  if (result.handled) {
@@ -32,7 +44,16 @@ function sendResult(result: HttpDispatcherResult, res: any): void {
32
44
  return;
33
45
  }
34
46
  }
35
- res.status(404).json({ success: false, error: { message: 'Not Found', code: 404 } });
47
+ // Semantic 404: no route matched include diagnostic info
48
+ res.status(404).json({
49
+ success: false,
50
+ error: {
51
+ message: 'Not Found',
52
+ code: 404,
53
+ type: 'ROUTE_NOT_FOUND',
54
+ hint: 'No handler matched this request. Check the API discovery endpoint for available routes.',
55
+ },
56
+ });
36
57
  }
37
58
 
38
59
  function errorResponse(err: any, res: any): void {
@@ -96,6 +117,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
96
117
  res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
97
118
  });
98
119
 
120
+ // ── Health ──────────────────────────────────────────────────
121
+ server.get(`${prefix}/health`, async (_req: any, res: any) => {
122
+ try {
123
+ const result = await dispatcher.dispatch('GET', '/health', undefined, {}, { request: _req });
124
+ sendResult(result, res);
125
+ } catch (err: any) {
126
+ errorResponse(err, res);
127
+ }
128
+ });
129
+
99
130
  // ── Auth ────────────────────────────────────────────────────
100
131
  server.post(`${prefix}/auth/login`, async (req: any, res: any) => {
101
132
  try {
@@ -359,6 +390,80 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
359
390
  });
360
391
 
361
392
  ctx.logger.info('Dispatcher bridge routes registered', { prefix });
393
+
394
+ // ── Dynamic service routes (AI, etc.) ───────────────────
395
+ // Listen for route definitions emitted by service plugins.
396
+ // The AIServicePlugin emits 'ai:routes' with RouteDefinition[].
397
+ ctx.hook('ai:routes', async (routes: RouteDefinition[]) => {
398
+ if (!server) return;
399
+ for (const route of routes) {
400
+ // Strip the /api/v1 prefix if present (it's already in the path)
401
+ // and register on the HTTP server with the configured prefix.
402
+ const routePath = route.path.startsWith('/api/v1')
403
+ ? route.path
404
+ : `${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
+ }
464
+ }
465
+ ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
466
+ });
362
467
  },
363
468
  };
364
469
  }
@@ -61,13 +61,16 @@ describe('HttpDispatcher Root Handling', () => {
61
61
  expect(data.routes).toBeDefined();
62
62
  });
63
63
 
64
- it('should NOT handle POST request to root path ("")', async () => {
64
+ it('should return semantic 404 for POST request to root path ("")', async () => {
65
65
  const context = { request: {} };
66
66
  const method = 'POST';
67
67
  const path = '';
68
68
 
69
69
  const result = await dispatcher.dispatch(method, path, {}, {}, context);
70
70
 
71
- expect(result.handled).toBe(false);
71
+ // The dispatcher now returns a typed 404 (ROUTE_NOT_FOUND) instead of { handled: false }
72
+ expect(result.handled).toBe(true);
73
+ expect(result.response?.status).toBe(404);
74
+ expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
72
75
  });
73
76
  });
@@ -59,6 +59,25 @@ export class HttpDispatcher {
59
59
  };
60
60
  }
61
61
 
62
+ /**
63
+ * 404 Route Not Found — no route is registered for this path.
64
+ */
65
+ private routeNotFound(route: string) {
66
+ return {
67
+ status: 404,
68
+ body: {
69
+ success: false,
70
+ error: {
71
+ code: 404,
72
+ message: `Route Not Found: ${route}`,
73
+ type: 'ROUTE_NOT_FOUND' as const,
74
+ route,
75
+ hint: 'No route is registered for this path. Check the API discovery endpoint for available routes.',
76
+ },
77
+ },
78
+ };
79
+ }
80
+
62
81
  private ensureBroker() {
63
82
  if (!this.kernel.broker) {
64
83
  throw { statusCode: 500, message: 'Kernel Broker not available' };
@@ -133,11 +152,14 @@ export class HttpDispatcher {
133
152
  };
134
153
 
135
154
  // Build per-service status map
155
+ // handlerReady: true means the dispatcher has a real, bound handler for this route.
156
+ // handlerReady: false means the route is present in the discovery table but may not
157
+ // yet have a concrete implementation or may be served by a stub.
136
158
  const svcAvailable = (route?: string, provider?: string) => ({
137
- enabled: true, status: 'available' as const, route, provider,
159
+ enabled: true, status: 'available' as const, handlerReady: true, route, provider,
138
160
  });
139
161
  const svcUnavailable = (name: string) => ({
140
- enabled: false, status: 'unavailable' as const,
162
+ enabled: false, status: 'unavailable' as const, handlerReady: false,
141
163
  message: `Install a ${name} plugin to enable`,
142
164
  });
143
165
 
@@ -174,7 +196,7 @@ export class HttpDispatcher {
174
196
  },
175
197
  services: {
176
198
  // Kernel-provided (always available via protocol implementation)
177
- metadata: { enabled: true, status: 'degraded' as const, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
199
+ metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
178
200
  data: svcAvailable(routes.data, 'kernel'),
179
201
  // Plugin-provided — only available when a plugin registers the service
180
202
  auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable('auth'),
@@ -362,12 +384,14 @@ export class HttpDispatcher {
362
384
  // /metadata/:type/:name
363
385
  if (parts.length === 2) {
364
386
  const [type, name] = parts;
387
+ // Extract optional package filter from query string
388
+ const packageId = query?.package || undefined;
365
389
 
366
390
  // PUT /metadata/:type/:name (Save)
367
391
  if (method === 'PUT' && body) {
368
392
  // Try to get the protocol service directly
369
393
  const protocol = await this.resolveService('protocol');
370
-
394
+
371
395
  if (protocol && typeof protocol.saveMetaItem === 'function') {
372
396
  try {
373
397
  const result = await protocol.saveMetaItem({ type, name, item: body });
@@ -376,7 +400,7 @@ export class HttpDispatcher {
376
400
  return { handled: true, response: this.error(e.message, 400) };
377
401
  }
378
402
  }
379
-
403
+
380
404
  // Fallback to broker if protocol not available (legacy)
381
405
  if (broker) {
382
406
  try {
@@ -408,12 +432,12 @@ export class HttpDispatcher {
408
432
  // If type is singular (e.g. 'app'), use it directly
409
433
  // If plural (e.g. 'apps'), slice it
410
434
  const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
411
-
435
+
412
436
  // Try Protocol Service First (Preferred)
413
437
  const protocol = await this.resolveService('protocol');
414
438
  if (protocol && typeof protocol.getMetaItem === 'function') {
415
439
  try {
416
- const data = await protocol.getMetaItem({ type: singularType, name });
440
+ const data = await protocol.getMetaItem({ type: singularType, name, packageId });
417
441
  return { handled: true, response: this.success(data) };
418
442
  } catch (e: any) {
419
443
  // Protocol might throw if not found or not supported
@@ -1188,25 +1212,138 @@ export class HttpDispatcher {
1188
1212
  return s.charAt(0).toUpperCase() + s.slice(1);
1189
1213
  }
1190
1214
 
1215
+ /**
1216
+ * Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.)
1217
+ * Resolves the AI service and its built-in route handlers, then dispatches.
1218
+ */
1219
+ async handleAI(subPath: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1220
+ let aiService: any;
1221
+ try {
1222
+ aiService = await this.resolveService('ai');
1223
+ } catch {
1224
+ // AI service not registered
1225
+ }
1226
+
1227
+ if (!aiService) {
1228
+ return {
1229
+ handled: true,
1230
+ response: {
1231
+ status: 404,
1232
+ body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
1233
+ },
1234
+ };
1235
+ }
1236
+
1237
+ // The AI service exposes route definitions via buildAIRoutes.
1238
+ // We match the request path against known AI route patterns.
1239
+ const fullPath = `/api/v1${subPath}`;
1240
+
1241
+ // Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id
1242
+ const matchRoute = (pattern: string, path: string): Record<string, string> | null => {
1243
+ const patternParts = pattern.split('/');
1244
+ const pathParts = path.split('/');
1245
+ if (patternParts.length !== pathParts.length) return null;
1246
+ const params: Record<string, string> = {};
1247
+ for (let i = 0; i < patternParts.length; i++) {
1248
+ if (patternParts[i].startsWith(':')) {
1249
+ params[patternParts[i].substring(1)] = pathParts[i];
1250
+ } else if (patternParts[i] !== pathParts[i]) {
1251
+ return null;
1252
+ }
1253
+ }
1254
+ return params;
1255
+ };
1256
+
1257
+ // Try to get route definitions from the AI service's cached routes
1258
+ const routes = (this.kernel as any).__aiRoutes as Array<{
1259
+ method: string; path: string; handler: (req: any) => Promise<any>;
1260
+ }> | undefined;
1261
+
1262
+ if (!routes) {
1263
+ return {
1264
+ handled: true,
1265
+ response: {
1266
+ status: 503,
1267
+ body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
1268
+ },
1269
+ };
1270
+ }
1271
+
1272
+ for (const route of routes) {
1273
+ if (route.method !== method) continue;
1274
+ const params = matchRoute(route.path, fullPath);
1275
+ if (params === null) continue;
1276
+
1277
+ const result = await route.handler({ body, params, query });
1278
+
1279
+ if (result.stream && result.events) {
1280
+ // Return a streaming result for the adapter to handle
1281
+ return {
1282
+ handled: true,
1283
+ result: {
1284
+ type: 'stream',
1285
+ contentType: result.vercelDataStream
1286
+ ? 'text/plain; charset=utf-8'
1287
+ : 'text/event-stream',
1288
+ events: result.events,
1289
+ vercelDataStream: result.vercelDataStream,
1290
+ headers: {
1291
+ 'Content-Type': result.vercelDataStream
1292
+ ? 'text/plain; charset=utf-8'
1293
+ : 'text/event-stream',
1294
+ 'Cache-Control': 'no-cache',
1295
+ 'Connection': 'keep-alive',
1296
+ },
1297
+ },
1298
+ };
1299
+ }
1300
+
1301
+ return {
1302
+ handled: true,
1303
+ response: {
1304
+ status: result.status,
1305
+ body: result.body,
1306
+ },
1307
+ };
1308
+ }
1309
+
1310
+ return {
1311
+ handled: true,
1312
+ response: this.routeNotFound(subPath),
1313
+ };
1314
+ }
1315
+
1191
1316
  /**
1192
1317
  * Main Dispatcher Entry Point
1193
1318
  * Routes the request to the appropriate handler based on path and precedence
1194
1319
  */
1195
- async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1320
+ async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise<HttpDispatcherResult> {
1196
1321
  const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
1197
1322
 
1198
1323
  // 0. Discovery Endpoint (GET /discovery or GET /)
1199
1324
  // Standard route: /discovery (protocol-compliant)
1200
1325
  // Legacy route: / (empty path, for backward compatibility — MSW strips base URL)
1201
1326
  if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') {
1202
- // We use '' as prefix since we are internal dispatcher
1203
- const info = await this.getDiscoveryInfo('');
1327
+ const info = await this.getDiscoveryInfo(prefix ?? '');
1204
1328
  return {
1205
1329
  handled: true,
1206
1330
  response: this.success(info)
1207
1331
  };
1208
1332
  }
1209
1333
 
1334
+ // 0b. Health Endpoint (GET /health)
1335
+ if (cleanPath === '/health' && method === 'GET') {
1336
+ return {
1337
+ handled: true,
1338
+ response: this.success({
1339
+ status: 'ok',
1340
+ timestamp: new Date().toISOString(),
1341
+ version: '1.0.0',
1342
+ uptime: typeof process !== 'undefined' ? process.uptime() : undefined,
1343
+ }),
1344
+ };
1345
+ }
1346
+
1210
1347
  // 1. System Protocols (Prefix-based)
1211
1348
  if (cleanPath.startsWith('/auth')) {
1212
1349
  return this.handleAuth(cleanPath.substring(5), method, body, context);
@@ -1249,6 +1386,11 @@ export class HttpDispatcher {
1249
1386
  return this.handleI18n(cleanPath.substring(5), method, query, context);
1250
1387
  }
1251
1388
 
1389
+ // AI Service — delegate to the registered AI route handlers
1390
+ if (cleanPath.startsWith('/ai')) {
1391
+ return this.handleAI(cleanPath, method, body, query, context);
1392
+ }
1393
+
1252
1394
  // OpenAPI Specification
1253
1395
  if (cleanPath === '/openapi.json' && method === 'GET') {
1254
1396
  const broker = this.ensureBroker();
@@ -1265,8 +1407,11 @@ export class HttpDispatcher {
1265
1407
  const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
1266
1408
  if (result.handled) return result;
1267
1409
 
1268
- // 3. Fallback (404)
1269
- return { handled: false };
1410
+ // 3. Fallback — return semantic 404 with diagnostic info
1411
+ return {
1412
+ handled: true,
1413
+ response: this.routeNotFound(cleanPath),
1414
+ };
1270
1415
  }
1271
1416
 
1272
1417
  /**
@@ -0,0 +1,25 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { defineConfig } from 'vitest/config';
4
+ import path from 'node:path';
5
+
6
+ export default defineConfig({
7
+ resolve: {
8
+ alias: {
9
+ '@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'),
10
+ '@objectstack/rest': path.resolve(__dirname, '../rest/src/index.ts'),
11
+ '@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'),
12
+ '@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'),
13
+ '@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'),
14
+ '@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'),
15
+ '@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'),
16
+ '@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'),
17
+ '@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'),
18
+ },
19
+ },
20
+ test: {
21
+ globals: true,
22
+ environment: 'node',
23
+ include: ['src/**/*.test.ts'],
24
+ },
25
+ });