Skip to content

Commit 589049b

Browse files
fix(metadata-protocol)!: an object saved back from a translated /meta read stores its source labels, and one the registry would refuse is refused at save and at publish (#29790)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8KvRttCwxSQ3t7fNg1Go9ya)_ Fixes #29673 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — `skills/objectstack-data/SKILL.md` (Ownership queues) already teaches that an object declaring its own `queue` field is refused, which stays true and is now said at save; no file under `skills/**` teaches saving a translated read back. ## 维护者速读 - 改了什么:对象保存入口(`PUT /api/v1/meta/object/NAME`,草稿与直接保存)把"中文读取被翻译过的标签"还原成源文本再存,并在保存时就拒绝注册表本会拒绝的对象(422),发布前再查一次;`os migrate meta --stored` 可修复已存的坏行。 - 为什么改:中文 Studio 读回对象时 `queue` 的标签是"队列",原样保存后被当成作者自定义字段,注册表拒绝注册,但发布仍报"成功",数据接口 404;`owner_id` 等列的中文标签还会被所有其他语言的用户看到。 - 风险与回滚:收窄——作者自带 `queue` 字段等注册表本就拒绝的对象,现在保存即 422(以前保存成功但永远注册不上);作者恰好输入某语言对源标签的译文时会被还原为源文本。回滚即还原本 PR,无数据迁移。 - 先看:`packages/metadata-protocol/src/protocol-translated-echo.ts`(还原规则)、`packages/metadata-protocol/src/protocol.ts` 中 `saveMetaItemUnaudited` 的两处还原调用(`restoreInjectedColumnEchoes`、`restoreTranslatedObjectEchoes`,后者以 `getPackagedObjectBase` 为打包基线)与规格校验之后的 `objectRegistrationRefusal`、`packages/objectql/src/registry.ts` 的 `judgeRegistrationDoors`(原有拒绝逻辑整体搬移)。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/protocol-meta-effective-schema.test.ts`,预期 20 个测试全绿,其中 `[#29673]` 一节 8 个。 - 需要你做的:无,席位落地 ## What changes **The door fixed: `saveMetaItem` (`packages/metadata-protocol`), the server door every object `PUT /api/v1/meta/object/NAME` reaches, draft and live.** Whatever the client sends, an injected column or a field no longer stores a label the read translated. Where the translation came from (measured, below): the REST `/meta` read runs `translateMetadataDocument`, which serves each injected system column's label from the built-in table with no bundle needed (`queue` → `队列`, `owner_id` → `负责人`) and an author field's label from the catalog. Studio saves the whole body it read. The save door stored it verbatim; `stripInjectedSystemColumns` removes only a byte-identical platform definition, so the translated `queue` and `owner_id` stayed as the author's own fields. The registry refuses an author `queue` on a user-owned object, `applyObjectRegistryMutation` caught that refusal into a `console.warn`, and `publish-drafts` answered `outcome: 'published'`. 1. **Restore the source text** (`protocol-translated-echo.ts`, new). `saveMetaItemUnaudited` calls it twice: a pure half (`restoreInjectedColumnEchoes`, the platform's injected-column definitions only, no read) just before `stripServedObjectColumns`, and the stored-row half (`restoreTranslatedObjectEchoes`) after every refusal, beside `carryForwardRedactedCredentials`, so a refused save costs no read. A slot is restored when the incoming value differs from its source and equals what `translateObject` makes of that source in some locale. Sources, asked in order: the stored row this save overwrites (the draft, else the active row a first draft overlays, read as the carry-forward reads it), else the packaged base (`getPackagedObjectBase`), then the platform's definition of an injected column (`injectedSystemColumnDefs`). Every read of the source is translated against that packaged base, passed as `translateObject`'s `packagedBase` exactly as the REST read passes it, so a packaged object's overlay slot that diverged from the packaged text (served untranslated) is no translation's source. The packaged base is a registry lookup, not a MetadataService read, so a degraded metadata service fails no save here. Locales: the registered bundle's, plus the built-in table's (`BUILTIN_SYSTEM_FIELD_LABEL_LOCALES`, a new export of `@objectstack/spec/system`), since the read applies that table with no bundle. Slots: object `label` / `pluralLabel` / `description`; field `label` / `description` / `inlineHelpText` / `inlineTitle`; option `label`. Once restored, the injected columns are byte-identical again and the existing strip removes them. `isInjectedColumnDefinition` is unchanged. 2. **Refuse at save what the registry would refuse.** `SchemaRegistry.objectRegistrationRefusal` (new, `packages/objectql/src/registry.ts`) answers the error `registerObject` would throw, registering nothing. `registerObject`'s doors moved whole into a private `judgeRegistrationDoors`, which `registerObject` now calls first, so the question and the throw are one code path. It judges the body alone: the record-currency `summary` door, which reads the registry's other objects as they stand, is left to the registration (a `bodyOnly` stop in `judgeRegistrationDoors`), since a sibling drafted in the same batch can change its answer either way. The save door asks it in both modes, after the spec gate (so a body both refuse keeps the gate's structured `issues[]`): `422 INVALID_METADATA`, nothing stored. The one refusal this makes new at the door is an author's own `queue` on a user-owned object; a field `type` the registry refuses was already refused by the object schema. 3. **Publish truth.** `promoteDraftForPublish` asks the same question inside its D9.9 gate block, before `repo.promoteDraft`. A draft stored before this rule is refused there: pinned on `publishMetaItem` (refused by name, no active row, nothing registered); in `publishPackageDrafts` the promote gates' throws land in `failed[]`, so the receipt does not report it published. 4. **Repair.** `migrateStoredMetadata` (`os migrate meta --stored [--apply]`) runs the same restoration on each stored object row (comparing the row with the platform's definition) and reports each slot as `translated-label-echo`, e.g. `fields.queue.label 队列 → Queue`. `--apply` re-saves the row through the save door, which restores and strips. A restart does not heal such a row; the migration does, and so does the row's next save. 5. **Said on the save answer.** Each author slot the stored-row half puts back is an `info` advisory on the save answer (`rule: 'translated-label-echo'`, `path: 'objects.NAME.fields.F.label'`, with the text stored and the text sent), beside the credential carry-forward's `withheld-key-dropped` notices. The platform's injected columns are not reported: the stored document never carries them. **Out of #29414's region.** The `promoteDraftForPublish` hunk sits inside the D9.9 gate block only; no `publishPackageDrafts` commit-plan rows, `prevVersion` / `existedBefore` recording, `revertCommit` or `resolveOverlayPackageBinding` line is touched. **A ui/** change is also owed (the seat files the card).** Studio's object designer edits the locale-translated read: `ui/packages/auth/src/createAuthenticatedFetch.ts` attaches `Accept-Language` to every API call, so the designer's `GET /api/v1/meta/object/NAME` returns translated labels and it `PUT`s that body back. With this PR the stored document is right, but the designer still shows a translated label as editable source text, so an author who edits it in zh-CN writes a Chinese source label (kept, correctly, as the author's edit). The owed card: the designer loads and edits the untranslated document, and does not send injected system columns back. Files (12): - `packages/metadata-protocol/src/protocol-translated-echo.ts` (new), `protocol.ts`: the restoration, the save and promote refusals, the migration notices. - `packages/objectql/src/registry.ts`: `objectRegistrationRefusal`, `judgeRegistrationDoors` (moved doors, no rule changed; `bodyOnly` stops before the record-currency door). - `packages/spec/src/system/i18n-resolver.ts`: `BUILTIN_SYSTEM_FIELD_LABEL_LOCALES`; `api-surface/system.json` and `export-origins/system.json` regenerated. - Tests: `protocol-translated-echo.test.ts` (new), `protocol-meta-effective-schema.test.ts`, `ownership-queue.test.ts`, `record-currency-guards.test.ts`, `packages/qa/dogfood/test/studio-zh-cn-echo-publish.dogfood.test.ts` (new). - `.changeset/29673-translated-echo-save-door.md`: patch for spec, objectql, metadata-protocol; `!` with the ADR-0087 `not-required` disposition. ## Tests - `packages/objectql/src/protocol-meta-effective-schema.test.ts`, `[#29673]` (real `SchemaRegistry` + real protocol): - zh-CN echo save: the stored fields are `amount`, `memo`; `memo` keeps its author label; the registry has the object; an `en` read is served `Owner` / `Queue`. - An author `queue` is refused at the save door in both modes (`ObjectQueueColumnRefusedError`, `INVALID_METADATA`, 422), and no row is written. - A stored draft carrying an author `queue`: `publishMetaItem` is refused by that name, no active row, nothing registered. - Migration: preview `pending` with the two `translated-label-echo` notices; apply `rewritten`; the stored fields are `amount` only and the object registers. - An author slot the catalog translated (`amount` served `金额`) is stored as `Amount`, and the save answer carries one `info` `translated-label-echo` advisory at `objects.crm_invoice.fields.amount.label`. - The packaged base: a stored overlay slot `Subject` diverged from the packaged `Title`, with the author typing the catalog string `标题`, is stored `标题` with no advisory; the control with no packaged base restores `Subject`. - A body the spec gate and the registry both refuse (an unknown field `type`) answers the spec gate's `INVALID_METADATA` with `issues[]`. - A brand-new object saves while the metadata service reports itself degraded. - `packages/metadata-protocol/src/protocol-translated-echo.test.ts` (6): every slot in zh-CN; `ja-JP` with no bundle; author text kept; a stored echo repaired against the platform definition; the array `fields` shape, and the same reference back when nothing changed; a diverged overlay slot set to the catalog string is kept when the packaged base is passed, and the undiverged control is restored. - `packages/objectql/src/ownership-queue.test.ts`: `objectRegistrationRefusal` answers the throw `registerObject` makes, and registers nothing. - `packages/objectql/src/record-currency-guards.test.ts`: `objectRegistrationRefusal` answers `null` for a parent whose `summary` reads a record-currency child, while `registerObject` still refuses it. - Dogfood `studio-zh-cn-echo-publish.dogfood.test.ts` (4, over the booted showcase through the REST doors): the zh-CN read is translated and the `en` read is not; the zh-CN echo publishes `published` / `failedCount: 0`, stores neither column, serves data, and an `en` read gets `Owner` / `Queue`; the `en` control; an author `queue` is refused 422 and no draft is stored. ## Acceptance notes - **Clause-②: no (narrowing).** The object save door refuses `422 INVALID_METADATA` an object declaring its own `queue` field on a user-owned object (ADR-0142 D3), which it stored before and the registry then refused to register; the promote refuses such a draft. A label equal to what the `/meta` read serves for its source in some locale is stored as the source. Nothing is widened. - **Metadata-protocol: none.** No key is added, removed, renamed or re-shaped; every refused body was already one the registry refused. - **The edge.** An author who types exactly the translation of a label's source in some locale gets the source back. A reader in that locale is served the translation anyway, since the catalog translates it. - **Residual: the post-commit catch stays for the record-currency door.** The pre-flight judges the body alone, so the record-currency `summary` door, which reads the registry's other objects as they stand, stays the registration's own question in both directions: a draft whose sibling in the same batch takes the child's amount out of record-currency mode is not refused at save, and one whose sibling completes the pair passes save and promote and fails at registration, where `applyObjectRegistryMutation` still only logs it. Turning that catch into a `failed[]` row means changing the Phase 2 receipt, which is #29414's region; left for a follow-up after it lands. - **Residual: other translated slots.** The restoration covers the slots `translateObject` writes for the object and its fields. Translated list-view labels and validation messages are not compared; none of them is a registration refusal. - **The protocol.ts freeze.** `protocol.ts` was frozen from 2026-11-03 03:39 UTC until the #25467 section-3 PR merged (339f63d). Main was merged into this branch after the lift with no conflicts, and the review fixes that touch `protocol.ts` landed after it (1fc44a4). - **studio-authoring.first-run-loop, zh-CN leg (for the seat to route).** Run the loop with the console in 中文 (`Accept-Language: zh-CN`): create the object in the designer, publish, and expect (a) the publish receipt `published`, (b) the new object's list serves records, not 404, (c) `GET /api/v1/meta/object/NAME?layers=true` has no `queue` / `owner_id` in the overlay, (d) the same read in `en` shows `Owner` / `Queue`. ## Verification <details><summary>Red first: measured on main fc55892, booted showcase, zh-CN vs en</summary> ``` step1 zh-CN queue 200 {"type":"text","label":"队列",...} step1 en queue 200 Queue package create 201 qazh_q_en PUT draft 200 qazh_q_zh PUT draft 200 publish 200 {"outcome":"published","publishedCount":2,"failedCount":0,...} qazh_q_en meta 200 data 200 qazh_q_zh meta 200 data 404 {"error":"Object 'qazh_q_zh' is not registered","code":"OBJECT_NOT_FOUND"} server log: [Protocol] registerObject failed for qazh_q_zh: ... declares a field named `queue` stored overlay: fields.queue.label 队列, fields.owner_id.label 负责人 ``` </details> <details><summary>Green: same script on this branch</summary> ``` publish 200 {"outcome":"published","publishedCount":2,"failedCount":0} qazh_q_en meta 200 data 200 qazh_q_zh meta 200 data 200 qazh_q_zh stored overlay fields {"title":{"type":"text","label":"Title"}} author queue PUT draft 422 ... declares a field named `queue` ... qazh_q_zh en read owner_id/queue labels Owner Queue ``` </details> <details><summary>Ablations (scripts/ablation-replace.mjs + ablation-dist-preflight, metadata-protocol dist rebuilt per leg)</summary> - Restoration ablated (the early return answers a marker): `protocol-meta-effective-schema.test.ts` 2 failed / 14 passed (the zh-CN echo and the migration repair). - Registration refusal ablated: 2 failed / 14 passed (the save-door refusal and the promote refusal). Its first run showed the promote pin was passing on the OWD gate's 422; the draft now carries a `sharingModel` and the pin asserts `ObjectQueueColumnRefusedError` by name, and the rerun was red. - Restore leg: rebuilt, marker absent, tree clean, 16/16. </details> <details><summary>Final head 1fc44a4 (after contract review 6595971055 and delta review): gates, lint, package tests, ablations, dogfood battery (exit codes captured to file)</summary> - `dispatch-gates --commands`: 85 derived; all 85 exit 0. `--ran` reconciliation: 85 run, 0 NOT-MEASURED. (`check-plugin-teardown-shape --self-test` needed `git fetch origin 621a487` in the shallow clone; `check:dual-build-cjs-loads` needed a whole-workspace `turbo run build` first.) - `pnpm lint` (eslint over the repo, then `turbo run typecheck`): exit 0. - Whole-workspace `turbo run build`: the first run failed only in `@object-ui/app-shell` on `pinyin-pro`, a dependency main added, missing from `node_modules` after the merge; after `pnpm install --frozen-lockfile` the app-shell build ran 65/65 tasks successful. - `pnpm --filter @objectstack/metadata-protocol test`: 311 files passed, 4 skipped; 22311 tests passed, 26 skipped. - `pnpm --filter @objectstack/objectql test`: 544 files, 10770 tests passed. - `pnpm --filter @objectstack/spec exec vitest run src/system`: 69 files, 2213 tests passed. - `pnpm --filter @objectstack/dogfood test` (the full battery, narrowing arm): 406 files passed, 3 skipped; 3356 tests passed, 28 skipped. - Ablations (`scripts/ablation-replace.mjs` + `ablation-dist-preflight`, metadata-protocol dist rebuilt per leg, marker present in the built files), each red as predicted and restored to blob == HEAD: - the packaged base not passed: the packaged-base door test, 1 of 20; - the advisories not added: the advisory test, 1 of 20; - the post-gate registry ask removed: the save-door `queue` refusal, 1 of 20; - the code-layer MetadataService read restored: the degraded-service test, 1 of 20; - in-module, the packaged base not passed to `translateObject`: 1 of 6; - the `bodyOnly` stop removed: the record-currency pre-flight test, 1 of 14. - Restore leg: marker absent from all 24 built files, 20/20, tree clean. The first attempts of three legs were no-ops (a comment marker the build strips, and a leg that did not type-check) and were rerun with markers that survive the build. - The first full pass at f1fa81f had 86 metadata-protocol and 5 objectql test failures: the stored-row read ran before the refusals, against test doubles without `getItem` / `engine.find`. 866fe6c moved that read after the refusals; 1fc44a4 replaced its code-layer fallback with the packaged base, and the interim `getItem` guard in `readCodeLayerForCarryForward` was reverted. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbZUVFvbHQ7Uq4qpCdd <!-- signed: domain:server#2 · os-warren · session_01JQwxbZUVFvbHQ7Uq4qpCdd --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99c0696 commit 589049b

12 files changed

Lines changed: 1186 additions & 45 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(metadata-protocol)!: an object saved back from a translated `/meta` read stores its source labels, and an object the registry would refuse is refused at save and at publish, never reported published (#29673)
8+
9+
Clause-②: no (narrowing) — the object save door (`saveMetaItem`, draft and live) refuses `422 INVALID_METADATA` an object declaring its own `queue` field on a user-owned object (ADR-0142 D3), which it stored before and the registry then refused to register; the publish promote refuses such a draft the same way. A label equal to what the `/meta` read serves for its source in some locale is stored as the source text. Nothing is widened.
10+
Metadata-protocol: none
11+
Skills-docs: none — `skills/objectstack-data/SKILL.md` (Ownership queues) already teaches that an object declaring its own `queue` field is refused, which stays true and is now said at save; no file under `skills/**` teaches saving a translated read back.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped. The refusal at save is the registry's own, moved forward to the door that stores the body; a row stored before this rule already never registered. A stored row carrying a translated injected column is repaired by `os migrate meta --stored`, which re-saves it through the same door: no ledger entry and no tombstone. -->
14+
15+
**BREAKING** accept-set narrowing at the object save door (`@objectstack/metadata-protocol`), served by `PUT /api/v1/meta/object/:name` (draft and live) and `POST /api/v1/packages/:id/publish-drafts`.
16+
17+
- **Before.** A `/meta` read in a non-English locale serves each injected system column with a translated label (`queue` → `队列`, `owner_id` → `负责人`). Studio saves the whole body it read, so the save stored those columns back as the author's own fields. For `queue` the registry then refused the object at publish, the refusal went to a log line, and `publish-drafts` answered `outcome: 'published'` while the object's data door answered 404. For `owner_id` and the other columns, every other locale was served the Chinese label.
18+
- **Now.**
19+
- The save door puts a slot back to its source text when the incoming value differs from the source and equals what the read's translator makes of that source in some locale, translating with the read's own options (a packaged object's overlay slot that diverged from the packaged text is served untranslated, so it is no translation's source). The source is the stored row the save overwrites, then the platform's own definition of an injected column. The strip that follows then removes the platform's columns as it always did. Slots: an object's `label`, `pluralLabel` and `description`; a field's `label`, `description`, `inlineHelpText` and `inlineTitle`; an option's `label`.
20+
- The save door asks the registry whether it would register the object, judging the body alone, and refuses what it would not, in both modes. The publish promote asks again, before anything is promoted, so a draft stored before this rule is refused and not reported published. The only refusal this makes new at the door is an author's own `queue`; a field `type` the registry refuses was already refused at save by the object schema. The record-currency `summary` door is left to the registration, since it judges the body against the other objects as they stand and a sibling in the same batch can change that answer either way.
21+
- `os migrate meta --stored` reports a stored object row carrying a translated label as `translated-label-echo` and, with `--apply`, re-saves it through the save door.
22+
- **Unchanged.** A label that is not a translation of its source is the author's edit and is kept. A field the source does not declare is not compared. An English body is stored as before.
23+
- **The edge.** An author who types exactly the translation of a label's source in some locale gets the source back; a reader in that locale is served the translation anyway.
24+
- **New export.** `BUILTIN_SYSTEM_FIELD_LABEL_LOCALES` (`@objectstack/spec/system`): the locales the built-in system-field label table answers in, with no bundle. `SchemaRegistry.objectRegistrationRefusal` (`@objectstack/objectql`) answers the refusal `registerObject` would throw judging the body alone (every door but the record-currency `summary` one), without registering anything.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29673] `restoreTranslatedEchoes` — the save door's inverse of the `/meta`
5+
* read's translation, as a pure function. The echo every case hands it is
6+
* built with the spec translator the read exits call, so a case can only pass
7+
* when the inverse matches what a read really serves.
8+
*
9+
* The door end to end (the real registry, the real protocol, the publish and
10+
* the stored-row repair) is pinned in `@objectstack/objectql`'s
11+
* `protocol-meta-effective-schema.test.ts`.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { translateObject, type TranslationBundle } from '@objectstack/spec/system';
16+
import { QUEUE_FIELD_DEF, OWNER_FIELD_DEF } from '@objectstack/spec/data';
17+
import { restoreTranslatedEchoes, translatedEchoLocales } from './protocol-translated-echo.js';
18+
19+
const bundle: TranslationBundle = {
20+
'zh-CN': {
21+
objects: {
22+
crm_invoice: {
23+
label: '发票',
24+
fields: {
25+
amount: { label: '金额' },
26+
status: { label: '状态', options: { open: '打开', closed: '关闭' } },
27+
},
28+
},
29+
},
30+
},
31+
} as unknown as TranslationBundle;
32+
33+
/** The source a stored row carries. */
34+
const stored = () => ({
35+
name: 'crm_invoice',
36+
label: 'Invoice',
37+
sharingModel: 'private',
38+
fields: {
39+
amount: { type: 'currency', label: 'Amount' },
40+
status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' }] },
41+
},
42+
});
43+
44+
/** What a read serves in `locale`: the stored row plus the injected columns, translated. */
45+
const read = (locale: string, b: TranslationBundle | undefined = bundle): any => {
46+
const doc: any = stored();
47+
doc.fields = { ...doc.fields, queue: { ...QUEUE_FIELD_DEF }, owner_id: { ...OWNER_FIELD_DEF } };
48+
return translateObject(doc, b, { locale });
49+
};
50+
51+
describe('[#29673] restoreTranslatedEchoes', () => {
52+
it('a zh-CN echo is put back to the source on every slot the read translated', () => {
53+
const echo = read('zh-CN');
54+
// The precondition: the read really translated each slot.
55+
expect([echo.label, echo.fields.amount.label, echo.fields.status.options[0].label, echo.fields.queue.label, echo.fields.owner_id.label])
56+
.toEqual(['发票', '金额', '打开', '队列', '负责人']);
57+
const { item, restored } = restoreTranslatedEchoes(echo, { source: stored(), bundle });
58+
expect(item.label).toBe('Invoice');
59+
expect(item.fields.amount.label).toBe('Amount');
60+
expect(item.fields.status.label).toBe('Status');
61+
expect(item.fields.status.options.map((o: any) => o.label)).toEqual(['Open', 'Closed']);
62+
// The injected columns are the platform's own again, byte for byte: the strip that follows removes them.
63+
expect(item.fields.queue).toEqual(QUEUE_FIELD_DEF);
64+
expect(item.fields.owner_id).toEqual(OWNER_FIELD_DEF);
65+
expect(restored.map((r) => r.path).sort()).toEqual([
66+
'fields.amount.label', 'fields.owner_id.label', 'fields.queue.label',
67+
'fields.status.label', 'fields.status.options[closed].label', 'fields.status.options[open].label', 'label',
68+
]);
69+
});
70+
71+
it('a built-in locale with no bundle at all is recognised (the read translates injected columns without one)', () => {
72+
expect(translatedEchoLocales(undefined)).toEqual(expect.arrayContaining(['zh-CN', 'ja-JP', 'es-ES']));
73+
const echo = read('ja-JP', undefined);
74+
expect(echo.fields.queue.label).toBe('キュー');
75+
const { item } = restoreTranslatedEchoes(echo, { source: stored(), bundle: undefined });
76+
expect(item.fields.queue).toEqual(QUEUE_FIELD_DEF);
77+
});
78+
79+
it('the author\'s own text is kept: an edit that is not a translation, a new field, an own owner_id label', () => {
80+
const echo = read('zh-CN');
81+
echo.fields.amount.label = 'Total';
82+
echo.fields.memo = { type: 'text', label: '备注' };
83+
echo.fields.owner_id = { ...OWNER_FIELD_DEF, label: 'Account Owner' };
84+
const source: any = stored();
85+
source.fields.owner_id = { ...OWNER_FIELD_DEF, label: 'Account Owner' };
86+
const { item } = restoreTranslatedEchoes(echo, { source, bundle });
87+
expect(item.fields.amount.label).toBe('Total');
88+
expect(item.fields.memo.label).toBe('备注');
89+
expect(item.fields.owner_id.label).toBe('Account Owner');
90+
});
91+
92+
it('a row that already stored the echo is repaired against the platform\'s definition', () => {
93+
const echo = read('zh-CN');
94+
// The stored row IS the echo (a zh-CN session saved it before this door existed).
95+
const { item } = restoreTranslatedEchoes(echo, { source: echo, bundle });
96+
expect(item.fields.queue).toEqual(QUEUE_FIELD_DEF);
97+
expect(item.fields.owner_id).toEqual(OWNER_FIELD_DEF);
98+
});
99+
100+
it('the array `fields` shape is restored in place, and an untranslated body comes back as the same reference', () => {
101+
const echo = read('zh-CN');
102+
const asArray = { ...echo, fields: Object.entries(echo.fields).map(([name, f]: [string, any]) => ({ name, ...f })) };
103+
const { item } = restoreTranslatedEchoes(asArray, { source: stored(), bundle });
104+
expect(item.fields.map((f: any) => f.name)).toEqual(asArray.fields.map((f: any) => f.name));
105+
expect(item.fields.find((f: any) => f.name === 'amount').label).toBe('Amount');
106+
107+
const untouched = read('en');
108+
const same = restoreTranslatedEchoes(untouched, { source: stored(), bundle });
109+
expect(same.item).toBe(untouched);
110+
expect(same.restored).toEqual([]);
111+
});
112+
113+
it('a packaged object\'s overlay slot that diverged from the packaged text is no translation\'s source: the catalog string an author types is kept', () => {
114+
// The packaged base ships `amount: Amount`; the catalog translates that
115+
// packaged text. The tenant's overlay renamed it `Total`, which the read
116+
// serves untranslated in every locale (ADR-0029 D9.2a).
117+
const packagedBase = stored();
118+
const overlay: any = stored();
119+
overlay.fields.amount = { ...overlay.fields.amount, label: 'Total' };
120+
// The precondition: the read, with the packaged base, serves `Total` in zh-CN.
121+
expect((translateObject(overlay, bundle, { locale: 'zh-CN', packagedBase }) as any).fields.amount.label).toBe('Total');
122+
123+
const typed: any = stored();
124+
typed.fields.amount = { ...typed.fields.amount, label: '金额' };
125+
const kept = restoreTranslatedEchoes(typed, { source: overlay, bundle, packagedBase });
126+
expect(kept.item.fields.amount.label).toBe('金额');
127+
expect(kept.restored).toEqual([]);
128+
129+
// Control: the same typed string over a slot that still equals the
130+
// packaged text IS the read's translation, and is put back.
131+
const echoed = restoreTranslatedEchoes(typed, { source: packagedBase, bundle, packagedBase });
132+
expect(echoed.item.fields.amount.label).toBe('Amount');
133+
});
134+
});

0 commit comments

Comments
 (0)