@objectstack/runtime 0.9.2 → 1.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.
@@ -250,6 +250,34 @@ export class RestServer {
250
250
  },
251
251
  });
252
252
  }
253
+ // PUT /meta/:type/:name - Save metadata item
254
+ // We always register this route, but return 501 if protocol doesn't support it
255
+ // This makes it discoverable even if not implemented
256
+ this.routeManager.register({
257
+ method: 'PUT',
258
+ path: `${metaPath}/:type/:name`,
259
+ handler: async (req, res) => {
260
+ try {
261
+ if (!this.protocol.saveMetaItem) {
262
+ res.status(501).json({ error: 'Save operation not supported by protocol implementation' });
263
+ return;
264
+ }
265
+ const result = await this.protocol.saveMetaItem({
266
+ type: req.params.type,
267
+ name: req.params.name,
268
+ item: req.body
269
+ });
270
+ res.json(result);
271
+ }
272
+ catch (error) {
273
+ res.status(400).json({ error: error.message });
274
+ }
275
+ },
276
+ metadata: {
277
+ summary: 'Save specific metadata item',
278
+ tags: ['metadata'],
279
+ },
280
+ });
253
281
  }
254
282
  /**
255
283
  * Register CRUD endpoints for data operations
@@ -0,0 +1,45 @@
1
+ import { ObjectKernel, Plugin, IHttpServer, ObjectKernelConfig } from '@objectstack/core';
2
+ import { ApiRegistryConfig } from './api-registry-plugin.js';
3
+ export interface RuntimeConfig {
4
+ /**
5
+ * Optional existing server instance (e.g. Hono, Express app)
6
+ * If provided, Runtime will use it as the 'http.server' service.
7
+ * If not provided, Runtime expects a server plugin (like HonoServerPlugin) to be registered manually.
8
+ */
9
+ server?: IHttpServer;
10
+ /**
11
+ * API Registry Configuration
12
+ */
13
+ api?: ApiRegistryConfig;
14
+ /**
15
+ * Kernel Configuration
16
+ */
17
+ kernel?: ObjectKernelConfig;
18
+ }
19
+ /**
20
+ * ObjectStack Runtime
21
+ *
22
+ * High-level entry point for bootstrapping an ObjectStack application.
23
+ * Wraps ObjectKernel and provides standard orchestration for:
24
+ * - HTTP Server binding
25
+ * - API Registry (REST Routes)
26
+ * - Plugin Management
27
+ */
28
+ export declare class Runtime {
29
+ readonly kernel: ObjectKernel;
30
+ constructor(config?: RuntimeConfig);
31
+ /**
32
+ * Register a plugin
33
+ */
34
+ use(plugin: Plugin): this;
35
+ /**
36
+ * Start the runtime
37
+ * 1. Initializes all plugins (init phase)
38
+ * 2. Starts all plugins (start phase)
39
+ */
40
+ start(): Promise<this>;
41
+ /**
42
+ * Get the kernel instance
43
+ */
44
+ getKernel(): ObjectKernel;
45
+ }
@@ -0,0 +1,50 @@
1
+ import { ObjectKernel } from '@objectstack/core';
2
+ import { createApiRegistryPlugin } from './api-registry-plugin.js';
3
+ /**
4
+ * ObjectStack Runtime
5
+ *
6
+ * High-level entry point for bootstrapping an ObjectStack application.
7
+ * Wraps ObjectKernel and provides standard orchestration for:
8
+ * - HTTP Server binding
9
+ * - API Registry (REST Routes)
10
+ * - Plugin Management
11
+ */
12
+ export class Runtime {
13
+ constructor(config = {}) {
14
+ this.kernel = new ObjectKernel(config.kernel);
15
+ // If external server provided, register it immediately
16
+ if (config.server) {
17
+ // If the provided server is not already an HttpServer wrapper, wrap it?
18
+ // Since IHttpServer is the interface, we assume it complies.
19
+ // But HttpServer class in runtime is an adapter.
20
+ // If user passes raw Hono, it won't work unless they wrapped it.
21
+ // We'll assume they pass a compliant IHttpServer.
22
+ this.kernel.registerService('http.server', config.server);
23
+ }
24
+ // Register API Registry by default
25
+ // This plugin is passive (wait for services) so it's safe to add early.
26
+ this.kernel.use(createApiRegistryPlugin(config.api));
27
+ }
28
+ /**
29
+ * Register a plugin
30
+ */
31
+ use(plugin) {
32
+ this.kernel.use(plugin);
33
+ return this;
34
+ }
35
+ /**
36
+ * Start the runtime
37
+ * 1. Initializes all plugins (init phase)
38
+ * 2. Starts all plugins (start phase)
39
+ */
40
+ async start() {
41
+ await this.kernel.bootstrap();
42
+ return this;
43
+ }
44
+ /**
45
+ * Get the kernel instance
46
+ */
47
+ getKernel() {
48
+ return this.kernel;
49
+ }
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Runtime } from './runtime';
3
+ // Mock ObjectKernel to isolate Runtime logic
4
+ vi.mock('@objectstack/core', async () => {
5
+ const actual = await vi.importActual('@objectstack/core');
6
+ return {
7
+ ...actual,
8
+ ObjectKernel: class {
9
+ constructor() {
10
+ this.use = vi.fn();
11
+ this.registerService = vi.fn();
12
+ this.bootstrap = vi.fn().mockResolvedValue(undefined);
13
+ this.getServices = vi.fn().mockReturnValue(new Map());
14
+ }
15
+ }
16
+ };
17
+ });
18
+ describe('Runtime', () => {
19
+ it('should initialize successfully', () => {
20
+ const runtime = new Runtime();
21
+ expect(runtime).toBeDefined();
22
+ // Should create a kernel
23
+ expect(runtime.getKernel()).toBeDefined();
24
+ });
25
+ it('should register api registry plugin by default', () => {
26
+ const runtime = new Runtime();
27
+ const kernel = runtime.getKernel();
28
+ // Check if use was called (at least once for api registry)
29
+ expect(kernel.use).toHaveBeenCalled();
30
+ });
31
+ it('should register external http server if provided', () => {
32
+ const mockServer = {
33
+ listen: vi.fn(),
34
+ close: vi.fn(),
35
+ get: vi.fn(),
36
+ post: vi.fn(),
37
+ put: vi.fn(),
38
+ delete: vi.fn(),
39
+ patch: vi.fn(),
40
+ use: vi.fn(),
41
+ };
42
+ const runtime = new Runtime({ server: mockServer });
43
+ const kernel = runtime.getKernel();
44
+ expect(kernel.registerService).toHaveBeenCalledWith('http.server', mockServer);
45
+ });
46
+ it('should delegate use() to kernel', () => {
47
+ const runtime = new Runtime();
48
+ const mockPlugin = { name: 'test', init: vi.fn() };
49
+ runtime.use(mockPlugin);
50
+ expect(runtime.getKernel().use).toHaveBeenCalledWith(mockPlugin);
51
+ });
52
+ it('should delegate start() to kernel.bootstrap()', async () => {
53
+ const runtime = new Runtime();
54
+ await runtime.start();
55
+ expect(runtime.getKernel().bootstrap).toHaveBeenCalled();
56
+ });
57
+ });
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "@objectstack/runtime",
3
- "version": "0.9.2",
3
+ "version": "1.0.1",
4
+ "license": "Apache-2.0",
4
5
  "description": "ObjectStack Core Runtime & Query Engine",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
7
8
  "types": "dist/index.d.ts",
8
9
  "dependencies": {
9
- "@objectstack/core": "0.9.2",
10
- "@objectstack/spec": "0.9.2",
11
- "@objectstack/types": "0.9.2"
10
+ "@objectstack/core": "1.0.1",
11
+ "@objectstack/spec": "1.0.1",
12
+ "@objectstack/types": "1.0.1"
12
13
  },
13
14
  "devDependencies": {
14
- "typescript": "^5.0.0"
15
+ "typescript": "^5.0.0",
16
+ "vitest": "^4.0.18"
15
17
  },
16
18
  "scripts": {
17
19
  "build": "tsc",
18
- "dev": "tsc -w"
20
+ "dev": "tsc -w",
21
+ "test": "vitest run"
19
22
  }
20
23
  }
@@ -0,0 +1,58 @@
1
+ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
2
+ import { RestServer } from './rest-server.js';
3
+ import { ObjectStackProtocol, RestServerConfig } from '@objectstack/spec/api';
4
+
5
+ export interface ApiRegistryConfig {
6
+ serverServiceName?: string;
7
+ protocolServiceName?: string;
8
+ api?: RestServerConfig;
9
+ }
10
+
11
+ /**
12
+ * ApiRegistryPlugin
13
+ *
14
+ * Responsibilities:
15
+ * 1. Consumes 'http.server' (or configured service)
16
+ * 2. Consumes 'protocol' (ObjectStackProtocol)
17
+ * 3. Instantiates RestServer to auto-generate routes
18
+ */
19
+ export function createApiRegistryPlugin(config: ApiRegistryConfig = {}): Plugin {
20
+ return {
21
+ name: 'com.objectstack.runtime.api-registry',
22
+ version: '1.0.0',
23
+
24
+ init: async (ctx: PluginContext) => {
25
+ // No service registration, this is a consumer plugin
26
+ },
27
+
28
+ start: async (ctx: PluginContext) => {
29
+ const serverService = config.serverServiceName || 'http.server';
30
+ const protocolService = config.protocolServiceName || 'protocol';
31
+
32
+ const server = ctx.getService<IHttpServer>(serverService);
33
+ const protocol = ctx.getService<ObjectStackProtocol>(protocolService);
34
+
35
+ if (!server) {
36
+ ctx.logger.warn(`ApiRegistryPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
37
+ return;
38
+ }
39
+
40
+ if (!protocol) {
41
+ ctx.logger.warn(`ApiRegistryPlugin: Protocol service '${protocolService}' not found. REST routes skipped.`);
42
+ return;
43
+ }
44
+
45
+ ctx.logger.info('Hydrating REST API from Protocol...');
46
+
47
+ try {
48
+ const restServer = new RestServer(server, protocol, config.api as any);
49
+ restServer.registerRoutes();
50
+
51
+ ctx.logger.info('REST API successfully registered');
52
+ } catch (err: any) {
53
+ ctx.logger.error('Failed to register REST API routes', { error: err.message } as any);
54
+ throw err;
55
+ }
56
+ }
57
+ };
58
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { AppPlugin } from './app-plugin';
3
+ import { PluginContext } from '@objectstack/core';
4
+
5
+ describe('AppPlugin', () => {
6
+ let mockContext: PluginContext;
7
+
8
+ beforeEach(() => {
9
+ mockContext = {
10
+ logger: {
11
+ info: vi.fn(),
12
+ error: vi.fn(),
13
+ warn: vi.fn(),
14
+ debug: vi.fn()
15
+ },
16
+ registerService: vi.fn(),
17
+ getService: vi.fn(),
18
+ getServices: vi.fn()
19
+ } as unknown as PluginContext;
20
+ });
21
+
22
+ it('should initialize with manifest info', () => {
23
+ const bundle = {
24
+ id: 'com.test.app',
25
+ name: 'Test App',
26
+ version: '1.0.0'
27
+ };
28
+ const plugin = new AppPlugin(bundle);
29
+ expect(plugin.name).toBe('plugin.app.com.test.app');
30
+ expect(plugin.version).toBe('1.0.0');
31
+ });
32
+
33
+ it('should handle nested stack definition manifest', () => {
34
+ const bundle = {
35
+ manifest: {
36
+ id: 'com.test.stack',
37
+ version: '2.0.0'
38
+ },
39
+ objects: []
40
+ };
41
+ const plugin = new AppPlugin(bundle);
42
+ expect(plugin.name).toBe('plugin.app.com.test.stack');
43
+ expect(plugin.version).toBe('2.0.0');
44
+ });
45
+
46
+ it('registerService should register raw manifest in init phase', async () => {
47
+ const bundle = {
48
+ id: 'com.test.simple',
49
+ objects: []
50
+ };
51
+ const plugin = new AppPlugin(bundle);
52
+
53
+ await plugin.init(mockContext);
54
+
55
+ expect(mockContext.registerService).toHaveBeenCalledWith('app.com.test.simple', bundle);
56
+ });
57
+
58
+ it('start should do nothing if no runtime hooks', async () => {
59
+ const bundle = { id: 'com.test.static' };
60
+ const plugin = new AppPlugin(bundle);
61
+
62
+ vi.mocked(mockContext.getService).mockReturnValue({}); // Mock ObjectQL exists
63
+
64
+ await plugin.start!(mockContext);
65
+ // Only logs, no errors
66
+ expect(mockContext.logger.debug).toHaveBeenCalled();
67
+ });
68
+
69
+ it('start should invoke onEnable if present', async () => {
70
+ const onEnableSpy = vi.fn();
71
+ const bundle = {
72
+ id: 'com.test.code',
73
+ onEnable: onEnableSpy
74
+ };
75
+ const plugin = new AppPlugin(bundle);
76
+
77
+ // Mock ObjectQL engine
78
+ const mockQL = { registry: {} };
79
+ vi.mocked(mockContext.getService).mockReturnValue(mockQL);
80
+
81
+ await plugin.start!(mockContext);
82
+
83
+ expect(onEnableSpy).toHaveBeenCalled();
84
+ // Check context passed to onEnable
85
+ const callArg = onEnableSpy.mock.calls[0][0];
86
+ expect(callArg.ql).toBe(mockQL);
87
+ });
88
+
89
+ it('start should warn if objectql not found', async () => {
90
+ const bundle = { id: 'com.test.warn' };
91
+ const plugin = new AppPlugin(bundle);
92
+
93
+ vi.mocked(mockContext.getService).mockReturnValue(undefined); // No ObjectQL
94
+
95
+ await plugin.start!(mockContext);
96
+
97
+ expect(mockContext.logger.warn).toHaveBeenCalledWith(
98
+ expect.stringContaining('ObjectQL engine service not found'),
99
+ expect.any(Object)
100
+ );
101
+ });
102
+ });
package/src/app-plugin.ts CHANGED
@@ -25,7 +25,7 @@ export class AppPlugin implements Plugin {
25
25
  this.version = sys.version;
26
26
  }
27
27
 
28
- async init(ctx: PluginContext) {
28
+ init = async (ctx: PluginContext) => {
29
29
  const sys = this.bundle.manifest || this.bundle;
30
30
  const appId = sys.id || sys.name;
31
31
 
@@ -48,7 +48,7 @@ export class AppPlugin implements Plugin {
48
48
  ctx.registerService(serviceName, servicePayload);
49
49
  }
50
50
 
51
- async start(ctx: PluginContext) {
51
+ start = async (ctx: PluginContext) => {
52
52
  const sys = this.bundle.manifest || this.bundle;
53
53
  const appId = sys.id || sys.name;
54
54
 
@@ -26,7 +26,7 @@ export class DriverPlugin implements Plugin {
26
26
  this.name = `com.objectstack.driver.${driverName || driver.name || 'unknown'}`;
27
27
  }
28
28
 
29
- async init(ctx: PluginContext) {
29
+ init = async (ctx: PluginContext) => {
30
30
  // Register driver as a service instead of directly to objectql
31
31
  const serviceName = `driver.${this.driver.name || 'unknown'}`;
32
32
  ctx.registerService(serviceName, this.driver);
@@ -37,7 +37,7 @@ export class DriverPlugin implements Plugin {
37
37
  });
38
38
  }
39
39
 
40
- async start(ctx: PluginContext) {
40
+ start = async (ctx: PluginContext) => {
41
41
  // Drivers don't need start phase, initialization happens in init
42
42
  ctx.logger.debug('Driver plugin started', { driverName: this.driver.name || 'unknown' });
43
43
  }
@@ -0,0 +1,107 @@
1
+
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { HttpDispatcher } from './http-dispatcher.js';
4
+ import { ObjectKernel } from '@objectstack/core';
5
+
6
+ describe('HttpDispatcher', () => {
7
+ let kernel: ObjectKernel;
8
+ let dispatcher: HttpDispatcher;
9
+ let mockProtocol: any;
10
+ let mockBroker: any;
11
+
12
+ beforeEach(() => {
13
+ // Mock Kernel
14
+ mockProtocol = {
15
+ saveMetaItem: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
16
+ getMetaItem: vi.fn().mockResolvedValue({ success: true, item: { foo: 'bar' } })
17
+ };
18
+
19
+ mockBroker = {
20
+ call: vi.fn(),
21
+ };
22
+
23
+ kernel = {
24
+ broker: mockBroker,
25
+ context: {
26
+ getService: (name: string) => {
27
+ if (name === 'protocol') return mockProtocol;
28
+ return null;
29
+ }
30
+ }
31
+ } as any;
32
+
33
+ dispatcher = new HttpDispatcher(kernel);
34
+ });
35
+
36
+ describe('handleMetadata', () => {
37
+ it('should handle PUT /metadata/:type/:name by calling protocol.saveMetaItem', async () => {
38
+ const context = { request: {} };
39
+ const body = { label: 'New Label' };
40
+ const path = '/objects/my_obj';
41
+
42
+ const result = await dispatcher.handleMetadata(path, context, 'PUT', body);
43
+
44
+ expect(result.handled).toBe(true);
45
+ expect(result.response?.status).toBe(200);
46
+ expect(mockProtocol.saveMetaItem).toHaveBeenCalledWith({
47
+ type: 'objects',
48
+ name: 'my_obj',
49
+ item: body
50
+ });
51
+ expect(result.response?.body).toEqual({
52
+ success: true,
53
+ data: { success: true, message: 'Saved' },
54
+ meta: undefined
55
+ });
56
+ });
57
+
58
+ it('should fallback to broker call if protocol is missing saveMetaItem', async () => {
59
+ // Mock protocol without saveMetaItem
60
+ (kernel as any).context.getService = () => ({});
61
+ // Mock broker success
62
+ mockBroker.call.mockResolvedValue({ success: true, fromBroker: true });
63
+
64
+ const context = { request: {} };
65
+ const body = { label: 'Fallback' };
66
+ const path = '/objects/my_obj';
67
+
68
+ const result = await dispatcher.handleMetadata(path, context, 'PUT', body);
69
+
70
+ expect(result.handled).toBe(true);
71
+ expect(mockBroker.call).toHaveBeenCalledWith(
72
+ 'metadata.saveItem',
73
+ { type: 'objects', name: 'my_obj', item: body },
74
+ { request: context.request }
75
+ );
76
+ expect(result.response?.body?.data).toEqual({ success: true, fromBroker: true });
77
+ });
78
+
79
+ it('should return error if save fails', async () => {
80
+ mockProtocol.saveMetaItem.mockRejectedValue(new Error('Save failed'));
81
+
82
+ const context = { request: {} };
83
+ const body = {};
84
+ const path = '/objects/bad_obj';
85
+
86
+ const result = await dispatcher.handleMetadata(path, context, 'PUT', body);
87
+
88
+ expect(result.handled).toBe(true);
89
+ expect(result.response?.status).toBe(400);
90
+ expect(result.response?.body?.error?.message).toBe('Save failed');
91
+ });
92
+
93
+ it('should handle READ operations as before', async () => {
94
+ mockBroker.call.mockResolvedValue({ name: 'my_obj' });
95
+
96
+ const context = { request: {} };
97
+ const result = await dispatcher.handleMetadata('/objects/my_obj', context, 'GET');
98
+
99
+ expect(result.handled).toBe(true);
100
+ expect(mockBroker.call).toHaveBeenCalledWith(
101
+ 'metadata.getObject',
102
+ { objectName: 'my_obj' },
103
+ { request: context.request }
104
+ );
105
+ });
106
+ });
107
+ });