Repository navigation
feat: promote i18n to core built-in service with in-memory fallback - #908
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- ServiceRequirementDef.i18n: 'optional' → 'core' - Add createMemoryI18n fallback in packages/core/src/fallbacks/ - Add i18n to CORE_FALLBACK_FACTORIES for auto-injection - Fix protocol.ts SERVICE_CONFIG['i18n'].plugin to 'service-i18n' - Add enableI18n config + i18n route registration in RestServer - Update kernel-services.mdx and CHANGELOG.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Promotes i18n to a core kernel service by adding an in-memory fallback implementation and wiring REST route registration/config so baseline i18n endpoints are available without explicit plugin configuration.
Changes:
- Updated the spec’s service criticality to make
i18nacoreservice and documented the new default behavior. - Added an in-memory
II18nServicefallback (createMemoryI18n) and registered it inCORE_FALLBACK_FACTORIES. - Added REST server config (
enableI18n) and route registration for i18n endpoints; corrected discovery/provider naming for i18n.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/system/core-services.zod.ts | Marks i18n as a core service requirement. |
| packages/spec/src/system/core-services.test.ts | Updates expectations for i18n criticality. |
| packages/spec/src/api/rest-server.zod.ts | Adds enableI18n flag to REST API config schema. |
| packages/rest/src/rest-server.ts | Registers i18n routes behind enableI18n. |
| packages/rest/src/rest.test.ts | Adds tests asserting i18n routes are registered / can be skipped. |
| packages/objectql/src/protocol.ts | Fixes i18n provider name used in discovery config. |
| packages/core/src/fallbacks/memory-i18n.ts | Introduces in-memory i18n fallback service implementation. |
| packages/core/src/fallbacks/index.ts | Exports createMemoryI18n and adds it to core fallback factories. |
| packages/core/src/fallbacks/fallbacks.test.ts | Adds unit tests for the i18n fallback and updates factory count. |
| content/docs/guides/kernel-services.mdx | Documents i18n as core with built-in fallback. |
| CHANGELOG.md | Records the feature and related behavior changes. |
| handler: async (req: any, res: any) => { | ||
| try { | ||
| if (this.protocol.getTranslations) { | ||
| const translations = await this.protocol.getTranslations({ locale: req.params.locale }); |
There was a problem hiding this comment.
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.
| 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); |
| 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) |
There was a problem hiding this comment.
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.
| ### 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) |
There was a problem hiding this comment.
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).
| // 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 }); | ||
| } | ||
| }, |
There was a problem hiding this comment.
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.
| // 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, |
| /** | ||
| * 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', |
There was a problem hiding this comment.
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.
i18n was
optional, meaning the kernel silently ignored its absence and/api/v1/i18n/*routes were unavailable unlessplugin-i18nwas explicitly loaded. This broke Studio and deployments that expect baseline i18n capability without extra plugin configuration.Spec & Kernel
ServiceRequirementDef.i18n:'optional'→'core'— kernel now warns and auto-injects fallbackcreateMemoryI18n()inpackages/core/src/fallbacks/memory-i18n.ts— implementsII18nService(translation loading, dot-notation key resolution,{{param}}interpolation, locale management)CORE_FALLBACK_FACTORIESalongside cache/queue/jobREST API
enableI18nflag toRestApiConfigschema (default:true)RestServer.registerRoutes()now mounts/api/v1/i18n/localesand/api/v1/i18n/translations/:localeProtocol fix
SERVICE_CONFIG['i18n'].plugin:'plugin-i18n'→'service-i18n'to match actual package nameOverride behavior
When
I18nServicePluginorDevPluginregistersi18nduringinit, it runs beforevalidateSystemRequirements(), so the fallback is never injected — existing plugin behavior is unchanged:Tests
createMemoryI18n(translation, interpolation, fallback locale, merge)enableI18n: falseskip)core-services.test.tsexpectationsOriginal prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.