diff --git a/.changeset/26128-translation-item-snake-case-name.md b/.changeset/26128-translation-item-snake-case-name.md new file mode 100644 index 00000000000..c0b41cc1934 --- /dev/null +++ b/.changeset/26128-translation-item-snake-case-name.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +'@objectstack/core': patch +--- + +fix(spec): `TranslationItemSchema.name` no longer tells authors to name a translation item after its locale tag + +Clause-②: no +Metadata-protocol: none + +The `name` describe said a translation item was "conventionally the locale code (`zh-CN`)", but the metadata save door's item-name grammar refuses `zh-CN` (uppercase and a hyphen), so an author or agent following the describe got a 400 `INVALID_REQUEST` on the first save. The describe now says a translation item is named in snake_case like every metadata item (e.g. `zh_cn`), with the BCP-47 tag in `locale`. The save door and the runtime sync's read-side name fallback for rows stored before `locale` became required are unchanged; the sync's skip warning now points authors at `locale` only. + +`@objectstack/core`: the authored-translation sync's no-locale skip warning now tells authors to set `locale` to the item's BCP-47 tag (e.g. `zh-CN`), instead of suggesting they name the item after it. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index a28db115d72..d45e85fcdc5 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -742,7 +742,7 @@ One locale of translations — the `translation` metadata type | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | -| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent | +| **name** | `string` | optional | Item name — snake_case like every metadata item (e.g. `zh_cn`); the BCP-47 tag goes in `locale` | | **label** | `string` | optional | Human-readable label shown in metadata lists | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/packages/core/src/fallbacks/authored-translation-sync.ts b/packages/core/src/fallbacks/authored-translation-sync.ts index 05e37c6b9a3..b116f4ceddc 100644 --- a/packages/core/src/fallbacks/authored-translation-sync.ts +++ b/packages/core/src/fallbacks/authored-translation-sync.ts @@ -192,7 +192,7 @@ export async function readAuthoredTranslationLayer( if (!locale) { logger?.warn?.( `[i18n] authored translation '${row?.name}' has no resolvable locale ` - + "(set the top-level 'locale', or name the item after its BCP-47 locale) — skipped", + + "(set the top-level 'locale' to its BCP-47 tag, e.g. 'zh-CN') — skipped", ); continue; } diff --git a/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts index 3e98e36290e..03b8f897ce3 100644 --- a/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts +++ b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts @@ -300,3 +300,27 @@ describe('#12194 — the refusal is scoped to the doors that MINT or PROMOTE', ( ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); }); }); + +describe('#26128 — a translation item is named in snake_case, the BCP-47 tag goes in `locale`', () => { + // `TranslationItemSchema.name` once called the locale tag (`zh-CN`) the + // conventional item name, while this door refused it. The describe moved; + // the door did not. Both halves are pinned here so they cannot drift again. + const TRANSLATION_BODY = { locale: 'zh-CN', label: '简体中文' }; + + it('refuses a tag-shaped item name (`zh-CN`) with the grammar refusal and persists nothing', async () => { + const { protocol, rows } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'translation', name: 'zh-CN', item: { ...TRANSLATION_BODY, name: 'zh-CN' } }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400, message: expect.stringMatching(GRAMMAR_MESSAGE) }); + expect(metaRows(rows)).toHaveLength(0); + }); + + it('accepts the snake_case name (`zh_cn`) with `locale: \'zh-CN\'`', async () => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ type: 'translation', name: 'zh_cn', item: { ...TRANSLATION_BODY, name: 'zh_cn' } }); + expect(result.success).toBe(true); + expect(metaRows(rows)).toHaveLength(1); + expect(metaRows(rows)[0]!.name).toBe('zh_cn'); + expect(JSON.parse(metaRows(rows)[0]!.metadata).locale).toBe('zh-CN'); + }); +}); diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index d0ea71a2e9b..0a14d0f3c0a 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -265,8 +265,8 @@ async function refusalOf(fn: () => Promise): Promise<{ code?: string; s /** * A `translation` body. `TranslationItemSchema` requires `locale`; `name` is - * declared and optional, and is deliberately NOT snake_case-constrained for - * this type (translation items are conventionally named after their locale). + * declared and optional; like every metadata item it is named in snake_case + * (`zh_cn`), with the BCP-47 tag in `locale`. */ const translationBody = (label: string) => ({ locale: 'zh-CN', name: 'zh_cn', label }); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index d8bd010ad95..39bb0800ca0 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -2255,12 +2255,15 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // Undeclared, they were stripped on every parse, so the metadata door quietly // discarded an item's own name on the way through. // - // Not snake_case-constrained the way `job.name` is: translation items are - // conventionally named after their locale (`zh-CN`), and the runtime sync - // reads `row.name` as a locale fallback for exactly that reason. Optional - // because `locale`, not `name`, is this type's required identity — that call - // was deliberate (a skipped item is invisible) and is not being reopened. - name: z.string().optional().describe('Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent'), + // Named like every other metadata item: snake_case (`zh_cn`), with the BCP-47 + // tag in `locale`. The save door's item-name grammar + // (`METADATA_ITEM_NAME_PATTERN`) refuses a tag-shaped name such as `zh-CN`, + // so this shape does not re-check it. The runtime sync still reads a + // tag-shaped `row.name` as a locale fallback, but only for rows stored before + // `locale` became required. Optional because `locale`, not `name`, is this + // type's required identity — that call was deliberate (a skipped item is + // invisible) and is not being reopened. + name: z.string().optional().describe('Item name — snake_case like every metadata item (e.g. `zh_cn`); the BCP-47 tag goes in `locale`'), label: z.string().optional().describe('Human-readable label shown in metadata lists'), // ADR-0010 — runtime protection envelope (internal — set by the loader).