Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **i18n as core built-in service** — The i18n service is now a `core` criticality service with
automatic in-memory fallback. When no plugin (e.g. `I18nServicePlugin`) registers an i18n service,
the kernel auto-injects `createMemoryI18n` (in-memory Map-backed II18nService implementation)
during `validateSystemRequirements()`. This ensures `/api/v1/i18n/*` routes and discovery always
report i18n as available, even without `plugin-i18n` installed.
- **REST i18n route auto-registration** — `RestServer.registerRoutes()` now automatically registers
`/api/v1/i18n/locales` and `/api/v1/i18n/translations/:locale` endpoints, controlled by the new
`enableI18n` config flag (default: `true`).
- `createMemoryI18n` fallback factory in `@objectstack/core` (packages/core/src/fallbacks/memory-i18n.ts)
implementing `II18nService` contract with translation loading, dot-notation key resolution, parameter
interpolation, and locale management.
- `enableI18n` flag in `RestApiConfig` schema (`rest-server.zod.ts`) for toggling i18n API endpoints.

### Changed
- `ServiceRequirementDef.i18n` upgraded from `'optional'` to `'core'` — kernel now warns (instead
of silently ignoring) when no i18n service is registered, and auto-injects in-memory fallback.
- `SERVICE_CONFIG['i18n'].plugin` in `protocol.ts` corrected from `'plugin-i18n'` to `'service-i18n'`
to match the actual `@objectstack/service-i18n` package name.
- Updated kernel-services.mdx documentation to reflect i18n as core/built-in capability.

### Fixed
- **AppPlugin getService crash on missing services** — `AppPlugin.start()` and
`loadTranslations()` now wrap `ctx.getService()` in try/catch, since the kernel's
Expand Down
15 changes: 10 additions & 5 deletions content/docs/guides/kernel-services.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The ObjectStack protocol defines **17 kernel services** registered via the `Core
| 8 | **realtime** | `optional` | 6 | ❌ Plugin Required | TBD plugin |
| 9 | **notification** | `optional` | 7 | ❌ Plugin Required | TBD plugin |
| 10 | **ai** | `optional` | 4 | ❌ Plugin Required | TBD plugin |
| 11 | **i18n** | `optional` | 3 | ❌ Plugin Required | TBD plugin |
| 11 | **i18n** | `core` | 3 | ✅ Built-in (in-memory fallback) | `@objectstack/service-i18n` |
| 12 | **file-storage** | `optional` | — | ❌ Plugin Required | TBD plugin |
| 13 | **search** | `optional` | — | ❌ Plugin Required | TBD plugin |
| 14 | **cache** | `core` | — | ❌ Plugin Required | TBD plugin |
Expand Down Expand Up @@ -246,27 +246,32 @@ Trigger engine, event triggers from ObjectQL hooks, flow executor, scheduled tri
### 11. i18n — 3 methods
`getLocales`, `getTranslations`, `getFieldLabels`

**Service Name**: `i18n` · **Criticality**: `optional`
**Implementations**: `@objectstack/service-i18n` (production — file-based) · Dev Plugin (in-memory stub)
**Service Name**: `i18n` · **Criticality**: `core`
**Implementations**: `@objectstack/service-i18n` (production — file-based) · Built-in in-memory fallback · Dev Plugin (in-memory stub)
Comment on lines 246 to +250

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section lists getFieldLabels as an i18n service method, but II18nService does not define getFieldLabels (it’s only present on the HTTP/protocol surface, and even there it’s optional/derivable). Update the doc to avoid implying the kernel i18n service must implement getFieldLabels (e.g., mark it as optional or move it under protocol endpoints).

Copilot uses AI. Check for mistakes.
**Route Mount**: `/api/v1/i18n`
**Contract**: `II18nService` in `@objectstack/spec/contracts`

#### Service Registration

The i18n service is registered by a **plugin** during the `init` phase:
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:

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

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

// Development (automatic — DevPlugin registers i18n stub for all 17 core services)
kernel.use(new DevPlugin());

// No plugin required — kernel auto-injects in-memory fallback if none registered
const kernel = new ObjectKernel();
await kernel.bootstrap(); // i18n service available via built-in fallback
```

#### Discovery & Handler Consistency
Expand Down
70 changes: 68 additions & 2 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
import { createMemoryCache } from './memory-cache';
import { createMemoryQueue } from './memory-queue';
import { createMemoryJob } from './memory-job';
import { createMemoryI18n } from './memory-i18n';
import { CORE_FALLBACK_FACTORIES } from './index';

describe('CORE_FALLBACK_FACTORIES', () => {
it('should have exactly 3 entries: cache, queue, job', () => {
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job']);
it('should have exactly 4 entries: cache, queue, job, i18n', () => {
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job', 'i18n']);
});

it('should map to factory functions', () => {
Expand Down Expand Up @@ -156,3 +157,68 @@ describe('createMemoryJob', () => {
expect(await job.getExecutions()).toEqual([]);
});
});

describe('createMemoryI18n', () => {
it('should return an object with _fallback: true', () => {
const i18n = createMemoryI18n();
expect(i18n._fallback).toBe(true);
expect(i18n._serviceName).toBe('i18n');
});

it('should return the key when no translations are loaded', () => {
const i18n = createMemoryI18n();
expect(i18n.t('objects.account.label', 'en')).toBe('objects.account.label');
});

it('should translate after loading translations', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { objects: { account: { label: 'Account' } } });
expect(i18n.t('objects.account.label', 'en')).toBe('Account');
});

it('should interpolate parameters', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });
expect(i18n.t('greeting', 'en', { name: 'World' })).toBe('Hello, World!');
});

it('should fall back to default locale', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { hello: 'Hello' });
// 'fr' has no translations, should fall back to default 'en'
expect(i18n.t('hello', 'fr')).toBe('Hello');
});

it('should get and set default locale', () => {
const i18n = createMemoryI18n();
expect(i18n.getDefaultLocale()).toBe('en');
i18n.setDefaultLocale('zh-CN');
expect(i18n.getDefaultLocale()).toBe('zh-CN');
});

it('should list loaded locales', () => {
const i18n = createMemoryI18n();
expect(i18n.getLocales()).toEqual([]);
i18n.loadTranslations('en', { hello: 'Hello' });
i18n.loadTranslations('zh-CN', { hello: '你好' });
expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
});

it('should get all translations for a locale', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { hello: 'Hello', bye: 'Goodbye' });
expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
});

it('should return empty object for unknown locale', () => {
const i18n = createMemoryI18n();
expect(i18n.getTranslations('unknown')).toEqual({});
});

it('should merge translations on subsequent loads', () => {
const i18n = createMemoryI18n();
i18n.loadTranslations('en', { hello: 'Hello' });
i18n.loadTranslations('en', { bye: 'Goodbye' });
expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
});
});
3 changes: 3 additions & 0 deletions packages/core/src/fallbacks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import { createMemoryCache } from './memory-cache.js';
import { createMemoryQueue } from './memory-queue.js';
import { createMemoryJob } from './memory-job.js';
import { createMemoryI18n } from './memory-i18n.js';

export { createMemoryCache } from './memory-cache.js';
export { createMemoryQueue } from './memory-queue.js';
export { createMemoryJob } from './memory-job.js';
export { createMemoryI18n } from './memory-i18n.js';

/**
* Map of core-criticality service names to their in-memory fallback factories.
Expand All @@ -17,4 +19,5 @@ export const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>
cache: createMemoryCache,
queue: createMemoryQueue,
job: createMemoryJob,
i18n: createMemoryI18n,
};
63 changes: 63 additions & 0 deletions packages/core/src/fallbacks/memory-i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* In-memory i18n service fallback.
*
* Implements the II18nService contract with basic translate/load/getLocales
* operations. Used by ObjectKernel as an automatic fallback when no real
* i18n plugin (e.g. I18nServicePlugin) is registered.
*
* Supports runtime translation loading and locale management.
* Does not load files from disk — operates purely in-memory.
*/
export function createMemoryI18n() {
const translations = new Map<string, Record<string, unknown>>();
let defaultLocale = 'en';

/**
* Resolve a dot-notation key from a nested object.
*/
function resolveKey(data: Record<string, unknown>, key: string): string | undefined {
const parts = key.split('.');
let current: unknown = data;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[part];
}
return typeof current === 'string' ? current : undefined;
}

return {
_fallback: true, _serviceName: 'i18n',

t(key: string, locale: string, params?: Record<string, unknown>): string {
const data = translations.get(locale) ?? translations.get(defaultLocale);
const value = data ? resolveKey(data, key) : undefined;
if (value == null) return key;
if (!params) return value;
// Interpolation format: {{paramName}} — matches FileI18nAdapter convention
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? `{{${name}}}`));
},

getTranslations(locale: string): Record<string, unknown> {
return translations.get(locale) ?? {};
},

loadTranslations(locale: string, data: Record<string, unknown>): void {
const existing = translations.get(locale) ?? {};
translations.set(locale, { ...existing, ...data });
},

getLocales(): string[] {
return [...translations.keys()];
},

getDefaultLocale(): string {
return defaultLocale;
},

setDefaultLocale(locale: string): void {
defaultLocale = locale;
},
};
}
2 changes: 1 addition & 1 deletion packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' },
notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' },
ai: { route: '/api/v1/ai', plugin: 'plugin-ai' },
i18n: { route: '/api/v1/i18n', plugin: 'plugin-i18n' },
i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' },
graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST)
Comment on lines 46 to 48

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discovery uses SERVICE_CONFIG to advertise i18n availability, and RestServer now mounts /api/v1/i18n/* routes that call protocol.getLocales/getTranslations. However, ObjectStackProtocolImplementation does not implement these optional i18n methods, so the endpoints will consistently return 501 even when the kernel has auto-injected the i18n fallback service. Implement getLocales, getTranslations (and ideally getFieldLabels) in ObjectStackProtocolImplementation by reading the registered i18n service from getServicesRegistry() and shaping the responses to the spec schemas.

Copilot uses AI. Check for mistakes.
'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' },
search: { route: '/api/v1/search', plugin: 'plugin-search' },
Expand Down
58 changes: 58 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type NormalizedRestServerConfig = {
enableUi: boolean;
enableBatch: boolean;
enableDiscovery: boolean;
enableI18n: boolean;
documentation: RestApiConfig['documentation'];
responseFormat: RestApiConfig['responseFormat'];
};
Expand Down Expand Up @@ -126,6 +127,7 @@ export class RestServer {
enableUi: api.enableUi ?? true,
enableBatch: api.enableBatch ?? true,
enableDiscovery: api.enableDiscovery ?? true,
enableI18n: api.enableI18n ?? true,
documentation: api.documentation,
responseFormat: api.responseFormat,
},
Expand Down Expand Up @@ -210,6 +212,11 @@ export class RestServer {
if (this.config.api.enableBatch) {
this.registerBatchEndpoints(basePath);
}

// i18n endpoints
if (this.config.api.enableI18n) {
this.registerI18nEndpoints(basePath);
}
}

/**
Expand Down Expand Up @@ -671,6 +678,57 @@ export class RestServer {
});
}
}

/**
* Register i18n endpoints for locale and translation operations
*/
private registerI18nEndpoints(basePath: string): void {
const i18nPath = `${basePath}/i18n`;

// GET /i18n/locales - List available locales
this.routeManager.register({
method: 'GET',
path: `${i18nPath}/locales`,
handler: async (_req: any, res: any) => {
try {
if (this.protocol.getLocales) {
const locales = await this.protocol.getLocales({});
res.json(locales);
} else {
res.status(501).json({ error: 'i18n service not available in protocol' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
},
metadata: {
summary: 'Get available locales',
tags: ['i18n'],
},
});

// GET /i18n/translations/:locale - Get translations for a locale
this.routeManager.register({
method: 'GET',
Comment on lines +682 to +712

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RestServer doesn’t register any /api/v1/i18n/labels/... endpoint, but the client calls /i18n/labels/:object?locale=... and the protocol spec defines getFieldLabels. Without this route, client.i18n.getFieldLabels() will fail for RestServer-based deployments. Add a labels endpoint (query-style and/or /:object/:locale) that forwards to protocol.getFieldLabels when available.

Copilot uses AI. Check for mistakes.
path: `${i18nPath}/translations/:locale`,
handler: async (req: any, res: any) => {
try {
if (this.protocol.getTranslations) {
const translations = await this.protocol.getTranslations({ locale: req.params.locale });

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registerI18nEndpoints only forwards locale to protocol.getTranslations and ignores optional request fields defined in the protocol schema (e.g. namespace, keys). This makes /i18n/translations/:locale incompatible with GetTranslationsRequestSchema and prevents clients from requesting scoped subsets of translations. Pass through supported query params (e.g. req.query.namespace and a parsed req.query.keys) when calling getTranslations.

Suggested change
const translations = await this.protocol.getTranslations({ locale: req.params.locale });
const locale = req.params.locale;
const { namespace, keys } = req.query ?? {};
let parsedKeys: string[] | undefined;
if (Array.isArray(keys)) {
parsedKeys = keys.map((k) => String(k));
} else if (typeof keys === 'string' && keys.length > 0) {
let jsonParsed: unknown;
try {
jsonParsed = JSON.parse(keys);
} catch {
jsonParsed = undefined;
}
if (Array.isArray(jsonParsed)) {
parsedKeys = jsonParsed.map((k) => String(k));
} else {
parsedKeys = keys
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0);
}
}
const request: any = { locale };
if (typeof namespace === 'string' && namespace.length > 0) {
request.namespace = namespace;
}
if (parsedKeys && parsedKeys.length > 0) {
request.keys = parsedKeys;
}
const translations = await this.protocol.getTranslations(request);

Copilot uses AI. Check for mistakes.
res.json(translations);
} else {
res.status(501).json({ error: 'i18n service not available in protocol' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
},
Comment on lines +710 to +725

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The translations route is registered as /i18n/translations/:locale, but the ObjectStack client uses /i18n/translations?locale=... (and runtime HttpDispatcher supports both). As-is, RestServer-based deployments will 404 for the client’s default request shape. Consider registering the query-style route (and optionally keep /:locale as an alias) and accept locale from req.query when not present in the path.

Suggested change
// GET /i18n/translations/:locale - Get translations for a locale
this.routeManager.register({
method: 'GET',
path: `${i18nPath}/translations/:locale`,
handler: async (req: any, res: any) => {
try {
if (this.protocol.getTranslations) {
const translations = await this.protocol.getTranslations({ locale: req.params.locale });
res.json(translations);
} else {
res.status(501).json({ error: 'i18n service not available in protocol' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
},
const translationsHandler = async (req: any, res: any) => {
try {
const localeFromParams = req && req.params ? req.params.locale : undefined;
const localeFromQuery = req && req.query ? (req.query.locale as string | undefined) : undefined;
const locale = localeFromParams ?? localeFromQuery;
if (!locale) {
res.status(400).json({ error: 'Missing required "locale" parameter' });
return;
}
if (this.protocol.getTranslations) {
const translations = await this.protocol.getTranslations({ locale });
res.json(translations);
} else {
res.status(501).json({ error: 'i18n service not available in protocol' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
};
// GET /i18n/translations?locale=... - Get translations for a locale (query style, default client shape)
this.routeManager.register({
method: 'GET',
path: `${i18nPath}/translations`,
handler: translationsHandler,
metadata: {
summary: 'Get translations for a locale',
tags: ['i18n'],
},
});
// GET /i18n/translations/:locale - Get translations for a locale (path parameter alias)
this.routeManager.register({
method: 'GET',
path: `${i18nPath}/translations/:locale`,
handler: translationsHandler,

Copilot uses AI. Check for mistakes.
metadata: {
summary: 'Get translations for a locale',
tags: ['i18n'],
},
});
}

/**
* Get the route manager
Expand Down
25 changes: 25 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,31 @@ describe('RestServer', () => {
);
expect(uiRoutes.length).toBeGreaterThan(0);
});

it('should register i18n endpoints by default', () => {
const rest = new RestServer(server as any, protocol as any);
rest.registerRoutes();

const i18nRoutes = rest.getRoutes().filter((r) =>
r.metadata?.tags?.includes('i18n'),
);
expect(i18nRoutes.length).toBeGreaterThan(0);
const paths = i18nRoutes.map((r) => r.path);
expect(paths).toContain('/api/v1/i18n/locales');
expect(paths).toContain('/api/v1/i18n/translations/:locale');
});

it('should skip i18n routes when enableI18n is false', () => {
const rest = new RestServer(server as any, protocol as any, {
api: { enableI18n: false },
} as any);
rest.registerRoutes();

const i18nRoutes = rest.getRoutes().filter((r) =>
r.metadata?.tags?.includes('i18n'),
);
expect(i18nRoutes).toHaveLength(0);
});
});

// -- getRouteManager / getRoutes ------------------------------------------
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/src/api/rest-server.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export const RestApiConfigSchema = z.object({
* Enable discovery endpoint
*/
enableDiscovery: z.boolean().default(true).describe('Enable API discovery endpoint'),

/**
* Enable i18n endpoints (locales, translations)
*/
enableI18n: z.boolean().default(true).describe('Enable i18n API endpoints (locales, translations)'),

/**
* API documentation configuration
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/system/core-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('ServiceRequirementDef', () => {
expect(ServiceRequirementDef.cache).toBe('core');
expect(ServiceRequirementDef.queue).toBe('core');
expect(ServiceRequirementDef.job).toBe('core');
expect(ServiceRequirementDef.i18n).toBe('core');
});

it('should define optional services', () => {
Expand All @@ -66,7 +67,6 @@ describe('ServiceRequirementDef', () => {
expect(ServiceRequirementDef.realtime).toBe('optional');
expect(ServiceRequirementDef.notification).toBe('optional');
expect(ServiceRequirementDef.ai).toBe('optional');
expect(ServiceRequirementDef.i18n).toBe('optional');
expect(ServiceRequirementDef.ui).toBe('optional');
expect(ServiceRequirementDef.workflow).toBe('optional');
});
Expand Down
Loading