Skip to content

feat: promote i18n to core built-in service with in-memory fallback - #908

Merged
hotlong merged 3 commits into
mainfrom
copilot/add-i18n-core-service-integration
Mar 11, 2026
Merged

hotlong merged 3 commits into
mainfrom
copilot/add-i18n-core-service-integration

Conversation

Copilot AI commented Mar 11, 2026 •

Copy link
Copy Markdown
Contributor

i18n was optional, meaning the kernel silently ignored its absence and /api/v1/i18n/* routes were unavailable unless plugin-i18n was 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 fallback
  • New createMemoryI18n() in packages/core/src/fallbacks/memory-i18n.ts — implements II18nService (translation loading, dot-notation key resolution, {{param}} interpolation, locale management)
  • Added to CORE_FALLBACK_FACTORIES alongside cache/queue/job

REST API

  • Added enableI18n flag to RestApiConfig schema (default: true)
  • RestServer.registerRoutes() now mounts /api/v1/i18n/locales and /api/v1/i18n/translations/:locale

Protocol fix

  • SERVICE_CONFIG['i18n'].plugin: 'plugin-i18n' → 'service-i18n' to match actual package name

Override behavior

When I18nServicePlugin or DevPlugin registers i18n during init, it runs before validateSystemRequirements(), so the fallback is never injected — existing plugin behavior is unchanged:

// No plugin needed — kernel auto-injects in-memory fallback
const kernel = new ObjectKernel();
await kernel.bootstrap(); // i18n service available

// Production override — FileI18nAdapter replaces fallback
kernel.use(new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' }));

Tests

  • 10 new test cases for createMemoryI18n (translation, interpolation, fallback locale, merge)
  • 2 new REST tests (i18n route registration, enableI18n: false skip)
  • Updated core-services.test.ts expectations
Original prompt

This section details on the original issue you should resolve

<issue_title>i18n 默认内置为核心服务,自动注册/覆盖机制和多环境兼容</issue_title>
<issue_description>### 背景与目标
目前 i18n(国际化)作为低代码平台的核心服务,只在 DevPlugin 或专业插件注册时才可用。这会导致内核在启动时,如果未加载 plugin-i18n,将无法通过 /api/v1/i18n/* 路由获取基本的多语言能力。为保证平台长期可用性、Studio 体验和部署友好,i18n 服务需要成为内置基础服务,采用与 metadata 一致的自动注册机制。

主要开发内容

  1. 内置 i18n 服务注册(ObjectQLPlugin.init)
    • 检查当前上下文是否已注册 'i18n' 服务
    • 如未注册,自动注册 FileI18nAdapter 作为默认实现(defaultLocale: 'en', fallbackLocale: 'en',不指定 localesDir,纯 in-memory)
    • 日志记录内置初始化及覆盖行为
  2. 服务重要性调整
    • ServiceRequirementDef.i18n 从 'optional' 升级为 'core'
    • kernel 启动时缺失 i18n 时给出 warn
  3. plugin-i18n 行为标准化
    • 正确覆盖内置服务
    • 文档说明如何定制 localesDir、defaultLocale
  4. REST 路由自动注册 i18n
    • RestServer.registerRoutes() 动态根据已注册服务追加挂载 i18n 路由
    • 配合 discovery/api root 保持服务可用性一致
  5. 协议配置同步修正
    • protocol.ts SERVICE_CONFIG['i18n'] provider 名统一为 'service-i18n'
  6. 测试与验证
    • 单元测试/集成测试:覆盖未注册 plugin-i18n 时的启动与 API 行为
    • 验证覆盖关系:外部 plugin-i18n 可替换内置 stub 实现
    • 生产与开发环境测试
  7. 文档与 CHANGELOG 更新
    • 体现 i18n 服务变为基础内置能力,API/Studio/部署相关说明

风险排查与最佳实践

  • 要避免重复注册导致的冲突
  • 避免 breaking changes(保证 plugin-i18n 仍能优雅覆盖或增强内置实现)
  • 保证 dev/stub/production 多环境一致性

验收标准

  • 不安装 plugin-i18n 也可正常通过 discovery 和 /api/v1/i18n/* 访问默认 i18n 服务
  • 安装 plugin-i18n 后表现完全一致或功能增强,不回退为内置实现
  • 文档与测试覆盖所有主要场景

本 Issue 旨在支撑平台演进、Studio 生产力需求和部署无障碍,须稳健设计,确保长期可维护性。</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@vercel

vercel Bot commented Mar 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-play Ready Ready Preview, Comment Mar 11, 2026 7:16am
spec Ready Ready Preview, Comment Mar 11, 2026 7:16am

Request Review

Copilot AI and others added 2 commits March 11, 2026 07:08
- 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>
Copilot AI changed the title [WIP] Add i18n as built-in core service with auto registration feat: promote i18n to core built-in service with in-memory fallback Mar 11, 2026
@hotlong
hotlong marked this pull request as ready for review March 11, 2026 08:28
Copilot AI review requested due to automatic review settings March 11, 2026 08:28
@hotlong
hotlong merged commit a9ab296 into main Mar 11, 2026
4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 i18n a core service and documented the new default behavior.
  • Added an in-memory II18nService fallback (createMemoryI18n) and registered it in CORE_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 });

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 48
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)

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 246 to +250
### 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)

Copilot AI Mar 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 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).

Copilot uses AI. Check for mistakes.
Comment on lines +710 to +725
// 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 });
}
},

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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,

Copilot uses AI. Check for mistakes.
Comment on lines +682 to +712
/**
* 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',

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

This branch was successfully deployed

2 active deployments
Preview – spec — 14320fc6 Deployed Mar 11, 2026 by vercel[bot]
Preview – objectstack-play — 14320fc6 Deployed Mar 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n 默认内置为核心服务,自动注册/覆盖机制和多环境兼容

3 participants