Skip to content

Commit 01b4164

Browse files
committed
Load multiple AppPlugin configs; adapt i18n
Refactor runtime config handling to load each example stack as an independent AppPlugin and normalize i18n/views handling. - Introduce prepareConfig() and export appConfigs + setupAppConfig from objectstack.shared.ts; merge views into object defs and convert stack i18n -> spec translations format. - Replace monolithic composeStacks/merged config approach with per-stack appConfigs; build aggregated sharedConfig fields (objects/apps/pages/dashboards/reports) from individual configs for backward compatibility. - Update createKernel to accept appConfigs (while still supporting legacy appConfig), register one AppPlugin per config, aggregate cubes for analytics, and collect i18n bundles across configs. - Update MSW/browser/server mocks and tests to pass appConfigs + setupAppConfig; adjust memory i18n plugin usage. - Update top-level objectstack.config.ts to prepare and register each example config as AppPlugin instead of using a composed merged app. This change fixes ordering/timing issues around SetupPlugin/AppPlugin and ensures the i18n pipeline and seed data work consistently across MSW and server modes.
1 parent dcc3ca9 commit 01b4164

7 files changed

Lines changed: 121 additions & 155 deletions

File tree

apps/console/objectstack.config.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const require = createRequire(import.meta.url);
44
// @ts-ignore
55
globalThis.require = require;
66

7-
import { sharedConfig } from './objectstack.shared';
7+
import { sharedConfig, appConfigs, setupAppConfig } from './objectstack.shared';
88

99
// @ts-ignore
1010
import * as MSWPluginPkg from '@objectstack/plugin-msw';
@@ -73,9 +73,12 @@ class MemoryI18nPlugin {
7373
*/
7474
const plugins: any[] = [
7575
new MemoryI18nPlugin(),
76-
new AppPlugin(sharedConfig),
7776
new ObjectQLPlugin(),
7877
new DriverPlugin(new InMemoryDriver(), 'memory'),
78+
// Each example stack loaded as an independent AppPlugin
79+
...appConfigs.map((config: any) => new AppPlugin(config)),
80+
// Setup App registered via AppPlugin so ObjectQLPlugin discovers it
81+
new AppPlugin(setupAppConfig),
7982
new HonoServerPlugin({ port: 3000 }),
8083
new ConsolePlugin(),
8184
];

apps/console/objectstack.shared.ts

Lines changed: 54 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { ObjectStackDefinition } from '@objectstack/spec';
2-
import { composeStacks } from '@objectstack/spec';
32
import { mergeViewsIntoObjects } from '@object-ui/core';
43
import { SETUP_APP_DEFAULTS } from '@objectstack/plugin-setup';
54
import crmConfigImport from '@object-ui/example-crm/objectstack.config';
@@ -15,47 +14,48 @@ function resolveDefault<T>(mod: MaybeDefault<T>): T {
1514
return mod as T;
1615
}
1716

18-
const crmConfig = resolveDefault<ObjectStackDefinition>(crmConfigImport);
19-
const todoConfig = resolveDefault<ObjectStackDefinition>(todoConfigImport);
20-
const kitchenSinkConfig = resolveDefault<ObjectStackDefinition>(kitchenSinkConfigImport);
21-
22-
const allConfigs = [crmConfig, todoConfig, kitchenSinkConfig];
23-
24-
// Aggregate seed data from all manifest.data arrays (spec selects one manifest,
25-
// so we collect data from all stacks before composing).
26-
const allData = allConfigs.flatMap((c: any) => c.manifest?.data || c.data || []);
27-
28-
// Aggregate i18n bundles from all stacks that declare an i18n section.
29-
// Each bundle carries a namespace (e.g. 'crm') and per-language translations.
30-
const i18nBundles = allConfigs
31-
.map((c: any) => c.i18n)
32-
.filter((i: any) => i?.namespace && i?.translations);
33-
34-
// Build the spec `translations` array for the runtime's AppPlugin.
35-
// AppPlugin.loadTranslations expects `translations: Array<{ [locale]: data }>`.
36-
// Each locale's data is nested under the bundle's namespace so that
37-
// both the server-mode (AppPlugin → memory i18n) and MSW-mode (createKernel)
38-
// produce the same structure: `{ crm: { objects: { ... } } }`.
39-
const specTranslations: Record<string, any>[] = i18nBundles.map((bundle: any) => {
40-
const result: Record<string, any> = {};
41-
for (const [locale, data] of Object.entries(bundle.translations)) {
42-
result[locale] = { [bundle.namespace]: data };
17+
/**
18+
* Adapter: prepare a stack config for AppPlugin.
19+
* - Merges stack-level views into object definitions
20+
* - Converts i18n translations to the spec format AppPlugin expects
21+
*/
22+
function prepareConfig(config: any) {
23+
const result = { ...config };
24+
if (result.objects && result.views) {
25+
result.objects = mergeViewsIntoObjects(result.objects, result.views);
26+
}
27+
if (result.i18n?.namespace && result.i18n?.translations) {
28+
const ns = result.i18n.namespace;
29+
const converted: Record<string, any> = {};
30+
for (const [locale, data] of Object.entries(result.i18n.translations)) {
31+
converted[locale] = { [ns]: data };
32+
}
33+
result.translations = [converted];
4334
}
4435
return result;
45-
});
36+
}
4637

47-
// Protocol-level composition via @objectstack/spec: handles object dedup,
48-
// array concatenation, actions→objects mapping, and manifest selection.
49-
const composed = composeStacks(allConfigs as any[], { objectConflict: 'override' }) as any;
38+
const crmConfig = prepareConfig(resolveDefault<ObjectStackDefinition>(crmConfigImport));
39+
const todoConfig = prepareConfig(resolveDefault<ObjectStackDefinition>(todoConfigImport));
40+
const kitchenSinkConfig = prepareConfig(resolveDefault<ObjectStackDefinition>(kitchenSinkConfigImport));
5041

51-
// Adapter: merge views[].listViews into object definitions for the runtime.
52-
if (composed.objects && composed.views) {
53-
composed.objects = mergeViewsIntoObjects(composed.objects, composed.views);
54-
}
42+
/**
43+
* Individual prepared configs for per-plugin AppPlugin loading.
44+
* Used by createKernel and server-mode objectstack.config.ts.
45+
*/
46+
export const appConfigs = [crmConfig, todoConfig, kitchenSinkConfig];
47+
48+
// Setup App config for registration via AppPlugin (avoids SetupPlugin timing issue)
49+
export const setupAppConfig = {
50+
apps: [SETUP_APP_DEFAULTS],
51+
manifest: { id: 'setup', name: 'setup' },
52+
};
5553

5654
// Patch CRM App Navigation to include Report using a supported navigation type
57-
// (type: 'url' passes schema validation while still routing correctly via React Router)
58-
const apps = [...JSON.parse(JSON.stringify(composed.apps || [])), SETUP_APP_DEFAULTS];
55+
const apps = [
56+
...JSON.parse(JSON.stringify(appConfigs.flatMap((c: any) => c.apps || []))),
57+
SETUP_APP_DEFAULTS,
58+
];
5959
const crmApp = apps.find((a: any) => a.name === 'crm_app');
6060
if (crmApp?.navigation) {
6161
const dashboardIdx = crmApp.navigation.findIndex((n: any) => n.id === 'nav_dashboard');
@@ -69,24 +69,29 @@ if (crmApp?.navigation) {
6969
});
7070
}
7171

72+
// Aggregate i18n bundles from all stacks
73+
const i18nBundles = appConfigs
74+
.map((c: any) => c.i18n)
75+
.filter((i: any) => i?.namespace && i?.translations);
76+
77+
// Aggregate seed data across all configs
78+
const allData = appConfigs.flatMap((c: any) => c.manifest?.data || c.data || []);
79+
80+
/**
81+
* Aggregated sharedConfig for backward compatibility.
82+
* Used by tests that mock objectstack.shared and by components
83+
* that need aggregated metadata (apps, objects, etc.).
84+
*/
7285
export const sharedConfig = {
73-
// ============================================================================
74-
// Project Metadata
75-
// ============================================================================
76-
7786
name: '@object-ui/console',
7887
version: '0.1.0',
7988
description: 'ObjectStack Console',
80-
81-
// ============================================================================
82-
// Merged Stack Configuration (CRM + Todo + Kitchen Sink)
83-
// ============================================================================
84-
objects: composed.objects,
89+
90+
objects: appConfigs.flatMap((c: any) => c.objects || []),
8591
apps,
86-
dashboards: composed.dashboards,
92+
dashboards: appConfigs.flatMap((c: any) => c.dashboards || []),
8793
reports: [
88-
...(composed.reports || []),
89-
// Console-specific report not in any example stack
94+
...appConfigs.flatMap((c: any) => c.reports || []),
9095
{
9196
name: 'sales_performance_q1',
9297
label: 'Q1 Sales Performance',
@@ -101,7 +106,7 @@ export const sharedConfig = {
101106
]
102107
}
103108
],
104-
pages: composed.pages,
109+
pages: appConfigs.flatMap((c: any) => c.pages || []),
105110
manifest: {
106111
id: 'com.objectui.console',
107112
version: '0.1.0',
@@ -113,11 +118,6 @@ export const sharedConfig = {
113118
bundles: i18nBundles,
114119
defaultLocale: 'en',
115120
},
116-
// Spec-format translations array consumed by AppPlugin.loadTranslations()
117-
// in real-server mode (pnpm start). Each entry maps locale → namespace-scoped
118-
// translation data so the runtime's memory i18n fallback serves the same
119-
// structure as the MSW mock handler.
120-
translations: specTranslations,
121121
plugins: [],
122122
datasources: [
123123
{

apps/console/src/__tests__/i18n-translations.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1313
import { setupServer } from 'msw/node';
1414
import { createKernel, type KernelResult } from '../mocks/createKernel';
1515
import { createAuthHandlers } from '../mocks/authHandlers';
16-
import appConfig from '../../objectstack.shared';
16+
import { appConfigs, setupAppConfig } from '../../objectstack.shared';
1717
import { crmLocales } from '@object-ui/example-crm';
1818

1919
// Expected values from the CRM i18n bundles — avoid hard-coding in assertions
@@ -26,7 +26,7 @@ describe('i18n translations pipeline', () => {
2626

2727
beforeAll(async () => {
2828
result = await createKernel({
29-
appConfig,
29+
appConfigs: [...appConfigs, setupAppConfig],
3030
persistence: false,
3131
mswOptions: {
3232
enableBrowser: false,

apps/console/src/mocks/browser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { setupWorker } from 'msw/browser';
1616
import { ObjectKernel } from '@objectstack/runtime';
1717
import { InMemoryDriver } from '@objectstack/driver-memory';
1818
import type { MSWPlugin } from '@objectstack/plugin-msw';
19-
import appConfig from '../../objectstack.shared';
19+
import { appConfigs, setupAppConfig } from '../../objectstack.shared';
2020
import { createKernel } from './createKernel';
2121
import { createAuthHandlers } from './authHandlers';
2222

@@ -43,7 +43,7 @@ export async function startMockServer() {
4343
if (import.meta.env.DEV) console.log('[MSW] Starting ObjectStack Runtime (Browser Mode)...');
4444

4545
const result = await createKernel({
46-
appConfig,
46+
appConfigs: [...appConfigs, setupAppConfig],
4747
mswOptions: {
4848
enableBrowser: false,
4949
baseUrl: '/api/v1',

apps/console/src/mocks/createKernel.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import type { Cube } from '@objectstack/spec/data';
2222
import { http, HttpResponse } from 'msw';
2323

2424
export interface KernelOptions {
25-
/** Application configuration (defineStack output) */
26-
appConfig: any;
25+
/** Application configuration (defineStack output). Deprecated: use appConfigs instead. */
26+
appConfig?: any;
27+
/** Individual application configurations loaded as separate AppPlugin instances. */
28+
appConfigs?: any[];
2729
/** Whether to skip system validation (useful in browser) */
2830
skipSystemValidation?: boolean;
2931
/** MSWPlugin options; when provided, MSWPlugin is added to the kernel. */
@@ -297,7 +299,10 @@ function resolveI18nTranslations(
297299
* so that kernel setup logic is not duplicated.
298300
*/
299301
export async function createKernel(options: KernelOptions): Promise<KernelResult> {
300-
const { appConfig, skipSystemValidation = true, mswOptions, persistence } = options;
302+
const { appConfig, appConfigs, skipSystemValidation = true, mswOptions, persistence } = options;
303+
304+
// Support both single appConfig (legacy) and multiple appConfigs
305+
const configs: any[] = appConfigs ?? (appConfig ? [appConfig] : []);
301306

302307
const driver = new InMemoryDriver(
303308
persistence !== undefined ? { persistence } : undefined,
@@ -309,15 +314,17 @@ export async function createKernel(options: KernelOptions): Promise<KernelResult
309314

310315
await kernel.use(new ObjectQLPlugin());
311316
await kernel.use(new DriverPlugin(driver, 'memory'));
312-
await kernel.use(new AppPlugin(appConfig));
317+
for (const config of configs) {
318+
await kernel.use(new AppPlugin(config));
319+
}
313320
await kernel.use(new SetupPlugin());
314321

315322
// Register MemoryAnalyticsService so that HttpDispatcher can serve
316323
// /api/v1/analytics/* endpoints in demo/MSW/dev environments.
317324
// Without this, analytics routes return 405 because the kernel has
318325
// no 'analytics' service and the dispatcher skips the handler.
319-
const cubes = buildCubesFromConfig(appConfig);
320-
const memoryAnalytics = new MemoryAnalyticsService({ driver, cubes });
326+
const allCubes = configs.flatMap(c => buildCubesFromConfig(c));
327+
const memoryAnalytics = new MemoryAnalyticsService({ driver, cubes: allCubes });
321328
kernel.registerService('analytics', {
322329
query: (query: any) => memoryAnalytics.query(query),
323330
getMeta: (cubeName?: string) => memoryAnalytics.getMeta(cubeName),
@@ -327,11 +334,13 @@ export async function createKernel(options: KernelOptions): Promise<KernelResult
327334
});
328335

329336
// ── i18n service registration ──────────────────────────────────────
330-
// Read translation bundles from the app config (populated by each stack's
331-
// `i18n: { namespace, translations }` field and merged via sharedConfig).
332-
// This ensures both MSW/mock and server modes share the same translation
333-
// resolution pipeline — no manual per-environment i18n handlers required.
334-
const i18nBundles: I18nBundle[] = appConfig.i18n?.bundles ?? [];
337+
// Collect translation bundles from all app configs.
338+
// Each config may have i18n.bundles (aggregated) or i18n with namespace+translations (individual stack).
339+
const i18nBundles: I18nBundle[] = configs.flatMap((c: any) => {
340+
if (c.i18n?.bundles) return c.i18n.bundles;
341+
if (c.i18n?.namespace && c.i18n?.translations) return [c.i18n];
342+
return [];
343+
});
335344

336345
if (i18nBundles.length > 0) {
337346
// Build a complete i18n service that satisfies both:

apps/console/src/mocks/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { ObjectKernel } from '@objectstack/runtime';
1616
import { InMemoryDriver } from '@objectstack/driver-memory';
1717
import { setupServer } from 'msw/node';
1818
import type { MSWPlugin } from '@objectstack/plugin-msw';
19-
import appConfig from '../../objectstack.shared';
19+
import { appConfigs, setupAppConfig } from '../../objectstack.shared';
2020
import { createKernel } from './createKernel';
2121
import { createAuthHandlers } from './authHandlers';
2222

@@ -34,7 +34,7 @@ export async function startMockServer() {
3434
console.log('[MSW] Starting ObjectStack Runtime (Test Mode)...');
3535

3636
const result = await createKernel({
37-
appConfig,
37+
appConfigs: [...appConfigs, setupAppConfig],
3838
persistence: false,
3939
mswOptions: {
4040
enableBrowser: false,

0 commit comments

Comments
 (0)