diff --git a/.changeset/compose-stacks-i18n-single-valued.md b/.changeset/compose-stacks-i18n-single-valued.md new file mode 100644 index 0000000000..d3d4a4c055 --- /dev/null +++ b/.changeset/compose-stacks-i18n-single-valued.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": major +--- + +fix(spec)!: `composeStacks` 的 `i18n` 退出 last-wins —— 同值放行、冲突报错带处方 (#5051) + +#5005 把 `composeStacks` 的非数组顶层键统一成「同值放行 / 冲突报错」,并由维护者 +2026-08-04 裁决点名否掉了 last-wins(**先声明的栈被后组合者无声覆盖**)。`i18n` 是 +唯一逃过那一轮的键 —— 因为 #5005 的主题是**被丢掉**的键,而 `i18n` 本来就有一个能 +工作(只是无声)的策略。于是它成了整个顶层键面上**仅剩的一个 last-wins**。 +2026-08-06 裁决取 A(对齐),2026-08-07 窗口拉入 v17。 + +## FROM → TO(行为激活面) + +| 场景 | FROM(v17 之前) | TO(本次) | +|:--|:--|:--| +| 只有一个栈声明 `i18n` | 保留该声明 | **不变** | +| 多个栈声明**相同**的 `i18n` | 保留(数组里最后那个,值相同) | **不变**(同值放行) | +| 多个栈声明**不同**的 `i18n` | 数组里靠后的栈胜出,**靠前那个栈的声明被静默丢弃** —— 不报错、不告警,作者无从分辨「被覆盖」与「从没写过」 | **抛错**,点名冲突键 `i18n`、两个来源栈(manifest id,无 manifest 时用 `stack #N`)与两条出路 | + +被静默丢弃的那一侧,过去从组合结果里彻底消失;现在它不再消失 —— 它以一条点名两个 +来源栈的错误的形式出现,由作者决定保留哪一个。这就是本次「静默丢弃 → 不再丢弃」的 +激活面:凡是今天**已经**在依赖 last-wins 覆盖语言配置的组合,升级后会在 +`composeStacks` 处响亮地停下。 + +## 为什么本地化配置不能择一,也不能深合并 + +一个栈的 `translations` 语言包是**照着它自己声明的 `supportedLocales` 写的**。让附加 +包的 `i18n` 胜出,等于让基础栈的语言包去寻址一组组合后的应用不再承认的 locale —— +运行时看到的只是「查不到翻译」,而不是「你的语言配置被覆盖了」。深合并同样出局 +(#5005 原裁决):它造出一个两位作者都没写过的第三种 locale 集合。 + +仓内实测:`examples/app-crm`(`['en','zh-CN']`)与 `examples/app-todo` +(`['en','zh-CN','ja-JP']`)正是这一对 —— `defaultLocale` 一致,locale 集合不一致。 + +## 迁移 + +组合时报 `composeStacks conflict: top-level key 'i18n' …`,按错误信息里的处方二选一: + +```ts +// A. 让两处声明一致(通常是取并集,由作者决定) +i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN', 'ja-JP'], fallbackLocale: 'en' } + +// B. 只在应当拥有它的那个栈里声明 `i18n`,其余栈删掉这一段 +``` + +`ComposeStacksOptionsSchema` **不加旋钮**:显式覆盖机制按 #5005 裁决,留给定制故事真 +拉动时统一设计,不为单个键预支。数组键(含 `translations`)的拼接语义一字未变。 diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index 326d03d041..47bcb0878d 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -306,6 +306,15 @@ import TodoApp from '../examples/app-todo/objectstack.config'; export default composeStacks([CrmApp, TodoApp]); ``` +Single-valued configuration keys — `i18n`, `api`, `server`, `runtimeModule` — +are neither overridden nor merged: identical declarations pass through, and two +stacks declaring *different* values throw an error naming both stacks. The two +apps above ship different `i18n.supportedLocales` (CRM `['en','zh-CN']`, Todo +`['en','zh-CN','ja-JP']`), so composing them means reconciling the two `i18n` +blocks first — make them identical, or declare `i18n` in only one of the +stacks. Composition never picks a locale set for you: each stack's +`translations` bundles are written against the locales that stack declares. + ### Short Names Are Canonical Each app declares a `namespace` in its manifest, but **the short object name is what you use everywhere** — in `engine.find()`, hooks, formulas, lookups, REST URLs, and physical tables. The namespace is internal metadata used only for package provenance and cross-package disambiguation. diff --git a/packages/spec/src/compose-stacks-i18n-merge.test.ts b/packages/spec/src/compose-stacks-i18n-merge.test.ts new file mode 100644 index 0000000000..87d38bc046 --- /dev/null +++ b/packages/spec/src/compose-stacks-i18n-merge.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5051 — `composeStacks` composes `i18n` like every other single-valued + * top-level key: identical declarations pass through, differing ones throw. + * + * ## What changed and why + * + * #5005 unified the non-array top-level keys on «same value passes / conflict + * errors», and the maintainer's 2026-08-04 ruling named the rejected shape + * explicitly: ⛔ no last-wins — an earlier stack's declaration overwritten + * without a word by whoever composes after it. `i18n` survived that pass only + * because #5005's subject was keys that were *dropped*, and `i18n` had a + * working (if silent) strategy of its own. It was then the single remaining + * last-wins key on the whole surface; the 2026-08-06 ruling on #5051 took + * option **A — align**. + * + * The harm is concrete rather than theoretical: a stack's `translations` + * bundles are authored against the `supportedLocales` that same stack + * declares. Letting an add-on's `i18n` win means the base stack's bundles now + * address locales the composed application does not admit — and the author is + * told nothing. (The shipped examples are exactly this pair: `app-crm` + * declares `['en','zh-CN']`, `app-todo` declares `['en','zh-CN','ja-JP']`.) + * + * ## Reverse verification — direction declared BEFORE running + * + * Restoring the deleted last-wins limb (the `i18n` step in `composeStacks` + * plus the `i18n: 'i18n'` disposition) must turn the CONFLICT cases below RED: + * they stop throwing and return the later stack's config, so the assertion + * fails naming exactly the value that survived — with the earlier stack's + * declaration nowhere in the result. That is the plain "red" direction. + * + * The pass-through cases (one declarant; identical declarations) are GREEN + * under both implementations by construction — last-wins and single-value + * agree whenever there is nothing to disagree about. They are control, not + * evidence, and are labelled as such below. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { composeStacks, defineStack, type ObjectStackDefinition } from './stack.zod'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +/** A stack object that is NOT schema-validated — lets a case declare a partial `i18n`. */ +function raw(overrides: Record): ObjectStackDefinition { + return defineStack(overrides as never, { strict: false }); +} + +const manifestA = { id: 'com.example.base', name: 'base', version: '1.0.0', type: 'app' as const }; +const manifestB = { id: 'com.example.addon', name: 'addon', version: '1.0.0', type: 'app' as const }; +const manifestC = { id: 'com.example.extra', name: 'extra', version: '1.0.0', type: 'app' as const }; + +let warnSpy: ReturnType; + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + warnSpy.mockRestore(); +}); + +// ─── Conflicts — the behaviour #5051 changed ──────────────────────── + +describe('#5051 — conflicting `i18n` declarations are a composition error', () => { + it('throws instead of letting the later stack win (the issue\'s own repro)', () => { + const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); + const b = raw({ manifest: manifestB, i18n: { defaultLocale: 'zh-CN' } }); + + // On origin/main this returned `{ defaultLocale: 'zh-CN' }` and stack A's + // declaration vanished without a word. + expect(() => composeStacks([a, b])).toThrow(/top-level key 'i18n'/); + }); + + it('names both source stacks and the way out', () => { + const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); + const b = raw({ manifest: manifestB, i18n: { defaultLocale: 'zh-CN' } }); + + let message = ''; + try { + composeStacks([a, b]); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toContain("'com.example.base' (stack #0)"); + expect(message).toContain("'com.example.addon' (stack #1)"); + expect(message).toContain("Fix: make the two 'i18n' declarations identical"); + // The prescription names the i18n-specific harm, not only the security one. + expect(message).toContain("'translations'"); + }); + + it('is order-independent — swapping the stacks still throws', () => { + const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); + const b = raw({ manifest: manifestB, i18n: { defaultLocale: 'zh-CN' } }); + + expect(() => composeStacks([b, a])).toThrow(/top-level key 'i18n'/); + }); + + it('throws on the shipped examples\' shape — same default locale, different locale sets', () => { + // Verbatim from examples/app-crm and examples/app-todo, the pair the + // composition docs use. `defaultLocale` agrees; `supportedLocales` does not. + const crm = raw({ + manifest: manifestA, + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' }, + }); + const todo = raw({ + manifest: manifestB, + i18n: { + defaultLocale: 'en', + supportedLocales: ['en', 'zh-CN', 'ja-JP'], + fallbackLocale: 'en', + }, + }); + + expect(() => composeStacks([crm, todo])).toThrow(/top-level key 'i18n'/); + }); + + it('does NOT deep-merge two partial declarations into a third value', () => { + // Each stack declares a key the other does not. Under a deep merge this + // would compose to { defaultLocale: 'en', fallbackLocale: 'en', + // supportedLocales: [...] } — a value neither author wrote. The ruling + // (#5005, reaffirmed for i18n by #5051) rejects that as loudly as last-wins. + const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); + const b = raw({ + manifest: manifestB, + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'ja-JP'] }, + }); + + expect(() => composeStacks([a, b])).toThrow(/top-level key 'i18n'/); + }); + + it('reports the first disagreeing pair across three stacks', () => { + const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); + const b = raw({ manifest: manifestB, i18n: { defaultLocale: 'zh-CN' } }); + const c = raw({ manifest: manifestC, i18n: { defaultLocale: 'en' } }); + + let message = ''; + try { + composeStacks([a, b, c]); + } catch (error) { + message = (error as Error).message; + } + + expect(message).toContain("'com.example.base' (stack #0)"); + expect(message).toContain("'com.example.addon' (stack #1)"); + }); + + it('falls back to the positional label when a stack has no manifest', () => { + const a = raw({ i18n: { defaultLocale: 'en' } }); + const b = raw({ i18n: { defaultLocale: 'zh-CN' } }); + + expect(() => composeStacks([a, b])).toThrow(/stack #0 and stack #1/); + }); + + it('rejects the conflict on schema-valid, strictly-parsed stacks too', () => { + // Not just the `strict: false` door: this is what an author writing two + // real `objectstack.config.ts` files and composing them now sees. + const a = defineStack({ + manifest: manifestA, + i18n: { defaultLocale: 'en', supportedLocales: ['en'] }, + }); + const b = defineStack({ + manifest: manifestB, + i18n: { defaultLocale: 'zh-CN', supportedLocales: ['zh-CN'] }, + }); + + expect(() => composeStacks([a, b])).toThrow(/top-level key 'i18n'/); + }); +}); + +// ─── Pass-through — CONTROL (green before and after #5051) ────────── + +describe('#5051 control — an `i18n` nobody disagrees about still composes', () => { + it('keeps the only declaration, from either position', () => { + const withI18n = { manifest: manifestA, i18n: { defaultLocale: 'en', supportedLocales: ['en'] } }; + const without = { manifest: manifestB }; + + expect(composeStacks([raw(withI18n), raw(without)]).i18n).toEqual({ + defaultLocale: 'en', + supportedLocales: ['en'], + }); + expect(composeStacks([raw(without), raw(withI18n)]).i18n).toEqual({ + defaultLocale: 'en', + supportedLocales: ['en'], + }); + }); + + it('passes identical declarations through (structural equality, not identity)', () => { + const a = raw({ + manifest: manifestA, + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' }, + }); + const b = raw({ + manifest: manifestB, + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' }, + }); + + expect(composeStacks([a, b]).i18n).toEqual({ + defaultLocale: 'en', + supportedLocales: ['en', 'zh-CN'], + fallbackLocale: 'en', + }); + }); + + it('treats an explicit `i18n: undefined` as "not declared"', () => { + const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); + const b = raw({ manifest: manifestB, i18n: undefined }); + + expect(composeStacks([a, b]).i18n).toEqual({ defaultLocale: 'en' }); + }); + + it('leaves `i18n` absent when no stack declares one', () => { + const composed = composeStacks([raw({ manifest: manifestA }), raw({ manifest: manifestB })]); + expect(composed.i18n).toBeUndefined(); + }); + + it('does not warn — `i18n` has a declared composition rule', () => { + const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); + const b = raw({ manifest: manifestB, i18n: { defaultLocale: 'en' } }); + + composeStacks([a, b]); + + // A key reaching the composer without a rule warns (#5005 rule 3). `i18n` + // is declared `'single'`, so silence here is the positive evidence that it + // did not fall through to the default path. + expect(warnSpy.mock.calls.map((call: unknown[]) => String(call[0]))).toEqual([]); + }); + + it('leaves the single-stack short circuit alone', () => { + const only = raw({ manifest: manifestA, i18n: { defaultLocale: 'zh-CN' } }); + expect(composeStacks([only]).i18n).toEqual({ defaultLocale: 'zh-CN' }); + }); +}); diff --git a/packages/spec/src/compose-stacks-key-loss.test.ts b/packages/spec/src/compose-stacks-key-loss.test.ts index c84283c4b6..199d18258a 100644 --- a/packages/spec/src/compose-stacks-key-loss.test.ts +++ b/packages/spec/src/compose-stacks-key-loss.test.ts @@ -287,7 +287,7 @@ describe('#5005 control — array keys still concatenate', () => { // ─── Control — the pre-existing bespoke strategies are untouched ──── -describe('#5005 control — manifest / objects / i18n strategies unchanged', () => { +describe('#5005 control — manifest / objects strategies unchanged', () => { it('manifest still follows the `manifest` option', () => { const a = raw({ manifest: manifestA }); const b = raw({ manifest: manifestB }); @@ -304,12 +304,10 @@ describe('#5005 control — manifest / objects / i18n strategies unchanged', () expect(composeStacks([a, b], { objectConflict: 'override' }).objects).toHaveLength(1); }); - it('i18n keeps its pre-existing last-wins (deliberately out of scope for #5005)', () => { - const a = raw({ manifest: manifestA, i18n: { defaultLocale: 'en' } }); - const b = raw({ manifest: manifestB, i18n: { defaultLocale: 'zh-CN' } }); - - expect(composeStacks([a, b]).i18n).toEqual({ defaultLocale: 'zh-CN' }); - }); + // `i18n` used to be pinned here as the one bespoke strategy #5005 left alone + // (last-wins). #5051 retired that strategy — the key is now `'single'` like + // `api` / `server`, and its coverage lives in + // `compose-stacks-i18n-merge.test.ts`. }); // ─── Structural pin — every declared key has a rule ───────────────── diff --git a/packages/spec/src/compose-stacks.test.ts b/packages/spec/src/compose-stacks.test.ts index 06a8025982..4537b504a9 100644 --- a/packages/spec/src/compose-stacks.test.ts +++ b/packages/spec/src/compose-stacks.test.ts @@ -261,12 +261,22 @@ describe('composeStacks - manifest strategy', () => { // ─── i18n handling ────────────────────────────────────────────────── describe('composeStacks - i18n', () => { - it('should use last i18n config (last-wins)', () => { + // #5051 flipped this case: `i18n` used to be last-wins, so composing these + // two stacks silently produced `zh` and threw `s1`'s declaration away. It is + // now a single-valued key like `api` / `server` — a disagreement is an error. + it('should throw on conflicting i18n config rather than letting the last stack win', () => { const s1 = makeStack({ i18n: { defaultLocale: 'en', supportedLocales: ['en'] } }); const s2 = makeStack({ i18n: { defaultLocale: 'zh', supportedLocales: ['zh', 'en'] } }); - const result = composeStacks([s1, s2]); - expect(result.i18n?.defaultLocale).toBe('zh'); + expect(() => composeStacks([s1, s2])).toThrow(/top-level key 'i18n'/); + }); + + it('should pass an identical i18n config through', () => { + const config = { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }; + const s1 = makeStack({ i18n: { ...config } }); + const s2 = makeStack({ i18n: { ...config } }); + + expect(composeStacks([s1, s2]).i18n).toEqual(config); }); it('should skip stacks without i18n', () => { diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index f913322825..299a0465ad 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1347,11 +1347,10 @@ export type ComposeStacksOptions = z.input; * through, differing ones are a composition ERROR. * - `'manifest'` — chosen by the `manifest` option. * - `'objects'` — merged by the `objectConflict` strategy. - * - `'i18n'` — last-wins (pre-existing; see the table note below). * - `'functions'` — named-handler collection; merged by name. * @internal */ -type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'i18n' | 'functions'; +type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'functions'; /** * The composition rule for EVERY top-level key of `ObjectStackDefinition` @@ -1380,13 +1379,19 @@ type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'i18n' * reaches composition without a rule — via `strict: false`, or a raw object — * still reports itself. * - * ## Note on `i18n` + * ## Note on `i18n` (#5051) * - * `i18n` keeps its pre-existing last-wins. It is the one key here that already - * had a deliberate, working strategy, so #5005 (whose subject is keys that were - * *dropped*) deliberately leaves it alone rather than breaking compositions - * that rely on it. It is nonetheless the same silent-override shape the - * maintainer rejected for `api`/`server` — tracked separately. + * `i18n` carried a last-wins of its own through #5005 — the one key here that + * already had a deliberate, working strategy, and #5005's subject was keys that + * got *dropped*. That left it as the only top-level key still resolving a + * disagreement by silent override: the very shape the maintainer rejected for + * `api`/`server` — an earlier stack's declaration overwritten without a word by + * whoever composes after it. #5051 closed the inconsistency — `i18n` is + * `'single'` like every other non-array configuration key. Which locales an + * application supports is not a detail a composer may pick for the author: the + * `translations` bundles each stack ships are written against its own + * `supportedLocales`, so overriding one stack's declaration leaves the other + * stack's bundles addressing locales the composed app no longer admits. * * @internal */ @@ -1394,7 +1399,6 @@ const COMPOSE_KEY_DISPOSITIONS: Record= 0; i--) { - if (stacks[i].i18n) { - composed.i18n = stacks[i].i18n; - break; - } - } - - // 3. Objects — use conflict strategy + // 2. Objects — use conflict strategy const objects = mergeObjects(stacks, opts.objectConflict); if (objects) { composed.objects = objects; } - // 4. Array collections — simple concatenation, in stack order. + // 3. Array collections — simple concatenation, in stack order. for (const field of CONCAT_ARRAY_FIELDS) { const declared = stacks .map((s) => (s as Record)[field]) @@ -1783,13 +1788,14 @@ export function composeStacks( } } - // 5. Named handler functions — merged by name (#5005). + // 4. Named handler functions — merged by name (#5005). const functions = composeFunctions(stacks); if (functions.declared) { composed.functions = functions.value; } - // 6. Every remaining top-level key (#5005). + // 5. Every remaining top-level key (#5005) — `api`, `server`, + // `runtimeModule` and, since #5051, `i18n`. // // This loop is the reason composition can no longer eat a key. It walks // what the STACKS actually carry rather than a whitelist, so a key with a