diff --git a/ROADMAP.md b/ROADMAP.md index 7ed1cb2193..08ce83197a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,7 +61,7 @@ the ecosystem for enterprise workloads. ### What Needs Building -12 of 25 service contracts are specification-only (no runtime implementation). +10 of 25 service contracts are specification-only (no runtime implementation). These are the backbone of ObjectStack's enterprise capabilities. ### Minimal Implementation Strategy @@ -96,7 +96,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti | Exported Schemas | 1,100+ | | `.describe()` Annotations | 7,111+ | | Service Contracts | 25 | -| Contracts Implemented | 11 (44%) | +| Contracts Implemented | 13 (52%) | | Test Files | 199 | | Tests Passing | 5,468 / 5,468 | | `@deprecated` Items | 3 | @@ -280,8 +280,8 @@ The following renames are planned for packages that implement core service contr | `ISearchService` | **P1** | `@objectstack/service-search` | In-memory search first, then Meilisearch driver | | `INotificationService` | **P2** | `@objectstack/service-notification` | Email adapter (console logger in dev mode) | -- [ ] `service-i18n` — Implement `II18nService` with file-based locale loading -- [ ] `service-realtime` — Implement `IRealtimeService` with WebSocket + in-memory pub/sub +- [x] `service-i18n` — Implement `II18nService` with file-based locale loading +- [x] `service-realtime` — Implement `IRealtimeService` with WebSocket + in-memory pub/sub - [ ] `service-search` — Implement `ISearchService` with in-memory search + Meilisearch adapter - [ ] `service-notification` — Implement `INotificationService` with email adapter @@ -543,20 +543,20 @@ Final polish and advanced features. | 11 | Queue Service | `IQueueService` | ✅ | `@objectstack/service-queue` | Memory + BullMQ skeleton | | 12 | Job Service | `IJobService` | ✅ | `@objectstack/service-job` | Interval + cron skeleton | | 13 | Storage Service | `IStorageService` | ✅ | `@objectstack/service-storage` | Local FS + S3 skeleton | -| 14 | Realtime Service | `IRealtimeService` | ❌ | `@objectstack/service-realtime` (planned) | Spec only | +| 14 | Realtime Service | `IRealtimeService` | ✅ | `@objectstack/service-realtime` | In-memory pub/sub | | 15 | Search Service | `ISearchService` | ❌ | `@objectstack/service-search` (planned) | Spec only | | 16 | Notification Service | `INotificationService` | ❌ | `@objectstack/service-notification` (planned) | Spec only | | 17 | AI Service | `IAIService` | ❌ | `@objectstack/service-ai` (planned) | Spec only | | 18 | Automation Service | `IAutomationService` | ❌ | `@objectstack/service-automation` (planned) | Spec only | | 19 | Workflow Service | `IWorkflowService` | ❌ | `@objectstack/service-workflow` (planned) | Spec only | | 20 | GraphQL Service | `IGraphQLService` | ❌ | `@objectstack/service-graphql` (planned) | Spec only | -| 21 | i18n Service | `II18nService` | ❌ | `@objectstack/service-i18n` (planned) | Spec only | +| 21 | i18n Service | `II18nService` | ✅ | `@objectstack/service-i18n` | File-based locale loading | | 22 | UI Service | `IUIService` | ⚠️ | — | **Deprecated** — merged into `IMetadataService` | | 23 | Schema Driver | `ISchemaDriver` | ❌ | — | Spec only | | 24 | Startup Orchestrator | `IStartupOrchestrator` | ❌ | — | Kernel handles basics | | 25 | Plugin Validator | `IPluginValidator` | ❌ | — | Spec only | -**Summary:** 11 fully implemented · 2 partially implemented · 12 specification only +**Summary:** 13 fully implemented · 2 partially implemented · 10 specification only --- @@ -583,6 +583,8 @@ Final polish and advanced features. | `@objectstack/service-queue` | 3.0.6 | 8 | ✅ Stable | 7/10 | | `@objectstack/service-job` | 3.0.6 | 11 | ✅ Stable | 7/10 | | `@objectstack/service-storage` | 3.0.6 | 8 | ✅ Stable | 7/10 | +| `@objectstack/service-i18n` | 3.0.7 | 20 | ✅ Stable | 7/10 | +| `@objectstack/service-realtime` | 3.0.7 | 14 | ✅ Stable | 7/10 | | `@objectstack/nextjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 | | `@objectstack/nestjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 | | `@objectstack/hono` | 3.0.2 | ✅ | ✅ Stable | 10/10 | diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json new file mode 100644 index 0000000000..4d678aaa5a --- /dev/null +++ b/packages/services/service-i18n/package.json @@ -0,0 +1,29 @@ +{ + "name": "@objectstack/service-i18n", + "version": "3.0.7", + "license": "Apache-2.0", + "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^4.0.18", + "@types/node": "^25.2.3" + } +} diff --git a/packages/services/service-i18n/src/file-i18n-adapter.test.ts b/packages/services/service-i18n/src/file-i18n-adapter.test.ts new file mode 100644 index 0000000000..d315648bff --- /dev/null +++ b/packages/services/service-i18n/src/file-i18n-adapter.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { FileI18nAdapter } from './file-i18n-adapter'; +import type { II18nService } from '@objectstack/spec/contracts'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('FileI18nAdapter', () => { + it('should implement II18nService contract', () => { + const i18n: II18nService = new FileI18nAdapter(); + expect(typeof i18n.t).toBe('function'); + expect(typeof i18n.getTranslations).toBe('function'); + expect(typeof i18n.loadTranslations).toBe('function'); + expect(typeof i18n.getLocales).toBe('function'); + expect(typeof i18n.getDefaultLocale).toBe('function'); + expect(typeof i18n.setDefaultLocale).toBe('function'); + }); + + it('should default to "en" locale', () => { + const i18n = new FileI18nAdapter(); + expect(i18n.getDefaultLocale()).toBe('en'); + }); + + it('should use custom default locale', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'zh-CN' }); + expect(i18n.getDefaultLocale()).toBe('zh-CN'); + }); + + it('should set and get default locale', () => { + const i18n = new FileI18nAdapter(); + i18n.setDefaultLocale('ja'); + expect(i18n.getDefaultLocale()).toBe('ja'); + }); + + it('should return empty translations for unknown locale', () => { + const i18n = new FileI18nAdapter(); + expect(i18n.getTranslations('fr')).toEqual({}); + }); + + it('should return empty locales when no translations loaded', () => { + const i18n = new FileI18nAdapter(); + expect(i18n.getLocales()).toEqual([]); + }); + + it('should load and retrieve translations', () => { + const i18n = new FileI18nAdapter(); + i18n.loadTranslations('en', { greeting: 'Hello' }); + i18n.loadTranslations('zh-CN', { greeting: '你好' }); + + expect(i18n.getLocales()).toContain('en'); + expect(i18n.getLocales()).toContain('zh-CN'); + expect(i18n.getTranslations('en')).toEqual({ greeting: 'Hello' }); + expect(i18n.getTranslations('zh-CN')).toEqual({ greeting: '你好' }); + }); + + it('should merge translations when loading into existing locale', () => { + const i18n = new FileI18nAdapter(); + i18n.loadTranslations('en', { greeting: 'Hello' }); + i18n.loadTranslations('en', { farewell: 'Goodbye' }); + + expect(i18n.getTranslations('en')).toEqual({ + greeting: 'Hello', + farewell: 'Goodbye', + }); + }); + + it('should translate a simple key', () => { + const i18n = new FileI18nAdapter(); + i18n.loadTranslations('en', { greeting: 'Hello' }); + + expect(i18n.t('greeting', 'en')).toBe('Hello'); + }); + + it('should return key when translation is missing', () => { + const i18n = new FileI18nAdapter(); + expect(i18n.t('missing.key', 'en')).toBe('missing.key'); + }); + + it('should resolve nested dot-notation keys', () => { + const i18n = new FileI18nAdapter(); + i18n.loadTranslations('en', { + objects: { + account: { + label: 'Account', + }, + }, + }); + + expect(i18n.t('objects.account.label', 'en')).toBe('Account'); + }); + + it('should interpolate parameters', () => { + const i18n = new FileI18nAdapter(); + i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' }); + + expect(i18n.t('greeting', 'en', { name: 'World' })).toBe('Hello, World!'); + }); + + it('should keep placeholder when parameter is missing', () => { + const i18n = new FileI18nAdapter(); + i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' }); + + expect(i18n.t('greeting', 'en', {})).toBe('Hello, {{name}}!'); + }); + + it('should fallback to fallback locale when key not found', () => { + const i18n = new FileI18nAdapter({ fallbackLocale: 'en' }); + i18n.loadTranslations('en', { greeting: 'Hello' }); + + expect(i18n.t('greeting', 'zh-CN')).toBe('Hello'); + }); + + it('should not fallback when key exists in requested locale', () => { + const i18n = new FileI18nAdapter({ fallbackLocale: 'en' }); + i18n.loadTranslations('en', { greeting: 'Hello' }); + i18n.loadTranslations('zh-CN', { greeting: '你好' }); + + expect(i18n.t('greeting', 'zh-CN')).toBe('你好'); + }); + + it('should return key when neither locale nor fallback has translation', () => { + const i18n = new FileI18nAdapter({ fallbackLocale: 'en' }); + i18n.loadTranslations('en', { greeting: 'Hello' }); + + expect(i18n.t('missing.key', 'zh-CN')).toBe('missing.key'); + }); + + describe('file-based loading', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should load translations from JSON files in a directory', () => { + fs.writeFileSync( + path.join(tmpDir, 'en.json'), + JSON.stringify({ greeting: 'Hello', objects: { account: { label: 'Account' } } }), + ); + fs.writeFileSync( + path.join(tmpDir, 'zh-CN.json'), + JSON.stringify({ greeting: '你好', objects: { account: { label: '客户' } } }), + ); + + const i18n = new FileI18nAdapter({ localesDir: tmpDir }); + + expect(i18n.getLocales()).toContain('en'); + expect(i18n.getLocales()).toContain('zh-CN'); + expect(i18n.t('greeting', 'en')).toBe('Hello'); + expect(i18n.t('greeting', 'zh-CN')).toBe('你好'); + expect(i18n.t('objects.account.label', 'en')).toBe('Account'); + expect(i18n.t('objects.account.label', 'zh-CN')).toBe('客户'); + }); + + it('should ignore non-JSON files in the directory', () => { + fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' })); + fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'not a translation file'); + + const i18n = new FileI18nAdapter({ localesDir: tmpDir }); + + expect(i18n.getLocales()).toEqual(['en']); + }); + + it('should skip malformed JSON files gracefully', () => { + fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' })); + fs.writeFileSync(path.join(tmpDir, 'bad.json'), '{invalid json'); + + const i18n = new FileI18nAdapter({ localesDir: tmpDir }); + + expect(i18n.getLocales()).toEqual(['en']); + expect(i18n.t('greeting', 'en')).toBe('Hello'); + }); + + it('should handle non-existent directory gracefully', () => { + const i18n = new FileI18nAdapter({ localesDir: '/nonexistent/path' }); + expect(i18n.getLocales()).toEqual([]); + }); + }); +}); diff --git a/packages/services/service-i18n/src/file-i18n-adapter.ts b/packages/services/service-i18n/src/file-i18n-adapter.ts new file mode 100644 index 0000000000..dacaa01b75 --- /dev/null +++ b/packages/services/service-i18n/src/file-i18n-adapter.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { II18nService } from '@objectstack/spec/contracts'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Configuration options for FileI18nAdapter. + */ +export interface FileI18nAdapterOptions { + /** Default locale (e.g. 'en') */ + defaultLocale?: string; + /** Directory containing locale files (JSON). Each file should be named `{locale}.json`. */ + localesDir?: string; + /** Fallback locale when a key is not found in the requested locale */ + fallbackLocale?: string; +} + +/** + * Resolve a nested key in a translations object using dot notation. + * + * @param data - Translation data object + * @param key - Dot-separated key (e.g. 'objects.account.label') + * @returns The resolved string value, or undefined if not found + */ +function resolveKey(data: Record, 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)[part]; + } + return typeof current === 'string' ? current : undefined; +} + +/** + * Interpolate parameters into a translated string. + * Replaces `{{paramName}}` with the corresponding value from params. + * + * @param template - Template string with `{{key}}` placeholders + * @param params - Parameter map + * @returns Interpolated string + */ +function interpolate(template: string, params: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { + return params[key] != null ? String(params[key]) : `{{${key}}}`; + }); +} + +/** + * File-based I18n adapter implementing II18nService. + * + * Loads JSON translation files from a directory on disk. + * Each file should be named `{locale}.json` and contain a flat or nested + * key-value map of translations. + * + * Supports: + * - Dot-notation key resolution (e.g. 'objects.account.label') + * - Parameter interpolation via `{{paramName}}` syntax + * - Fallback locale for missing translations + * - Runtime translation loading via loadTranslations() + * + * Suitable for server-side rendering, CLI tools, and development environments. + * + * @example + * ```ts + * const i18n = new FileI18nAdapter({ + * defaultLocale: 'en', + * localesDir: './i18n', + * fallbackLocale: 'en', + * }); + * + * i18n.t('objects.account.label', 'zh-CN'); // '客户' + * i18n.t('greeting', 'en', { name: 'World' }); // 'Hello, World!' + * ``` + */ +export class FileI18nAdapter implements II18nService { + private readonly translations = new Map>(); + private defaultLocale: string; + private readonly fallbackLocale: string | undefined; + + constructor(options: FileI18nAdapterOptions = {}) { + this.defaultLocale = options.defaultLocale ?? 'en'; + this.fallbackLocale = options.fallbackLocale; + + if (options.localesDir) { + this.loadFromDirectory(options.localesDir); + } + } + + t(key: string, locale: string, params?: Record): string { + // Try requested locale + let value = this.resolveFromLocale(key, locale); + + // Try fallback locale + if (value === undefined && this.fallbackLocale && this.fallbackLocale !== locale) { + value = this.resolveFromLocale(key, this.fallbackLocale); + } + + // Return key if not found + if (value === undefined) return key; + + // Interpolate parameters + if (params && Object.keys(params).length > 0) { + return interpolate(value, params); + } + + return value; + } + + getTranslations(locale: string): Record { + return this.translations.get(locale) ?? {}; + } + + loadTranslations(locale: string, translations: Record): void { + const existing = this.translations.get(locale); + if (existing) { + // Merge into existing translations + this.translations.set(locale, { ...existing, ...translations }); + } else { + this.translations.set(locale, { ...translations }); + } + } + + getLocales(): string[] { + return Array.from(this.translations.keys()); + } + + getDefaultLocale(): string { + return this.defaultLocale; + } + + setDefaultLocale(locale: string): void { + this.defaultLocale = locale; + } + + /** + * Load all JSON translation files from a directory. + * Each file should be named `{locale}.json`. + */ + private loadFromDirectory(dir: string): void { + if (!fs.existsSync(dir)) return; + + const files = fs.readdirSync(dir); + for (const file of files) { + if (!file.endsWith('.json')) continue; + const locale = file.replace(/\.json$/, ''); + const filePath = path.join(dir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content) as Record; + this.translations.set(locale, data); + } catch { + // Skip files that can't be parsed + } + } + } + + private resolveFromLocale(key: string, locale: string): string | undefined { + const data = this.translations.get(locale); + if (!data) return undefined; + return resolveKey(data, key); + } +} diff --git a/packages/services/service-i18n/src/i18n-service-plugin.ts b/packages/services/service-i18n/src/i18n-service-plugin.ts new file mode 100644 index 0000000000..bd975f6cee --- /dev/null +++ b/packages/services/service-i18n/src/i18n-service-plugin.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { FileI18nAdapter } from './file-i18n-adapter.js'; +import type { FileI18nAdapterOptions } from './file-i18n-adapter.js'; + +/** + * Configuration options for the I18nServicePlugin. + */ +export interface I18nServicePluginOptions { + /** Default locale (default: 'en') */ + defaultLocale?: string; + /** Directory containing locale JSON files */ + localesDir?: string; + /** Fallback locale for missing translations */ + fallbackLocale?: string; +} + +/** + * I18nServicePlugin — Production II18nService implementation. + * + * Registers an i18n service with the kernel during the init phase. + * Uses file-based locale loading with JSON files. + * + * @example + * ```ts + * import { ObjectKernel } from '@objectstack/core'; + * import { I18nServicePlugin } from '@objectstack/service-i18n'; + * + * const kernel = new ObjectKernel(); + * kernel.use(new I18nServicePlugin({ + * defaultLocale: 'en', + * localesDir: './i18n', + * fallbackLocale: 'en', + * })); + * await kernel.bootstrap(); + * + * const i18n = kernel.getService('i18n'); + * i18n.t('objects.account.label', 'en'); // 'Account' + * ``` + */ +export class I18nServicePlugin implements Plugin { + name = 'com.objectstack.service.i18n'; + version = '1.0.0'; + type = 'standard'; + + private readonly options: I18nServicePluginOptions; + + constructor(options: I18nServicePluginOptions = {}) { + this.options = options; + } + + async init(ctx: PluginContext): Promise { + const adapterOptions: FileI18nAdapterOptions = { + defaultLocale: this.options.defaultLocale, + localesDir: this.options.localesDir, + fallbackLocale: this.options.fallbackLocale, + }; + + const i18n = new FileI18nAdapter(adapterOptions); + ctx.registerService('i18n', i18n); + ctx.logger.info( + `I18nServicePlugin: registered file-based i18n adapter (default: ${i18n.getDefaultLocale()})`, + ); + } +} diff --git a/packages/services/service-i18n/src/index.ts b/packages/services/service-i18n/src/index.ts new file mode 100644 index 0000000000..b805701da9 --- /dev/null +++ b/packages/services/service-i18n/src/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { I18nServicePlugin } from './i18n-service-plugin.js'; +export type { I18nServicePluginOptions } from './i18n-service-plugin.js'; +export { FileI18nAdapter } from './file-i18n-adapter.js'; +export type { FileI18nAdapterOptions } from './file-i18n-adapter.js'; diff --git a/packages/services/service-i18n/tsconfig.json b/packages/services/service-i18n/tsconfig.json new file mode 100644 index 0000000000..583257f97a --- /dev/null +++ b/packages/services/service-i18n/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json new file mode 100644 index 0000000000..89b76376f6 --- /dev/null +++ b/packages/services/service-realtime/package.json @@ -0,0 +1,29 @@ +{ + "name": "@objectstack/service-realtime", + "version": "3.0.7", + "license": "Apache-2.0", + "description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^4.0.18", + "@types/node": "^25.2.3" + } +} diff --git a/packages/services/service-realtime/src/in-memory-realtime-adapter.test.ts b/packages/services/service-realtime/src/in-memory-realtime-adapter.test.ts new file mode 100644 index 0000000000..0881b8193e --- /dev/null +++ b/packages/services/service-realtime/src/in-memory-realtime-adapter.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter'; +import type { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; + +describe('InMemoryRealtimeAdapter', () => { + it('should implement IRealtimeService contract', () => { + const realtime: IRealtimeService = new InMemoryRealtimeAdapter(); + expect(typeof realtime.publish).toBe('function'); + expect(typeof realtime.subscribe).toBe('function'); + expect(typeof realtime.unsubscribe).toBe('function'); + }); + + it('should start with zero subscriptions', () => { + const realtime = new InMemoryRealtimeAdapter(); + expect(realtime.getSubscriptionCount()).toBe(0); + expect(realtime.getChannels()).toEqual([]); + }); + + it('should subscribe and receive published events', async () => { + const realtime = new InMemoryRealtimeAdapter(); + const received: RealtimeEventPayload[] = []; + + await realtime.subscribe('records', (event) => { + received.push(event); + }); + + await realtime.publish({ + type: 'record.created', + object: 'account', + payload: { id: 'acc-1', name: 'Acme' }, + timestamp: new Date().toISOString(), + }); + + expect(received).toHaveLength(1); + expect(received[0].type).toBe('record.created'); + expect(received[0].object).toBe('account'); + }); + + it('should deliver events to multiple subscribers', async () => { + const realtime = new InMemoryRealtimeAdapter(); + const received1: RealtimeEventPayload[] = []; + const received2: RealtimeEventPayload[] = []; + + await realtime.subscribe('records', (event) => { received1.push(event); }); + await realtime.subscribe('records', (event) => { received2.push(event); }); + + await realtime.publish({ + type: 'record.created', + object: 'account', + payload: { id: 'acc-1' }, + timestamp: new Date().toISOString(), + }); + + expect(received1).toHaveLength(1); + expect(received2).toHaveLength(1); + }); + + it('should unsubscribe and stop receiving events', async () => { + const realtime = new InMemoryRealtimeAdapter(); + const received: RealtimeEventPayload[] = []; + + const subId = await realtime.subscribe('records', (event) => { + received.push(event); + }); + + await realtime.publish({ + type: 'record.created', + payload: { id: '1' }, + timestamp: new Date().toISOString(), + }); + expect(received).toHaveLength(1); + + await realtime.unsubscribe(subId); + + await realtime.publish({ + type: 'record.updated', + payload: { id: '2' }, + timestamp: new Date().toISOString(), + }); + expect(received).toHaveLength(1); // No new events + }); + + it('should handle unsubscribing an unknown subscription gracefully', async () => { + const realtime = new InMemoryRealtimeAdapter(); + await expect(realtime.unsubscribe('nonexistent')).resolves.toBeUndefined(); + }); + + it('should filter events by object name', async () => { + const realtime = new InMemoryRealtimeAdapter(); + const received: RealtimeEventPayload[] = []; + + await realtime.subscribe('records', (event) => { + received.push(event); + }, { object: 'account' }); + + await realtime.publish({ + type: 'record.created', + object: 'account', + payload: { id: '1' }, + timestamp: new Date().toISOString(), + }); + + await realtime.publish({ + type: 'record.created', + object: 'contact', + payload: { id: '2' }, + timestamp: new Date().toISOString(), + }); + + expect(received).toHaveLength(1); + expect(received[0].object).toBe('account'); + }); + + it('should filter events by event type', async () => { + const realtime = new InMemoryRealtimeAdapter(); + const received: RealtimeEventPayload[] = []; + + await realtime.subscribe('records', (event) => { + received.push(event); + }, { eventTypes: ['record.created'] }); + + await realtime.publish({ + type: 'record.created', + payload: { id: '1' }, + timestamp: new Date().toISOString(), + }); + + await realtime.publish({ + type: 'record.updated', + payload: { id: '2' }, + timestamp: new Date().toISOString(), + }); + + expect(received).toHaveLength(1); + expect(received[0].type).toBe('record.created'); + }); + + it('should filter by both object and event type', async () => { + const realtime = new InMemoryRealtimeAdapter(); + const received: RealtimeEventPayload[] = []; + + await realtime.subscribe('records', (event) => { + received.push(event); + }, { object: 'account', eventTypes: ['record.created'] }); + + // Match: correct object + correct type + await realtime.publish({ + type: 'record.created', + object: 'account', + payload: { id: '1' }, + timestamp: new Date().toISOString(), + }); + + // No match: wrong object + await realtime.publish({ + type: 'record.created', + object: 'contact', + payload: { id: '2' }, + timestamp: new Date().toISOString(), + }); + + // No match: wrong type + await realtime.publish({ + type: 'record.updated', + object: 'account', + payload: { id: '3' }, + timestamp: new Date().toISOString(), + }); + + expect(received).toHaveLength(1); + expect(received[0].payload).toEqual({ id: '1' }); + }); + + it('should track subscription count and channels', async () => { + const realtime = new InMemoryRealtimeAdapter(); + + const sub1 = await realtime.subscribe('records', () => {}); + await realtime.subscribe('events', () => {}); + + expect(realtime.getSubscriptionCount()).toBe(2); + expect(realtime.getChannels().sort()).toEqual(['events', 'records']); + + await realtime.unsubscribe(sub1); + expect(realtime.getSubscriptionCount()).toBe(1); + expect(realtime.getChannels()).toEqual(['events']); + }); + + it('should enforce maxSubscriptions limit', async () => { + const realtime = new InMemoryRealtimeAdapter({ maxSubscriptions: 2 }); + + await realtime.subscribe('ch1', () => {}); + await realtime.subscribe('ch2', () => {}); + + await expect(realtime.subscribe('ch3', () => {})).rejects.toThrow( + /Maximum subscription limit reached/, + ); + }); + + it('should not break publish loop on handler error', async () => { + const realtime = new InMemoryRealtimeAdapter(); + const received: RealtimeEventPayload[] = []; + + await realtime.subscribe('records', () => { + throw new Error('handler error'); + }); + await realtime.subscribe('records', (event) => { + received.push(event); + }); + + await realtime.publish({ + type: 'record.created', + payload: { id: '1' }, + timestamp: new Date().toISOString(), + }); + + // Second handler should still receive the event + expect(received).toHaveLength(1); + }); + + it('should return unique subscription IDs', async () => { + const realtime = new InMemoryRealtimeAdapter(); + + const id1 = await realtime.subscribe('ch1', () => {}); + const id2 = await realtime.subscribe('ch1', () => {}); + const id3 = await realtime.subscribe('ch2', () => {}); + + expect(id1).not.toBe(id2); + expect(id2).not.toBe(id3); + }); + + it('should clean up channel index on last subscription removal', async () => { + const realtime = new InMemoryRealtimeAdapter(); + + const sub1 = await realtime.subscribe('records', () => {}); + expect(realtime.getChannels()).toContain('records'); + + await realtime.unsubscribe(sub1); + expect(realtime.getChannels()).not.toContain('records'); + }); +}); diff --git a/packages/services/service-realtime/src/in-memory-realtime-adapter.ts b/packages/services/service-realtime/src/in-memory-realtime-adapter.ts new file mode 100644 index 0000000000..5242a71306 --- /dev/null +++ b/packages/services/service-realtime/src/in-memory-realtime-adapter.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { + IRealtimeService, + RealtimeEventPayload, + RealtimeEventHandler, + RealtimeSubscriptionOptions, +} from '@objectstack/spec/contracts'; + +/** + * Internal subscription entry. + */ +interface Subscription { + id: string; + channel: string; + handler: RealtimeEventHandler; + options?: RealtimeSubscriptionOptions; +} + +/** + * Configuration options for InMemoryRealtimeAdapter. + */ +export interface InMemoryRealtimeAdapterOptions { + /** Maximum number of subscriptions allowed (0 = unlimited) */ + maxSubscriptions?: number; +} + +/** + * In-memory pub/sub adapter implementing IRealtimeService. + * + * Uses a Map-backed subscription store with channel-based routing. + * Supports event type and object filtering via subscription options. + * + * Suitable for single-process environments, development, and testing. + * For production multi-instance deployments, use a Redis-backed adapter. + * + * @example + * ```ts + * const realtime = new InMemoryRealtimeAdapter(); + * + * const subId = await realtime.subscribe('records', (event) => { + * console.log('Received:', event.type, event.payload); + * }, { object: 'account', eventTypes: ['record.created'] }); + * + * await realtime.publish({ + * type: 'record.created', + * object: 'account', + * payload: { id: 'acc-1', name: 'Acme' }, + * timestamp: new Date().toISOString(), + * }); + * + * await realtime.unsubscribe(subId); + * ``` + */ +export class InMemoryRealtimeAdapter implements IRealtimeService { + private readonly subscriptions = new Map(); + private readonly channelIndex = new Map>(); + private counter = 0; + private readonly maxSubscriptions: number; + + constructor(options: InMemoryRealtimeAdapterOptions = {}) { + this.maxSubscriptions = options.maxSubscriptions ?? 0; + } + + async publish(event: RealtimeEventPayload): Promise { + // Deliver to all channel subscriptions that match filters + for (const sub of this.subscriptions.values()) { + if (this.matchesSubscription(event, sub)) { + try { + await sub.handler(event); + } catch { + // Swallow handler errors to avoid breaking the publish loop + } + } + } + } + + async subscribe( + channel: string, + handler: RealtimeEventHandler, + options?: RealtimeSubscriptionOptions, + ): Promise { + if (this.maxSubscriptions > 0 && this.subscriptions.size >= this.maxSubscriptions) { + throw new Error( + `Maximum subscription limit reached (${this.maxSubscriptions}). ` + + 'Unsubscribe from existing channels before adding new subscriptions.', + ); + } + + const id = `sub-${++this.counter}`; + const sub: Subscription = { id, channel, handler, options }; + this.subscriptions.set(id, sub); + + // Maintain channel index for efficient lookups + if (!this.channelIndex.has(channel)) { + this.channelIndex.set(channel, new Set()); + } + this.channelIndex.get(channel)!.add(id); + + return id; + } + + async unsubscribe(subscriptionId: string): Promise { + const sub = this.subscriptions.get(subscriptionId); + if (!sub) return; + + this.subscriptions.delete(subscriptionId); + + // Clean up channel index + const channelSubs = this.channelIndex.get(sub.channel); + if (channelSubs) { + channelSubs.delete(subscriptionId); + if (channelSubs.size === 0) { + this.channelIndex.delete(sub.channel); + } + } + } + + /** + * Get the number of active subscriptions. + */ + getSubscriptionCount(): number { + return this.subscriptions.size; + } + + /** + * Get all active channel names. + */ + getChannels(): string[] { + return Array.from(this.channelIndex.keys()); + } + + /** + * Check if an event matches a subscription's filters. + */ + private matchesSubscription(event: RealtimeEventPayload, sub: Subscription): boolean { + const opts = sub.options; + if (!opts) return true; + + // Filter by object name + if (opts.object && event.object !== opts.object) { + return false; + } + + // Filter by event types + if (opts.eventTypes && opts.eventTypes.length > 0) { + if (!opts.eventTypes.includes(event.type)) { + return false; + } + } + + return true; + } +} diff --git a/packages/services/service-realtime/src/index.ts b/packages/services/service-realtime/src/index.ts new file mode 100644 index 0000000000..2b6486d616 --- /dev/null +++ b/packages/services/service-realtime/src/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { RealtimeServicePlugin } from './realtime-service-plugin.js'; +export type { RealtimeServicePluginOptions } from './realtime-service-plugin.js'; +export { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js'; +export type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js'; diff --git a/packages/services/service-realtime/src/realtime-service-plugin.ts b/packages/services/service-realtime/src/realtime-service-plugin.ts new file mode 100644 index 0000000000..cd52813265 --- /dev/null +++ b/packages/services/service-realtime/src/realtime-service-plugin.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { InMemoryRealtimeAdapter } from './in-memory-realtime-adapter.js'; +import type { InMemoryRealtimeAdapterOptions } from './in-memory-realtime-adapter.js'; + +/** + * Configuration options for the RealtimeServicePlugin. + */ +export interface RealtimeServicePluginOptions { + /** Realtime adapter type (default: 'memory') */ + adapter?: 'memory'; + /** Options for the in-memory adapter */ + memory?: InMemoryRealtimeAdapterOptions; +} + +/** + * RealtimeServicePlugin — Production IRealtimeService implementation. + * + * Registers a realtime pub/sub service with the kernel during the init phase. + * Currently supports in-memory pub/sub for single-process environments. + * + * @example + * ```ts + * import { ObjectKernel } from '@objectstack/core'; + * import { RealtimeServicePlugin } from '@objectstack/service-realtime'; + * + * const kernel = new ObjectKernel(); + * kernel.use(new RealtimeServicePlugin()); + * await kernel.bootstrap(); + * + * const realtime = kernel.getService('realtime'); + * await realtime.subscribe('records', (event) => { + * console.log(event.type, event.payload); + * }); + * ``` + */ +export class RealtimeServicePlugin implements Plugin { + name = 'com.objectstack.service.realtime'; + version = '1.0.0'; + type = 'standard'; + + private readonly options: RealtimeServicePluginOptions; + + constructor(options: RealtimeServicePluginOptions = {}) { + this.options = { adapter: 'memory', ...options }; + } + + async init(ctx: PluginContext): Promise { + const realtime = new InMemoryRealtimeAdapter(this.options.memory); + ctx.registerService('realtime', realtime); + ctx.logger.info('RealtimeServicePlugin: registered in-memory realtime adapter'); + } +} diff --git a/packages/services/service-realtime/tsconfig.json b/packages/services/service-realtime/tsconfig.json new file mode 100644 index 0000000000..583257f97a --- /dev/null +++ b/packages/services/service-realtime/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a56a678b1..ebe8b8219c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -899,6 +899,25 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.3)(happy-dom@20.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0) + packages/services/service-i18n: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.3)(happy-dom@20.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0) + packages/services/service-job: dependencies: '@objectstack/core': @@ -937,6 +956,25 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.3)(happy-dom@20.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0) + packages/services/service-realtime: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.3)(happy-dom@20.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0) + packages/services/service-storage: dependencies: '@objectstack/core':