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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **i18n service registration & state inconsistency** — Discovery API (`getDiscoveryInfo`) now uses
the same async `resolveService()` fallback chain that request handlers (`handleI18n`) use, ensuring
the reported service status is always consistent with actual runtime availability.
- Discovery `locale` field is now populated from the actual i18n service (`getDefaultLocale`,
`getLocales`) instead of being hardcoded, so clients get accurate locale information.
- Updated all framework adapters (Hono, Express, Fastify, Next.js, NestJS, Nuxt, SvelteKit),
the dispatcher plugin, and the MSW plugin to `await` the now-async `getDiscoveryInfo()`.

### Added
- **AppPlugin i18n auto-loading** — `AppPlugin` now automatically loads translation bundles from
app configs (`translations` array) into the kernel's i18n service during the `start` phase,
coordinating i18n data loading across server/dev/mock environments.
- i18n service registration guide in `content/docs/guides/kernel-services.mdx` documenting
service registration patterns, discovery consistency, and AppPlugin auto-loading behavior.

### Changed
- Updated ROADMAP.md for v3.0 release preparation with full codebase scan results
- Audited all @deprecated items: 14 in spec, 9 in runtime packages (23 total)
Expand Down
62 changes: 62 additions & 0 deletions content/docs/guides/kernel-services.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,68 @@ 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)
**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:

| Environment | Provider | Registration |
|:------------|:---------|:-------------|
| **Production** | `I18nServicePlugin` | File-based `FileI18nAdapter` loads JSON locale files from disk |
| **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
kernel.use(new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' }));

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

#### Discovery & Handler Consistency

The Discovery API (`/api/v1` or `/.well-known/objectstack`) and the i18n route handler (`/api/v1/i18n/*`) both use the **same async resolution chain** to detect i18n availability:

```
getServiceAsync() → getService() → context.getService() → services Map
```

This ensures that `discovery.services.i18n.status` always matches the actual runtime behavior — a service registered via any mechanism (sync Map, async factory, or context) will be reported correctly in both places.

The `locale` field in the discovery response is populated from the actual i18n service:
- `locale.default` — from `i18nService.getDefaultLocale()` (falls back to `'en'`)
- `locale.supported` — from `i18nService.getLocales()` (falls back to `[default]`)

#### AppPlugin Auto-Loading

When an app bundle includes an `i18n` config and `translations` array, `AppPlugin` automatically loads the translation data into the i18n service during the `start` phase:

```typescript
export default defineStack({
manifest: { id: 'com.example.crm', namespace: 'crm' },
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
translations: [CrmTranslations], // TranslationBundle[]
});
```

AppPlugin will:
1. Set the default locale via `i18nService.setDefaultLocale()`
2. Call `i18nService.loadTranslations(locale, data)` for each locale in every bundle
3. Skip gracefully if no i18n service is registered (no errors, just a debug log)

#### REST API Endpoints

| Method | Path | Description |
|:-------|:-----|:------------|
| `GET` | `/api/v1/i18n/locales` | List available locales |
| `GET` | `/api/v1/i18n/translations/:locale` | Get all translations for a locale |
| `GET` | `/api/v1/i18n/labels/:object/:locale` | Get translated field labels for an object |

---

## 12–17. Infrastructure Services ❌ Plugin Required
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters/express/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,8 +71,8 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router {
};

// --- Discovery ---
router.get('/', (_req: Request, res: Response) => {
res.json({ data: dispatcher.getDiscoveryInfo(prefix) });
router.get('/', async (_req: Request, res: Response) => {
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
});

// --- Auth ---
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/fastify/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti

// --- Discovery ---
fastify.get(`${prefix}`, async (_request: FastifyRequest, reply: FastifyReply) => {
return reply.send({ data: dispatcher.getDiscoveryInfo(prefix) });
return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) });
});

// --- .well-known ---
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters/hono/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,8 +72,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
};

// --- Discovery ---
app.get(`${prefix}`, (c) => {
return c.json({ data: dispatcher.getDiscoveryInfo(prefix) });
app.get(`${prefix}`, async (c) => {
return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
});

// --- .well-known ---
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/nestjs/src/__mocks__/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
import { vi } from 'vitest';

export class HttpDispatcher {
getDiscoveryInfo = vi.fn().mockReturnValue({ version: '1.0' });
getDiscoveryInfo = vi.fn().mockResolvedValue({ version: '1.0' });
handleGraphQL = vi.fn().mockResolvedValue({ data: {} });
handleAuth = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: { ok: true } } });
handleMetadata = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: [] } });
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters/nestjs/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -95,8 +95,8 @@ export class ObjectStackController {

// --- Discovery Endpoint ---
@Get()
discovery() {
return { data: this.service.dispatcher.getDiscoveryInfo('/api') };
async discovery() {
return { data: await this.service.dispatcher.getDiscoveryInfo('/api') };
}

@Post('graphql')
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters/nestjs/src/nestjs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,8 +136,8 @@ describe('ObjectStackController', () => {
});

describe('discovery()', () => {
it('returns discovery info from the dispatcher', () => {
const result = controller.discovery();
it('returns discovery info from the dispatcher', async () => {
const result = await controller.discovery();
expect(result).toEqual({ data: { version: '1.0' } });
expect(service.dispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api');
});
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/nextjs/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,7 +60,7 @@ export function createRouteHandler(options: NextAdapterOptions) {

// --- 0. Discovery Endpoint ---
if (segments.length === 0 && method === 'GET') {
return NextResponse.json({ data: dispatcher.getDiscoveryInfo(options.prefix || '/api') });
return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') });
}

try {
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters/nuxt/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,8 +82,8 @@ export function createH3Router(options: NuxtAdapterOptions): Router {
// --- Discovery ---
router.get(
`${prefix}`,
defineEventHandler(() => {
return { data: dispatcher.getDiscoveryInfo(prefix) };
defineEventHandler(async () => {
return { data: await dispatcher.getDiscoveryInfo(prefix) };
}),
);

Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/sveltekit/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,7 +97,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) {

// --- Discovery ---
if (segments.length === 0 && method === 'GET') {
return new Response(JSON.stringify({ data: dispatcher.getDiscoveryInfo(prefix) }), {
return new Response(JSON.stringify({ data: await dispatcher.getDiscoveryInfo(prefix) }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-msw/src/msw-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,10 +204,10 @@ export class MSWPlugin implements Plugin {

// Discovery Endpoint
this.handlers.push(
http.get('*/.well-known/objectstack', () => {
http.get('*/.well-known/objectstack', async () => {
if (this.dispatcher) {
return HttpResponse.json({
data: this.dispatcher.getDiscoveryInfo(baseUrl)
data: await this.dispatcher.getDiscoveryInfo(baseUrl)
});
}
return HttpResponse.json({
Expand Down
133 changes: 133 additions & 0 deletions packages/runtime/src/app-plugin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,4 +99,137 @@ describe('AppPlugin', () => {
expect.any(Object)
);
});

// ═══════════════════════════════════════════════════════════════
// i18n translation auto-loading
// ═══════════════════════════════════════════════════════════════

describe('i18n translation loading', () => {
let mockI18n: any;
let mockQL: any;

beforeEach(() => {
mockI18n = {
loadTranslations: vi.fn(),
setDefaultLocale: vi.fn(),
getLocales: vi.fn().mockReturnValue([]),
getDefaultLocale: vi.fn().mockReturnValue('en'),
};
mockQL = { registry: {} };

vi.mocked(mockContext.getService).mockImplementation((name: string) => {
if (name === 'objectql') return mockQL;
if (name === 'i18n') return mockI18n;
return undefined;
});
});

it('should auto-load translations from bundle into i18n service', async () => {
const bundle = {
id: 'com.test.i18n',
translations: [
{
en: { objects: { task: { label: 'Task' } } },
'zh-CN': { objects: { task: { label: '任务' } } },
},
],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { objects: { task: { label: 'Task' } } });
expect(mockI18n.loadTranslations).toHaveBeenCalledWith('zh-CN', { objects: { task: { label: '任务' } } });
});

it('should set default locale from i18n config', async () => {
const bundle = {
id: 'com.test.locale',
i18n: { defaultLocale: 'zh-CN', supportedLocales: ['en', 'zh-CN'] },
translations: [{ en: { messages: { hello: 'Hello' } } }],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
});

it('should skip translation loading when i18n service is not registered', async () => {
vi.mocked(mockContext.getService).mockImplementation((name: string) => {
if (name === 'objectql') return mockQL;
return undefined; // No i18n service
});

const bundle = {
id: 'com.test.noi18n',
translations: [{ en: { messages: { hello: 'Hello' } } }],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

// Should log debug but not throw
expect(mockContext.logger.debug).toHaveBeenCalledWith(
expect.stringContaining('No i18n service registered'),
expect.any(Object)
);
Comment on lines +156 to +173

CopilotAIMar 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 test simulates “no i18n service” by returning undefined from mockContext.getService, but real PluginContext.getService() throws when a service is missing. To ensure the intended graceful-skip behavior is covered, update the mock to throw (e.g., throw new Error("[Kernel] Service 'i18n' not found")) and assert AppPlugin.start() does not reject.

Copilot uses AI. Check for mistakes.
});

it('should handle bundle with no translations gracefully', async () => {
const bundle = { id: 'com.test.notrans' };
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.loadTranslations).not.toHaveBeenCalled();
});

it('should load translations from nested manifest.translations', async () => {
const bundle = {
manifest: {
id: 'com.test.nested',
translations: [
{ en: { messages: { save: 'Save' } } },
],
},
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } });
});

it('should load multiple translation bundles', async () => {
const bundle = {
id: 'com.test.multi',
translations: [
{ en: { objects: { task: { label: 'Task' } } } },
{ en: { objects: { contact: { label: 'Contact' } } }, 'ja-JP': { objects: { contact: { label: '連絡先' } } } },
],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

expect(mockI18n.loadTranslations).toHaveBeenCalledTimes(3);
});

it('should handle errors in loadTranslations gracefully', async () => {
mockI18n.loadTranslations.mockImplementation((locale: string) => {
if (locale === 'zh-CN') throw new Error('Disk read failed');
});

const bundle = {
id: 'com.test.error',
translations: [
{ en: { messages: { save: 'Save' } }, 'zh-CN': { messages: { save: '保存' } } },
],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

// en should still be loaded despite zh-CN failure
expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } });
expect(mockContext.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to load translations'),
expect.objectContaining({ locale: 'zh-CN' })
);
});
});
});
Loading