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
13 changes: 13 additions & 0 deletions .changeset/26128-translation-item-snake-case-name.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ One locale of translations — the `translation` metadata type
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | 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. |
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/fallbacks/authored-translation-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
24 changes: 24 additions & 0 deletions packages/metadata-protocol/src/protocol.item-name-grammar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
4 changes: 2 additions & 2 deletions packages/objectql/src/protocol-publish-canonical-fold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@ async function refusalOf(fn: () => Promise<unknown>): 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 });

Expand Down
15 changes: 9 additions & 6 deletions packages/spec/src/system/translation.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading