Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/compose-stacks-i18n-single-valued.md
Original file line number Diff line number Diff line change
@@ -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`)的拼接语义一字未变。
9 changes: 9 additions & 0 deletions content/docs/getting-started/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
235 changes: 235 additions & 0 deletions packages/spec/src/compose-stacks-i18n-merge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<typeof vi.spyOn>;

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' });
});
});
12 changes: 5 additions & 7 deletions packages/spec/src/compose-stacks-key-loss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 ─────────────────
Expand Down
16 changes: 13 additions & 3 deletions packages/spec/src/compose-stacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading