@objectstack/runtime 4.0.4 → 4.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.
@@ -1,274 +0,0 @@
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
- // Mock the manifest service
54
- const mockManifestService = { register: vi.fn() };
55
- vi.mocked(mockContext.getService).mockReturnValue(mockManifestService);
56
-
57
- await plugin.init(mockContext);
58
-
59
- expect(mockContext.getService).toHaveBeenCalledWith('manifest');
60
- expect(mockManifestService.register).toHaveBeenCalledWith(bundle);
61
- });
62
-
63
- it('start should do nothing if no runtime hooks', async () => {
64
- const bundle = { id: 'com.test.static' };
65
- const plugin = new AppPlugin(bundle);
66
-
67
- vi.mocked(mockContext.getService).mockReturnValue({}); // Mock ObjectQL exists
68
-
69
- await plugin.start!(mockContext);
70
- // Only logs, no errors
71
- expect(mockContext.logger.debug).toHaveBeenCalled();
72
- });
73
-
74
- it('start should invoke onEnable if present', async () => {
75
- const onEnableSpy = vi.fn();
76
- const bundle = {
77
- id: 'com.test.code',
78
- onEnable: onEnableSpy
79
- };
80
- const plugin = new AppPlugin(bundle);
81
-
82
- // Mock ObjectQL engine
83
- const mockQL = { registry: {} };
84
- vi.mocked(mockContext.getService).mockReturnValue(mockQL);
85
-
86
- await plugin.start!(mockContext);
87
-
88
- expect(onEnableSpy).toHaveBeenCalled();
89
- // Check context passed to onEnable
90
- const callArg = onEnableSpy.mock.calls[0][0];
91
- expect(callArg.ql).toBe(mockQL);
92
- });
93
-
94
- it('start should warn if objectql not found', async () => {
95
- const bundle = { id: 'com.test.warn' };
96
- const plugin = new AppPlugin(bundle);
97
-
98
- vi.mocked(mockContext.getService).mockReturnValue(undefined); // No ObjectQL
99
-
100
- await plugin.start!(mockContext);
101
-
102
- expect(mockContext.logger.warn).toHaveBeenCalledWith(
103
- expect.stringContaining('ObjectQL engine service not found'),
104
- expect.any(Object)
105
- );
106
- });
107
-
108
- it('start should handle getService throwing for objectql', async () => {
109
- const bundle = { id: 'com.test.throw' };
110
- const plugin = new AppPlugin(bundle);
111
-
112
- vi.mocked(mockContext.getService).mockImplementation(() => {
113
- throw new Error("[Kernel] Service 'objectql' not found");
114
- });
115
-
116
- await plugin.start!(mockContext);
117
-
118
- expect(mockContext.logger.warn).toHaveBeenCalledWith(
119
- expect.stringContaining('ObjectQL engine service not found'),
120
- expect.any(Object)
121
- );
122
- });
123
-
124
- // ═══════════════════════════════════════════════════════════════
125
- // i18n translation auto-loading
126
- // ═══════════════════════════════════════════════════════════════
127
-
128
- describe('i18n translation loading', () => {
129
- let mockI18n: any;
130
- let mockQL: any;
131
-
132
- beforeEach(() => {
133
- mockI18n = {
134
- loadTranslations: vi.fn(),
135
- setDefaultLocale: vi.fn(),
136
- getLocales: vi.fn().mockReturnValue([]),
137
- getDefaultLocale: vi.fn().mockReturnValue('en'),
138
- };
139
- mockQL = { registry: {} };
140
-
141
- vi.mocked(mockContext.getService).mockImplementation((name: string) => {
142
- if (name === 'objectql') return mockQL;
143
- if (name === 'i18n') return mockI18n;
144
- return undefined;
145
- });
146
- });
147
-
148
- it('should auto-load translations from bundle into i18n service', async () => {
149
- const bundle = {
150
- id: 'com.test.i18n',
151
- translations: [
152
- {
153
- en: { objects: { task: { label: 'Task' } } },
154
- 'zh-CN': { objects: { task: { label: '任务' } } },
155
- },
156
- ],
157
- };
158
- const plugin = new AppPlugin(bundle);
159
- await plugin.start!(mockContext);
160
-
161
- expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { objects: { task: { label: 'Task' } } });
162
- expect(mockI18n.loadTranslations).toHaveBeenCalledWith('zh-CN', { objects: { task: { label: '任务' } } });
163
- });
164
-
165
- it('should set default locale from i18n config', async () => {
166
- const bundle = {
167
- id: 'com.test.locale',
168
- i18n: { defaultLocale: 'zh-CN', supportedLocales: ['en', 'zh-CN'] },
169
- translations: [{ en: { messages: { hello: 'Hello' } } }],
170
- };
171
- const plugin = new AppPlugin(bundle);
172
- await plugin.start!(mockContext);
173
-
174
- expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
175
- });
176
-
177
- it('should skip translation loading when i18n service is not registered', async () => {
178
- vi.mocked(mockContext.getService).mockImplementation((name: string) => {
179
- if (name === 'objectql') return mockQL;
180
- return undefined; // No i18n service
181
- });
182
-
183
- const bundle = {
184
- id: 'com.test.noi18n',
185
- translations: [{ en: { messages: { hello: 'Hello' } } }],
186
- };
187
- const plugin = new AppPlugin(bundle);
188
- await plugin.start!(mockContext);
189
-
190
- // Should log warning (translations exist but no i18n service) and not throw
191
- expect(mockContext.logger.warn).toHaveBeenCalledWith(
192
- expect.stringContaining('no i18n service is registered')
193
- );
194
- });
195
-
196
- it('should skip translation loading when getService throws for i18n', async () => {
197
- vi.mocked(mockContext.getService).mockImplementation((name: string) => {
198
- if (name === 'objectql') return mockQL;
199
- throw new Error("[Kernel] Service 'i18n' not found");
200
- });
201
-
202
- const bundle = {
203
- id: 'com.test.i18nthrow',
204
- translations: [{ en: { messages: { hello: 'Hello' } } }],
205
- };
206
- const plugin = new AppPlugin(bundle);
207
- await plugin.start!(mockContext);
208
-
209
- // Should log warning (translations exist but no i18n service) and not throw
210
- expect(mockContext.logger.warn).toHaveBeenCalledWith(
211
- expect.stringContaining('no i18n service is registered')
212
- );
213
- });
214
-
215
- it('should handle bundle with no translations gracefully', async () => {
216
- const bundle = { id: 'com.test.notrans' };
217
- const plugin = new AppPlugin(bundle);
218
- await plugin.start!(mockContext);
219
-
220
- expect(mockI18n.loadTranslations).not.toHaveBeenCalled();
221
- });
222
-
223
- it('should load translations from nested manifest.translations', async () => {
224
- const bundle = {
225
- manifest: {
226
- id: 'com.test.nested',
227
- translations: [
228
- { en: { messages: { save: 'Save' } } },
229
- ],
230
- },
231
- };
232
- const plugin = new AppPlugin(bundle);
233
- await plugin.start!(mockContext);
234
-
235
- expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } });
236
- });
237
-
238
- it('should load multiple translation bundles', async () => {
239
- const bundle = {
240
- id: 'com.test.multi',
241
- translations: [
242
- { en: { objects: { task: { label: 'Task' } } } },
243
- { en: { objects: { contact: { label: 'Contact' } } }, 'ja-JP': { objects: { contact: { label: '連絡先' } } } },
244
- ],
245
- };
246
- const plugin = new AppPlugin(bundle);
247
- await plugin.start!(mockContext);
248
-
249
- expect(mockI18n.loadTranslations).toHaveBeenCalledTimes(3);
250
- });
251
-
252
- it('should handle errors in loadTranslations gracefully', async () => {
253
- mockI18n.loadTranslations.mockImplementation((locale: string) => {
254
- if (locale === 'zh-CN') throw new Error('Disk read failed');
255
- });
256
-
257
- const bundle = {
258
- id: 'com.test.error',
259
- translations: [
260
- { en: { messages: { save: 'Save' } }, 'zh-CN': { messages: { save: '保存' } } },
261
- ],
262
- };
263
- const plugin = new AppPlugin(bundle);
264
- await plugin.start!(mockContext);
265
-
266
- // en should still be loaded despite zh-CN failure
267
- expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } });
268
- expect(mockContext.logger.warn).toHaveBeenCalledWith(
269
- expect.stringContaining('Failed to load translations'),
270
- expect.objectContaining({ locale: 'zh-CN' })
271
- );
272
- });
273
- });
274
- });
package/src/app-plugin.ts DELETED
@@ -1,285 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { Plugin, PluginContext } from '@objectstack/core';
4
- import { SeedLoaderService } from './seed-loader.js';
5
- import type { IMetadataService, II18nService } from '@objectstack/spec/contracts';
6
-
7
- /**
8
- * AppPlugin
9
- *
10
- * Adapts a generic App Bundle (Manifest + Runtime Code) into a Kernel Plugin.
11
- *
12
- * Responsibilities:
13
- * 1. Register App Manifest as a service (for ObjectQL discovery)
14
- * 2. Execute Runtime `onEnable` hook (for code logic)
15
- * 3. Auto-load i18n translation bundles into the kernel's i18n service
16
- */
17
- export class AppPlugin implements Plugin {
18
- name: string;
19
- type = 'app';
20
- version?: string;
21
-
22
- private bundle: any;
23
-
24
- constructor(bundle: any) {
25
- this.bundle = bundle;
26
- // Support both direct manifest (legacy) and Stack Definition (nested manifest)
27
- const sys = bundle.manifest || bundle;
28
- const appId = sys.id || sys.name || 'unnamed-app';
29
-
30
- this.name = `plugin.app.${appId}`;
31
- this.version = sys.version;
32
- }
33
-
34
- init = async (ctx: PluginContext) => {
35
- const sys = this.bundle.manifest || this.bundle;
36
- const appId = sys.id || sys.name;
37
-
38
- ctx.logger.info('Registering App Service', {
39
- appId,
40
- pluginName: this.name,
41
- version: this.version
42
- });
43
-
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
47
- ? { ...this.bundle.manifest, ...this.bundle }
48
- : this.bundle;
49
-
50
- ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
51
- }
52
-
53
- start = async (ctx: PluginContext) => {
54
- const sys = this.bundle.manifest || this.bundle;
55
- const appId = sys.id || sys.name;
56
-
57
- // Execute Runtime Step
58
- // Retrieve ObjectQL engine from services
59
- // ctx.getService throws when a service is not registered, so we
60
- // must use try/catch instead of a null-check.
61
- let ql: any;
62
- try {
63
- ql = ctx.getService('objectql');
64
- } catch {
65
- // Service not registered — handled below
66
- }
67
-
68
- if (!ql) {
69
- ctx.logger.warn('ObjectQL engine service not found', {
70
- appName: this.name,
71
- appId
72
- });
73
- return;
74
- }
75
-
76
- ctx.logger.debug('Retrieved ObjectQL engine service', { appId });
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
-
87
- const runtime = this.bundle.default || this.bundle;
88
-
89
- if (runtime && typeof runtime.onEnable === 'function') {
90
- ctx.logger.info('Executing runtime.onEnable', {
91
- appName: this.name,
92
- appId
93
- });
94
-
95
- // Construct the Host Context (mirroring old ObjectQL.use logic)
96
- const hostContext = {
97
- ...ctx,
98
- ql,
99
- logger: ctx.logger,
100
- drivers: {
101
- register: (driver: any) => {
102
- ctx.logger.debug('Registering driver via app runtime', {
103
- driverName: driver.name,
104
- appId
105
- });
106
- ql.registerDriver(driver);
107
- }
108
- },
109
- };
110
-
111
- await runtime.onEnable(hostContext);
112
- ctx.logger.debug('Runtime.onEnable completed', { appId });
113
- } else {
114
- ctx.logger.debug('No runtime.onEnable function found', { appId });
115
- }
116
-
117
- // ── i18n Translation Loading ─────────────────────────────────────
118
- // Auto-load translation bundles from the app config into the
119
- // kernel's i18n service, so discovery and handlers stay consistent.
120
- this.loadTranslations(ctx, appId);
121
-
122
- // Data Seeding
123
- // Collect seed data from multiple locations (top-level `data` preferred, `manifest.data` for backward compat)
124
- const seedDatasets: any[] = [];
125
-
126
- // 1. Top-level `data` field (new standard location on ObjectStackDefinition)
127
- if (Array.isArray(this.bundle.data)) {
128
- seedDatasets.push(...this.bundle.data);
129
- }
130
-
131
- // 2. Legacy: `manifest.data` (backward compatibility)
132
- const manifest = this.bundle.manifest || this.bundle;
133
- if (manifest && Array.isArray(manifest.data)) {
134
- seedDatasets.push(...manifest.data);
135
- }
136
-
137
- // Resolve short object names to FQN using the package's namespace.
138
- // e.g., seed `object: 'task'` in namespace 'todo' → 'todo__task'
139
- // Reserved namespaces ('base', 'system') are not prefixed.
140
- const namespace = (this.bundle.manifest || this.bundle)?.namespace as string | undefined;
141
- const RESERVED_NS = new Set(['base', 'system']);
142
- const toFQN = (name: string) => {
143
- if (name.includes('__') || !namespace || RESERVED_NS.has(namespace)) return name;
144
- return `${namespace}__${name}`;
145
- };
146
-
147
- if (seedDatasets.length > 0) {
148
- ctx.logger.info(`[AppPlugin] Found ${seedDatasets.length} seed datasets for ${appId}`);
149
-
150
- // Normalize dataset object names to FQN
151
- const normalizedDatasets = seedDatasets
152
- .filter((d: any) => d.object && Array.isArray(d.records))
153
- .map((d: any) => ({
154
- ...d,
155
- object: toFQN(d.object),
156
- }));
157
-
158
- // Use SeedLoaderService for metadata-driven loading with reference resolution
159
- try {
160
- const metadata = ctx.getService('metadata') as IMetadataService | undefined;
161
- if (metadata) {
162
- const seedLoader = new SeedLoaderService(ql, metadata, ctx.logger);
163
- const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data');
164
- const request = SeedLoaderRequestSchema.parse({
165
- datasets: normalizedDatasets,
166
- config: { defaultMode: 'upsert', multiPass: true },
167
- });
168
- const result = await seedLoader.load(request);
169
- ctx.logger.info('[Seeder] Seed loading complete', {
170
- inserted: result.summary.totalInserted,
171
- updated: result.summary.totalUpdated,
172
- errors: result.errors.length,
173
- });
174
- } else {
175
- // Fallback: basic insert when metadata service is not available
176
- ctx.logger.debug('[Seeder] No metadata service; using basic insert fallback');
177
- for (const dataset of normalizedDatasets) {
178
- ctx.logger.info(`[Seeder] Seeding ${dataset.records.length} records for ${dataset.object}`);
179
- for (const record of dataset.records) {
180
- try {
181
- await ql.insert(dataset.object, record);
182
- } catch (err: any) {
183
- ctx.logger.warn(`[Seeder] Failed to insert ${dataset.object} record:`, { error: err.message });
184
- }
185
- }
186
- }
187
- ctx.logger.info('[Seeder] Data seeding complete.');
188
- }
189
- } catch (err: any) {
190
- // If SeedLoaderService fails (e.g., metadata not available), fall back to basic insert
191
- ctx.logger.warn('[Seeder] SeedLoaderService failed, falling back to basic insert', { error: err.message });
192
- for (const dataset of normalizedDatasets) {
193
- for (const record of dataset.records) {
194
- try {
195
- await ql.insert(dataset.object, record);
196
- } catch (insertErr: any) {
197
- ctx.logger.warn(`[Seeder] Failed to insert ${dataset.object} record:`, { error: insertErr.message });
198
- }
199
- }
200
- }
201
- ctx.logger.info('[Seeder] Data seeding complete (fallback).');
202
- }
203
- }
204
- }
205
-
206
- /**
207
- * Auto-load i18n translation bundles from the app config into the
208
- * kernel's i18n service. Handles both `translations` (array of
209
- * TranslationBundle) and `i18n` config (default locale, etc.).
210
- *
211
- * Gracefully skips when the i18n service is not registered —
212
- * this keeps AppPlugin resilient across server/dev/mock environments.
213
- */
214
- private loadTranslations(ctx: PluginContext, appId: string): void {
215
- // ctx.getService throws when a service is not registered, so we
216
- // must use try/catch to gracefully skip when no i18n plugin is loaded.
217
- let i18nService: II18nService | undefined;
218
- try {
219
- i18nService = ctx.getService('i18n') as II18nService;
220
- } catch {
221
- // Service not registered — handled below
222
- }
223
-
224
- // Collect translation bundles early to determine if we have data
225
- const bundles: Array<Record<string, unknown>> = [];
226
- if (Array.isArray(this.bundle.translations)) {
227
- bundles.push(...this.bundle.translations);
228
- }
229
- const manifest = this.bundle.manifest || this.bundle;
230
- if (manifest && Array.isArray(manifest.translations) && manifest.translations !== this.bundle.translations) {
231
- bundles.push(...manifest.translations);
232
- }
233
-
234
- if (!i18nService) {
235
- if (bundles.length > 0) {
236
- ctx.logger.warn(
237
- `[i18n] App "${appId}" has ${bundles.length} translation bundle(s) but no i18n service is registered. ` +
238
- 'Translations will not be served via REST API. ' +
239
- 'Register I18nServicePlugin from @objectstack/service-i18n, or use DevPlugin ' +
240
- 'which auto-detects translations and registers the i18n service automatically.'
241
- );
242
- } else {
243
- ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId });
244
- }
245
- return;
246
- }
247
-
248
- // Apply i18n config (default locale, etc.)
249
- const i18nConfig = this.bundle.i18n || (this.bundle.manifest || this.bundle)?.i18n;
250
- if (i18nConfig?.defaultLocale && typeof i18nService.setDefaultLocale === 'function') {
251
- i18nService.setDefaultLocale(i18nConfig.defaultLocale);
252
- ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale });
253
- }
254
-
255
- if (bundles.length === 0) {
256
- return;
257
- }
258
-
259
- let loadedLocales = 0;
260
- for (const bundle of bundles) {
261
- // Each bundle is a TranslationBundle: Record<locale, TranslationData>
262
- for (const [locale, data] of Object.entries(bundle)) {
263
- if (data && typeof data === 'object') {
264
- try {
265
- i18nService.loadTranslations(locale, data as Record<string, unknown>);
266
- loadedLocales++;
267
- } catch (err: any) {
268
- ctx.logger.warn('[i18n] Failed to load translations', { appId, locale, error: err.message });
269
- }
270
- }
271
- }
272
- }
273
-
274
- // Emit diagnostic when the active i18n service is a fallback/stub
275
- const svcAny = i18nService as unknown as Record<string, unknown>;
276
- if (svcAny._fallback || svcAny._dev) {
277
- ctx.logger.info(
278
- `[i18n] Loaded ${loadedLocales} locale(s) into in-memory i18n fallback for "${appId}". ` +
279
- 'For production, consider registering I18nServicePlugin from @objectstack/service-i18n.'
280
- );
281
- } else {
282
- ctx.logger.info('[i18n] Loaded translation bundles', { appId, bundles: bundles.length, locales: loadedLocales });
283
- }
284
- }
285
- }