Skip to content

Commit dfa3a4f

Browse files
authored
Merge pull request #728 from objectstack-ai/copilot/implement-i18n-and-realtime-services
2 parents cf636ce + 8deb004 commit dfa3a4f

14 files changed

Lines changed: 1000 additions & 7 deletions

ROADMAP.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ the ecosystem for enterprise workloads.
6161

6262
### What Needs Building
6363

64-
12 of 25 service contracts are specification-only (no runtime implementation).
64+
10 of 25 service contracts are specification-only (no runtime implementation).
6565
These are the backbone of ObjectStack's enterprise capabilities.
6666

6767
### Minimal Implementation Strategy
@@ -96,7 +96,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti
9696
| Exported Schemas | 1,100+ |
9797
| `.describe()` Annotations | 7,111+ |
9898
| Service Contracts | 25 |
99-
| Contracts Implemented | 11 (44%) |
99+
| Contracts Implemented | 13 (52%) |
100100
| Test Files | 199 |
101101
| Tests Passing | 5,468 / 5,468 |
102102
| `@deprecated` Items | 3 |
@@ -280,8 +280,8 @@ The following renames are planned for packages that implement core service contr
280280
| `ISearchService` | **P1** | `@objectstack/service-search` | In-memory search first, then Meilisearch driver |
281281
| `INotificationService` | **P2** | `@objectstack/service-notification` | Email adapter (console logger in dev mode) |
282282

283-
- [ ] `service-i18n` — Implement `II18nService` with file-based locale loading
284-
- [ ] `service-realtime` — Implement `IRealtimeService` with WebSocket + in-memory pub/sub
283+
- [x] `service-i18n` — Implement `II18nService` with file-based locale loading
284+
- [x] `service-realtime` — Implement `IRealtimeService` with WebSocket + in-memory pub/sub
285285
- [ ] `service-search` — Implement `ISearchService` with in-memory search + Meilisearch adapter
286286
- [ ] `service-notification` — Implement `INotificationService` with email adapter
287287

@@ -543,20 +543,20 @@ Final polish and advanced features.
543543
| 11 | Queue Service | `IQueueService` || `@objectstack/service-queue` | Memory + BullMQ skeleton |
544544
| 12 | Job Service | `IJobService` || `@objectstack/service-job` | Interval + cron skeleton |
545545
| 13 | Storage Service | `IStorageService` || `@objectstack/service-storage` | Local FS + S3 skeleton |
546-
| 14 | Realtime Service | `IRealtimeService` | | `@objectstack/service-realtime` (planned) | Spec only |
546+
| 14 | Realtime Service | `IRealtimeService` | | `@objectstack/service-realtime` | In-memory pub/sub |
547547
| 15 | Search Service | `ISearchService` || `@objectstack/service-search` (planned) | Spec only |
548548
| 16 | Notification Service | `INotificationService` || `@objectstack/service-notification` (planned) | Spec only |
549549
| 17 | AI Service | `IAIService` || `@objectstack/service-ai` (planned) | Spec only |
550550
| 18 | Automation Service | `IAutomationService` || `@objectstack/service-automation` (planned) | Spec only |
551551
| 19 | Workflow Service | `IWorkflowService` || `@objectstack/service-workflow` (planned) | Spec only |
552552
| 20 | GraphQL Service | `IGraphQLService` || `@objectstack/service-graphql` (planned) | Spec only |
553-
| 21 | i18n Service | `II18nService` | | `@objectstack/service-i18n` (planned) | Spec only |
553+
| 21 | i18n Service | `II18nService` | | `@objectstack/service-i18n` | File-based locale loading |
554554
| 22 | UI Service | `IUIService` | ⚠️ || **Deprecated** — merged into `IMetadataService` |
555555
| 23 | Schema Driver | `ISchemaDriver` ||| Spec only |
556556
| 24 | Startup Orchestrator | `IStartupOrchestrator` ||| Kernel handles basics |
557557
| 25 | Plugin Validator | `IPluginValidator` ||| Spec only |
558558

559-
**Summary:** 11 fully implemented · 2 partially implemented · 12 specification only
559+
**Summary:** 13 fully implemented · 2 partially implemented · 10 specification only
560560

561561
---
562562

@@ -583,6 +583,8 @@ Final polish and advanced features.
583583
| `@objectstack/service-queue` | 3.0.6 | 8 | ✅ Stable | 7/10 |
584584
| `@objectstack/service-job` | 3.0.6 | 11 | ✅ Stable | 7/10 |
585585
| `@objectstack/service-storage` | 3.0.6 | 8 | ✅ Stable | 7/10 |
586+
| `@objectstack/service-i18n` | 3.0.7 | 20 | ✅ Stable | 7/10 |
587+
| `@objectstack/service-realtime` | 3.0.7 | 14 | ✅ Stable | 7/10 |
586588
| `@objectstack/nextjs` | 3.0.2 || ✅ Stable | 10/10 |
587589
| `@objectstack/nestjs` | 3.0.2 || ✅ Stable | 10/10 |
588590
| `@objectstack/hono` | 3.0.2 || ✅ Stable | 10/10 |
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@objectstack/service-i18n",
3+
"version": "3.0.7",
4+
"license": "Apache-2.0",
5+
"description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.mjs",
13+
"require": "./dist/index.js"
14+
}
15+
},
16+
"scripts": {
17+
"build": "tsup --config ../../../tsup.config.ts",
18+
"test": "vitest run"
19+
},
20+
"dependencies": {
21+
"@objectstack/core": "workspace:*",
22+
"@objectstack/spec": "workspace:*"
23+
},
24+
"devDependencies": {
25+
"typescript": "^5.0.0",
26+
"vitest": "^4.0.18",
27+
"@types/node": "^25.2.3"
28+
}
29+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4+
import { FileI18nAdapter } from './file-i18n-adapter';
5+
import type { II18nService } from '@objectstack/spec/contracts';
6+
import * as fs from 'node:fs';
7+
import * as path from 'node:path';
8+
import * as os from 'node:os';
9+
10+
describe('FileI18nAdapter', () => {
11+
it('should implement II18nService contract', () => {
12+
const i18n: II18nService = new FileI18nAdapter();
13+
expect(typeof i18n.t).toBe('function');
14+
expect(typeof i18n.getTranslations).toBe('function');
15+
expect(typeof i18n.loadTranslations).toBe('function');
16+
expect(typeof i18n.getLocales).toBe('function');
17+
expect(typeof i18n.getDefaultLocale).toBe('function');
18+
expect(typeof i18n.setDefaultLocale).toBe('function');
19+
});
20+
21+
it('should default to "en" locale', () => {
22+
const i18n = new FileI18nAdapter();
23+
expect(i18n.getDefaultLocale()).toBe('en');
24+
});
25+
26+
it('should use custom default locale', () => {
27+
const i18n = new FileI18nAdapter({ defaultLocale: 'zh-CN' });
28+
expect(i18n.getDefaultLocale()).toBe('zh-CN');
29+
});
30+
31+
it('should set and get default locale', () => {
32+
const i18n = new FileI18nAdapter();
33+
i18n.setDefaultLocale('ja');
34+
expect(i18n.getDefaultLocale()).toBe('ja');
35+
});
36+
37+
it('should return empty translations for unknown locale', () => {
38+
const i18n = new FileI18nAdapter();
39+
expect(i18n.getTranslations('fr')).toEqual({});
40+
});
41+
42+
it('should return empty locales when no translations loaded', () => {
43+
const i18n = new FileI18nAdapter();
44+
expect(i18n.getLocales()).toEqual([]);
45+
});
46+
47+
it('should load and retrieve translations', () => {
48+
const i18n = new FileI18nAdapter();
49+
i18n.loadTranslations('en', { greeting: 'Hello' });
50+
i18n.loadTranslations('zh-CN', { greeting: '你好' });
51+
52+
expect(i18n.getLocales()).toContain('en');
53+
expect(i18n.getLocales()).toContain('zh-CN');
54+
expect(i18n.getTranslations('en')).toEqual({ greeting: 'Hello' });
55+
expect(i18n.getTranslations('zh-CN')).toEqual({ greeting: '你好' });
56+
});
57+
58+
it('should merge translations when loading into existing locale', () => {
59+
const i18n = new FileI18nAdapter();
60+
i18n.loadTranslations('en', { greeting: 'Hello' });
61+
i18n.loadTranslations('en', { farewell: 'Goodbye' });
62+
63+
expect(i18n.getTranslations('en')).toEqual({
64+
greeting: 'Hello',
65+
farewell: 'Goodbye',
66+
});
67+
});
68+
69+
it('should translate a simple key', () => {
70+
const i18n = new FileI18nAdapter();
71+
i18n.loadTranslations('en', { greeting: 'Hello' });
72+
73+
expect(i18n.t('greeting', 'en')).toBe('Hello');
74+
});
75+
76+
it('should return key when translation is missing', () => {
77+
const i18n = new FileI18nAdapter();
78+
expect(i18n.t('missing.key', 'en')).toBe('missing.key');
79+
});
80+
81+
it('should resolve nested dot-notation keys', () => {
82+
const i18n = new FileI18nAdapter();
83+
i18n.loadTranslations('en', {
84+
objects: {
85+
account: {
86+
label: 'Account',
87+
},
88+
},
89+
});
90+
91+
expect(i18n.t('objects.account.label', 'en')).toBe('Account');
92+
});
93+
94+
it('should interpolate parameters', () => {
95+
const i18n = new FileI18nAdapter();
96+
i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });
97+
98+
expect(i18n.t('greeting', 'en', { name: 'World' })).toBe('Hello, World!');
99+
});
100+
101+
it('should keep placeholder when parameter is missing', () => {
102+
const i18n = new FileI18nAdapter();
103+
i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });
104+
105+
expect(i18n.t('greeting', 'en', {})).toBe('Hello, {{name}}!');
106+
});
107+
108+
it('should fallback to fallback locale when key not found', () => {
109+
const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
110+
i18n.loadTranslations('en', { greeting: 'Hello' });
111+
112+
expect(i18n.t('greeting', 'zh-CN')).toBe('Hello');
113+
});
114+
115+
it('should not fallback when key exists in requested locale', () => {
116+
const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
117+
i18n.loadTranslations('en', { greeting: 'Hello' });
118+
i18n.loadTranslations('zh-CN', { greeting: '你好' });
119+
120+
expect(i18n.t('greeting', 'zh-CN')).toBe('你好');
121+
});
122+
123+
it('should return key when neither locale nor fallback has translation', () => {
124+
const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
125+
i18n.loadTranslations('en', { greeting: 'Hello' });
126+
127+
expect(i18n.t('missing.key', 'zh-CN')).toBe('missing.key');
128+
});
129+
130+
describe('file-based loading', () => {
131+
let tmpDir: string;
132+
133+
beforeEach(() => {
134+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-test-'));
135+
});
136+
137+
afterEach(() => {
138+
fs.rmSync(tmpDir, { recursive: true, force: true });
139+
});
140+
141+
it('should load translations from JSON files in a directory', () => {
142+
fs.writeFileSync(
143+
path.join(tmpDir, 'en.json'),
144+
JSON.stringify({ greeting: 'Hello', objects: { account: { label: 'Account' } } }),
145+
);
146+
fs.writeFileSync(
147+
path.join(tmpDir, 'zh-CN.json'),
148+
JSON.stringify({ greeting: '你好', objects: { account: { label: '客户' } } }),
149+
);
150+
151+
const i18n = new FileI18nAdapter({ localesDir: tmpDir });
152+
153+
expect(i18n.getLocales()).toContain('en');
154+
expect(i18n.getLocales()).toContain('zh-CN');
155+
expect(i18n.t('greeting', 'en')).toBe('Hello');
156+
expect(i18n.t('greeting', 'zh-CN')).toBe('你好');
157+
expect(i18n.t('objects.account.label', 'en')).toBe('Account');
158+
expect(i18n.t('objects.account.label', 'zh-CN')).toBe('客户');
159+
});
160+
161+
it('should ignore non-JSON files in the directory', () => {
162+
fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
163+
fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'not a translation file');
164+
165+
const i18n = new FileI18nAdapter({ localesDir: tmpDir });
166+
167+
expect(i18n.getLocales()).toEqual(['en']);
168+
});
169+
170+
it('should skip malformed JSON files gracefully', () => {
171+
fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
172+
fs.writeFileSync(path.join(tmpDir, 'bad.json'), '{invalid json');
173+
174+
const i18n = new FileI18nAdapter({ localesDir: tmpDir });
175+
176+
expect(i18n.getLocales()).toEqual(['en']);
177+
expect(i18n.t('greeting', 'en')).toBe('Hello');
178+
});
179+
180+
it('should handle non-existent directory gracefully', () => {
181+
const i18n = new FileI18nAdapter({ localesDir: '/nonexistent/path' });
182+
expect(i18n.getLocales()).toEqual([]);
183+
});
184+
});
185+
});

0 commit comments

Comments
 (0)