Skip to content

Commit 473e77d

Browse files
authored
Merge pull request #903 from objectstack-ai/copilot/fix-i18n-service-consistency
2 parents 9cd67b8 + 979414a commit 473e77d

17 files changed

Lines changed: 574 additions & 45 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Fixed
11+
- **i18n service registration & state inconsistency** — Discovery API (`getDiscoveryInfo`) now uses
12+
the same async `resolveService()` fallback chain that request handlers (`handleI18n`) use, ensuring
13+
the reported service status is always consistent with actual runtime availability.
14+
- Discovery `locale` field is now populated from the actual i18n service (`getDefaultLocale`,
15+
`getLocales`) instead of being hardcoded, so clients get accurate locale information.
16+
- Updated all framework adapters (Hono, Express, Fastify, Next.js, NestJS, Nuxt, SvelteKit),
17+
the dispatcher plugin, and the MSW plugin to `await` the now-async `getDiscoveryInfo()`.
18+
19+
### Added
20+
- **AppPlugin i18n auto-loading**`AppPlugin` now automatically loads translation bundles from
21+
app configs (`translations` array) into the kernel's i18n service during the `start` phase,
22+
coordinating i18n data loading across server/dev/mock environments.
23+
- i18n service registration guide in `content/docs/guides/kernel-services.mdx` documenting
24+
service registration patterns, discovery consistency, and AppPlugin auto-loading behavior.
25+
1026
### Changed
1127
- Updated ROADMAP.md for v3.0 release preparation with full codebase scan results
1228
- Audited all @deprecated items: 14 in spec, 9 in runtime packages (23 total)

content/docs/guides/kernel-services.mdx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,68 @@ 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)
251+
**Route Mount**: `/api/v1/i18n`
252+
**Contract**: `II18nService` in `@objectstack/spec/contracts`
253+
254+
#### Service Registration
255+
256+
The i18n service is registered by a **plugin** during the `init` phase:
257+
258+
| Environment | Provider | Registration |
259+
|:------------|:---------|:-------------|
260+
| **Production** | `I18nServicePlugin` | File-based `FileI18nAdapter` loads JSON locale files from disk |
261+
| **Development** | `DevPlugin` | In-memory Map-backed stub, supports `loadTranslations()` |
262+
| **Mock / MSW** | `MswPlugin` | Routes via `HttpDispatcher.dispatch()` catch-all — requires one of the above |
263+
264+
```typescript
265+
// Production
266+
kernel.use(new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' }));
267+
268+
// Development (automatic — DevPlugin registers i18n stub for all 17 core services)
269+
kernel.use(new DevPlugin());
270+
```
271+
272+
#### Discovery & Handler Consistency
273+
274+
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:
275+
276+
```
277+
getServiceAsync() → getService() → context.getService() → services Map
278+
```
279+
280+
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.
281+
282+
The `locale` field in the discovery response is populated from the actual i18n service:
283+
- `locale.default` — from `i18nService.getDefaultLocale()` (falls back to `'en'`)
284+
- `locale.supported` — from `i18nService.getLocales()` (falls back to `[default]`)
285+
286+
#### AppPlugin Auto-Loading
287+
288+
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:
289+
290+
```typescript
291+
export default defineStack({
292+
manifest: { id: 'com.example.crm', namespace: 'crm' },
293+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
294+
translations: [CrmTranslations], // TranslationBundle[]
295+
});
296+
```
297+
298+
AppPlugin will:
299+
1. Set the default locale via `i18nService.setDefaultLocale()`
300+
2. Call `i18nService.loadTranslations(locale, data)` for each locale in every bundle
301+
3. Skip gracefully if no i18n service is registered (no errors, just a debug log)
302+
303+
#### REST API Endpoints
304+
305+
| Method | Path | Description |
306+
|:-------|:-----|:------------|
307+
| `GET` | `/api/v1/i18n/locales` | List available locales |
308+
| `GET` | `/api/v1/i18n/translations/:locale` | Get all translations for a locale |
309+
| `GET` | `/api/v1/i18n/labels/:object/:locale` | Get translated field labels for an object |
310+
249311
---
250312

251313
## 12–17. Infrastructure Services ❌ Plugin Required

packages/adapters/express/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router {
7171
};
7272

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

7878
// --- Auth ---

packages/adapters/fastify/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti
7070

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

7676
// --- .well-known ---

packages/adapters/hono/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
7272
};
7373

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

7979
// --- .well-known ---

packages/adapters/nestjs/src/__mocks__/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { vi } from 'vitest';
33

44
export class HttpDispatcher {
5-
getDiscoveryInfo = vi.fn().mockReturnValue({ version: '1.0' });
5+
getDiscoveryInfo = vi.fn().mockResolvedValue({ version: '1.0' });
66
handleGraphQL = vi.fn().mockResolvedValue({ data: {} });
77
handleAuth = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: { ok: true } } });
88
handleMetadata = vi.fn().mockResolvedValue({ handled: true, response: { status: 200, body: [] } });

packages/adapters/nestjs/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ export class ObjectStackController {
9595

9696
// --- Discovery Endpoint ---
9797
@Get()
98-
discovery() {
99-
return { data: this.service.dispatcher.getDiscoveryInfo('/api') };
98+
async discovery() {
99+
return { data: await this.service.dispatcher.getDiscoveryInfo('/api') };
100100
}
101101

102102
@Post('graphql')

packages/adapters/nestjs/src/nestjs.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ describe('ObjectStackController', () => {
136136
});
137137

138138
describe('discovery()', () => {
139-
it('returns discovery info from the dispatcher', () => {
140-
const result = controller.discovery();
139+
it('returns discovery info from the dispatcher', async () => {
140+
const result = await controller.discovery();
141141
expect(result).toEqual({ data: { version: '1.0' } });
142142
expect(service.dispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api');
143143
});

packages/adapters/nextjs/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function createRouteHandler(options: NextAdapterOptions) {
6060

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

6666
try {

packages/adapters/nuxt/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ export function createH3Router(options: NuxtAdapterOptions): Router {
8282
// --- Discovery ---
8383
router.get(
8484
`${prefix}`,
85-
defineEventHandler(() => {
86-
return { data: dispatcher.getDiscoveryInfo(prefix) };
85+
defineEventHandler(async () => {
86+
return { data: await dispatcher.getDiscoveryInfo(prefix) };
8787
}),
8888
);
8989

0 commit comments

Comments
 (0)