Skip to content

Commit 0b3b0e6

Browse files
Copilothotlong
andcommitted
feat: upgrade i18n to core built-in service with in-memory fallback
- ServiceRequirementDef.i18n: 'optional' → 'core' - Add createMemoryI18n fallback in packages/core/src/fallbacks/ - Add i18n to CORE_FALLBACK_FACTORIES for auto-injection - Fix protocol.ts SERVICE_CONFIG['i18n'].plugin to 'service-i18n' - Add enableI18n config + i18n route registration in RestServer - Update kernel-services.mdx and CHANGELOG.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ed5bfad commit 0b3b0e6

11 files changed

Lines changed: 256 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **i18n as core built-in service** — The i18n service is now a `core` criticality service with
12+
automatic in-memory fallback. When no plugin (e.g. `I18nServicePlugin`) registers an i18n service,
13+
the kernel auto-injects `createMemoryI18n` (in-memory Map-backed II18nService implementation)
14+
during `validateSystemRequirements()`. This ensures `/api/v1/i18n/*` routes and discovery always
15+
report i18n as available, even without `plugin-i18n` installed.
16+
- **REST i18n route auto-registration**`RestServer.registerRoutes()` now automatically registers
17+
`/api/v1/i18n/locales` and `/api/v1/i18n/translations/:locale` endpoints, controlled by the new
18+
`enableI18n` config flag (default: `true`).
19+
- `createMemoryI18n` fallback factory in `@objectstack/core` (packages/core/src/fallbacks/memory-i18n.ts)
20+
implementing `II18nService` contract with translation loading, dot-notation key resolution, parameter
21+
interpolation, and locale management.
22+
- `enableI18n` flag in `RestApiConfig` schema (`rest-server.zod.ts`) for toggling i18n API endpoints.
23+
24+
### Changed
25+
- `ServiceRequirementDef.i18n` upgraded from `'optional'` to `'core'` — kernel now warns (instead
26+
of silently ignoring) when no i18n service is registered, and auto-injects in-memory fallback.
27+
- `SERVICE_CONFIG['i18n'].plugin` in `protocol.ts` corrected from `'plugin-i18n'` to `'service-i18n'`
28+
to match the actual `@objectstack/service-i18n` package name.
29+
- Updated kernel-services.mdx documentation to reflect i18n as core/built-in capability.
30+
1031
### Fixed
1132
- **AppPlugin getService crash on missing services**`AppPlugin.start()` and
1233
`loadTranslations()` now wrap `ctx.getService()` in try/catch, since the kernel's

content/docs/guides/kernel-services.mdx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ The ObjectStack protocol defines **17 kernel services** registered via the `Core
5858
| 8 | **realtime** | `optional` | 6 | ❌ Plugin Required | TBD plugin |
5959
| 9 | **notification** | `optional` | 7 | ❌ Plugin Required | TBD plugin |
6060
| 10 | **ai** | `optional` | 4 | ❌ Plugin Required | TBD plugin |
61-
| 11 | **i18n** | `optional` | 3 | ❌ Plugin Required | TBD plugin |
61+
| 11 | **i18n** | `core` | 3 | ✅ Built-in (in-memory fallback) | `@objectstack/service-i18n` |
6262
| 12 | **file-storage** | `optional` || ❌ Plugin Required | TBD plugin |
6363
| 13 | **search** | `optional` || ❌ Plugin Required | TBD plugin |
6464
| 14 | **cache** | `core` || ❌ Plugin Required | TBD plugin |
@@ -246,27 +246,32 @@ Trigger engine, event triggers from ObjectQL hooks, flow executor, scheduled tri
246246
### 11. i18n — 3 methods
247247
`getLocales`, `getTranslations`, `getFieldLabels`
248248

249-
**Service Name**: `i18n` · **Criticality**: `optional`
250-
**Implementations**: `@objectstack/service-i18n` (production — file-based) · Dev Plugin (in-memory stub)
249+
**Service Name**: `i18n` · **Criticality**: `core`
250+
**Implementations**: `@objectstack/service-i18n` (production — file-based) · Built-in in-memory fallback · Dev Plugin (in-memory stub)
251251
**Route Mount**: `/api/v1/i18n`
252252
**Contract**: `II18nService` in `@objectstack/spec/contracts`
253253

254254
#### Service Registration
255255

256-
The i18n service is registered by a **plugin** during the `init` phase:
256+
The i18n service is a **core built-in service** with automatic in-memory fallback. If no plugin registers the service, the kernel automatically injects an in-memory implementation during startup:
257257

258258
| Environment | Provider | Registration |
259259
|:------------|:---------|:-------------|
260260
| **Production** | `I18nServicePlugin` | File-based `FileI18nAdapter` loads JSON locale files from disk |
261+
| **Built-in Fallback** | Kernel | In-memory Map-backed stub, auto-injected when no plugin provides `i18n` |
261262
| **Development** | `DevPlugin` | In-memory Map-backed stub, supports `loadTranslations()` |
262263
| **Mock / MSW** | `MswPlugin` | Routes via `HttpDispatcher.dispatch()` catch-all — requires one of the above |
263264

264265
```typescript
265-
// Production
266+
// Production (overrides built-in fallback)
266267
kernel.use(new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' }));
267268

268269
// Development (automatic — DevPlugin registers i18n stub for all 17 core services)
269270
kernel.use(new DevPlugin());
271+
272+
// No plugin required — kernel auto-injects in-memory fallback if none registered
273+
const kernel = new ObjectKernel();
274+
await kernel.bootstrap(); // i18n service available via built-in fallback
270275
```
271276

272277
#### Discovery & Handler Consistency

packages/core/src/fallbacks/fallbacks.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
22
import { createMemoryCache } from './memory-cache';
33
import { createMemoryQueue } from './memory-queue';
44
import { createMemoryJob } from './memory-job';
5+
import { createMemoryI18n } from './memory-i18n';
56
import { CORE_FALLBACK_FACTORIES } from './index';
67

78
describe('CORE_FALLBACK_FACTORIES', () => {
8-
it('should have exactly 3 entries: cache, queue, job', () => {
9-
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job']);
9+
it('should have exactly 4 entries: cache, queue, job, i18n', () => {
10+
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job', 'i18n']);
1011
});
1112

1213
it('should map to factory functions', () => {
@@ -156,3 +157,68 @@ describe('createMemoryJob', () => {
156157
expect(await job.getExecutions()).toEqual([]);
157158
});
158159
});
160+
161+
describe('createMemoryI18n', () => {
162+
it('should return an object with _fallback: true', () => {
163+
const i18n = createMemoryI18n();
164+
expect(i18n._fallback).toBe(true);
165+
expect(i18n._serviceName).toBe('i18n');
166+
});
167+
168+
it('should return the key when no translations are loaded', () => {
169+
const i18n = createMemoryI18n();
170+
expect(i18n.t('objects.account.label', 'en')).toBe('objects.account.label');
171+
});
172+
173+
it('should translate after loading translations', () => {
174+
const i18n = createMemoryI18n();
175+
i18n.loadTranslations('en', { objects: { account: { label: 'Account' } } });
176+
expect(i18n.t('objects.account.label', 'en')).toBe('Account');
177+
});
178+
179+
it('should interpolate parameters', () => {
180+
const i18n = createMemoryI18n();
181+
i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });
182+
expect(i18n.t('greeting', 'en', { name: 'World' })).toBe('Hello, World!');
183+
});
184+
185+
it('should fall back to default locale', () => {
186+
const i18n = createMemoryI18n();
187+
i18n.loadTranslations('en', { hello: 'Hello' });
188+
// 'fr' has no translations, should fall back to default 'en'
189+
expect(i18n.t('hello', 'fr')).toBe('Hello');
190+
});
191+
192+
it('should get and set default locale', () => {
193+
const i18n = createMemoryI18n();
194+
expect(i18n.getDefaultLocale()).toBe('en');
195+
i18n.setDefaultLocale('zh-CN');
196+
expect(i18n.getDefaultLocale()).toBe('zh-CN');
197+
});
198+
199+
it('should list loaded locales', () => {
200+
const i18n = createMemoryI18n();
201+
expect(i18n.getLocales()).toEqual([]);
202+
i18n.loadTranslations('en', { hello: 'Hello' });
203+
i18n.loadTranslations('zh-CN', { hello: '你好' });
204+
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
205+
});
206+
207+
it('should get all translations for a locale', () => {
208+
const i18n = createMemoryI18n();
209+
i18n.loadTranslations('en', { hello: 'Hello', bye: 'Goodbye' });
210+
expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
211+
});
212+
213+
it('should return empty object for unknown locale', () => {
214+
const i18n = createMemoryI18n();
215+
expect(i18n.getTranslations('unknown')).toEqual({});
216+
});
217+
218+
it('should merge translations on subsequent loads', () => {
219+
const i18n = createMemoryI18n();
220+
i18n.loadTranslations('en', { hello: 'Hello' });
221+
i18n.loadTranslations('en', { bye: 'Goodbye' });
222+
expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
223+
});
224+
});

packages/core/src/fallbacks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import { createMemoryCache } from './memory-cache.js';
44
import { createMemoryQueue } from './memory-queue.js';
55
import { createMemoryJob } from './memory-job.js';
6+
import { createMemoryI18n } from './memory-i18n.js';
67

78
export { createMemoryCache } from './memory-cache.js';
89
export { createMemoryQueue } from './memory-queue.js';
910
export { createMemoryJob } from './memory-job.js';
11+
export { createMemoryI18n } from './memory-i18n.js';
1012

1113
/**
1214
* Map of core-criticality service names to their in-memory fallback factories.
@@ -17,4 +19,5 @@ export const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>
1719
cache: createMemoryCache,
1820
queue: createMemoryQueue,
1921
job: createMemoryJob,
22+
i18n: createMemoryI18n,
2023
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* In-memory i18n service fallback.
5+
*
6+
* Implements the II18nService contract with basic translate/load/getLocales
7+
* operations. Used by ObjectKernel as an automatic fallback when no real
8+
* i18n plugin (e.g. I18nServicePlugin) is registered.
9+
*
10+
* Supports runtime translation loading and locale management.
11+
* Does not load files from disk — operates purely in-memory.
12+
*/
13+
export function createMemoryI18n() {
14+
const translations = new Map<string, Record<string, unknown>>();
15+
let defaultLocale = 'en';
16+
17+
/**
18+
* Resolve a dot-notation key from a nested object.
19+
*/
20+
function resolveKey(data: Record<string, unknown>, key: string): string | undefined {
21+
const parts = key.split('.');
22+
let current: unknown = data;
23+
for (const part of parts) {
24+
if (current == null || typeof current !== 'object') return undefined;
25+
current = (current as Record<string, unknown>)[part];
26+
}
27+
return typeof current === 'string' ? current : undefined;
28+
}
29+
30+
return {
31+
_fallback: true, _serviceName: 'i18n',
32+
33+
t(key: string, locale: string, params?: Record<string, unknown>): string {
34+
const data = translations.get(locale) ?? translations.get(defaultLocale);
35+
const value = data ? resolveKey(data, key) : undefined;
36+
if (value == null) return key;
37+
if (!params) return value;
38+
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? `{{${name}}}`));
39+
},
40+
41+
getTranslations(locale: string): Record<string, unknown> {
42+
return translations.get(locale) ?? {};
43+
},
44+
45+
loadTranslations(locale: string, data: Record<string, unknown>): void {
46+
const existing = translations.get(locale) ?? {};
47+
translations.set(locale, { ...existing, ...data });
48+
},
49+
50+
getLocales(): string[] {
51+
return [...translations.keys()];
52+
},
53+
54+
getDefaultLocale(): string {
55+
return defaultLocale;
56+
},
57+
58+
setDefaultLocale(locale: string): void {
59+
defaultLocale = locale;
60+
},
61+
};
62+
}

packages/objectql/src/protocol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
4444
realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' },
4545
notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' },
4646
ai: { route: '/api/v1/ai', plugin: 'plugin-ai' },
47-
i18n: { route: '/api/v1/i18n', plugin: 'plugin-i18n' },
47+
i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' },
4848
graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST)
4949
'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' },
5050
search: { route: '/api/v1/search', plugin: 'plugin-search' },

packages/rest/src/rest-server.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type NormalizedRestServerConfig = {
1919
enableUi: boolean;
2020
enableBatch: boolean;
2121
enableDiscovery: boolean;
22+
enableI18n: boolean;
2223
documentation: RestApiConfig['documentation'];
2324
responseFormat: RestApiConfig['responseFormat'];
2425
};
@@ -126,6 +127,7 @@ export class RestServer {
126127
enableUi: api.enableUi ?? true,
127128
enableBatch: api.enableBatch ?? true,
128129
enableDiscovery: api.enableDiscovery ?? true,
130+
enableI18n: api.enableI18n ?? true,
129131
documentation: api.documentation,
130132
responseFormat: api.responseFormat,
131133
},
@@ -210,6 +212,11 @@ export class RestServer {
210212
if (this.config.api.enableBatch) {
211213
this.registerBatchEndpoints(basePath);
212214
}
215+
216+
// i18n endpoints
217+
if (this.config.api.enableI18n) {
218+
this.registerI18nEndpoints(basePath);
219+
}
213220
}
214221

215222
/**
@@ -671,6 +678,57 @@ export class RestServer {
671678
});
672679
}
673680
}
681+
682+
/**
683+
* Register i18n endpoints for locale and translation operations
684+
*/
685+
private registerI18nEndpoints(basePath: string): void {
686+
const i18nPath = `${basePath}/i18n`;
687+
688+
// GET /i18n/locales - List available locales
689+
this.routeManager.register({
690+
method: 'GET',
691+
path: `${i18nPath}/locales`,
692+
handler: async (_req: any, res: any) => {
693+
try {
694+
if (this.protocol.getLocales) {
695+
const locales = await this.protocol.getLocales({});
696+
res.json(locales);
697+
} else {
698+
res.status(501).json({ error: 'i18n locales not supported by protocol implementation' });
699+
}
700+
} catch (error: any) {
701+
res.status(500).json({ error: error.message });
702+
}
703+
},
704+
metadata: {
705+
summary: 'Get available locales',
706+
tags: ['i18n'],
707+
},
708+
});
709+
710+
// GET /i18n/translations/:locale - Get translations for a locale
711+
this.routeManager.register({
712+
method: 'GET',
713+
path: `${i18nPath}/translations/:locale`,
714+
handler: async (req: any, res: any) => {
715+
try {
716+
if (this.protocol.getTranslations) {
717+
const translations = await this.protocol.getTranslations({ locale: req.params.locale });
718+
res.json(translations);
719+
} else {
720+
res.status(501).json({ error: 'i18n translations not supported by protocol implementation' });
721+
}
722+
} catch (error: any) {
723+
res.status(500).json({ error: error.message });
724+
}
725+
},
726+
metadata: {
727+
summary: 'Get translations for a locale',
728+
tags: ['i18n'],
729+
},
730+
});
731+
}
674732

675733
/**
676734
* Get the route manager

packages/rest/src/rest.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,31 @@ describe('RestServer', () => {
398398
);
399399
expect(uiRoutes.length).toBeGreaterThan(0);
400400
});
401+
402+
it('should register i18n endpoints by default', () => {
403+
const rest = new RestServer(server as any, protocol as any);
404+
rest.registerRoutes();
405+
406+
const i18nRoutes = rest.getRoutes().filter((r) =>
407+
r.metadata?.tags?.includes('i18n'),
408+
);
409+
expect(i18nRoutes.length).toBeGreaterThan(0);
410+
const paths = i18nRoutes.map((r) => r.path);
411+
expect(paths).toContain('/api/v1/i18n/locales');
412+
expect(paths).toContain('/api/v1/i18n/translations/:locale');
413+
});
414+
415+
it('should skip i18n routes when enableI18n is false', () => {
416+
const rest = new RestServer(server as any, protocol as any, {
417+
api: { enableI18n: false },
418+
} as any);
419+
rest.registerRoutes();
420+
421+
const i18nRoutes = rest.getRoutes().filter((r) =>
422+
r.metadata?.tags?.includes('i18n'),
423+
);
424+
expect(i18nRoutes).toHaveLength(0);
425+
});
401426
});
402427

403428
// -- getRouteManager / getRoutes ------------------------------------------

packages/spec/src/api/rest-server.zod.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export const RestApiConfigSchema = z.object({
8383
* Enable discovery endpoint
8484
*/
8585
enableDiscovery: z.boolean().default(true).describe('Enable API discovery endpoint'),
86+
87+
/**
88+
* Enable i18n endpoints (locales, translations)
89+
*/
90+
enableI18n: z.boolean().default(true).describe('Enable i18n API endpoints (locales, translations)'),
8691

8792
/**
8893
* API documentation configuration

packages/spec/src/system/core-services.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe('ServiceRequirementDef', () => {
5555
expect(ServiceRequirementDef.cache).toBe('core');
5656
expect(ServiceRequirementDef.queue).toBe('core');
5757
expect(ServiceRequirementDef.job).toBe('core');
58+
expect(ServiceRequirementDef.i18n).toBe('core');
5859
});
5960

6061
it('should define optional services', () => {
@@ -66,7 +67,6 @@ describe('ServiceRequirementDef', () => {
6667
expect(ServiceRequirementDef.realtime).toBe('optional');
6768
expect(ServiceRequirementDef.notification).toBe('optional');
6869
expect(ServiceRequirementDef.ai).toBe('optional');
69-
expect(ServiceRequirementDef.i18n).toBe('optional');
7070
expect(ServiceRequirementDef.ui).toBe('optional');
7171
expect(ServiceRequirementDef.workflow).toBe('optional');
7272
});

0 commit comments

Comments
 (0)