fix(i18n)!: field labels emit the entry shape they declare, and stop discarding help/options (#3847) - #3859
Merged
os-zhuang merged 1 commit intoJul 28, 2026
Conversation
…discarding help/options (#3847) `GetFieldLabelsResponseSchema` has always declared each label as `{ label, help?, options? }`. Both serving surfaces emitted `Record<string, string>` — a bare label per field. A client typed against `GetFieldLabelsResponse` read `labels[field].label` and got undefined, because the value was the string itself. The SDK's type was right the whole time; the servers were wrong. The cost is not only the type mismatch. `FieldTranslationSchema` carries `help` and `options`, bundles populate them, and the endpoint threw them away. objectui needs exactly those — its `spec-translations.ts` transform reads `label` AND `options` (as `fieldOptions.<obj>.<fld>.<value>`) — and gets them by pulling the whole bundle from `/i18n/translations/:locale` and resolving client-side. The per-object endpoint could not have served it even if it wanted to: the data was dropped at the emit site. Fixed at that emit site, `resolveObjectFieldLabels`, which both surfaces already share as of #3833, so one change covers both. `help` and `options` attach only when non-empty: an `options: {}` would claim a field has translated options and hand back none, and a `help: ''` would erase a caller's source help text. Fields with no non-empty `label` are still omitted entirely, which is what lets `ResolvedFieldLabel.label` be a required string. The response schema is UNCHANGED — this moves the implementation onto the contract, not the contract onto the implementation. Generated docs are byte-identical for that reason. `placeholder` is deliberately left out: `FieldTranslationSchema` has it and the response schema does not, so emitting it would widen the contract rather than satisfy it, and adding an optional response field later is additive. The regression guard is the part worth keeping: a test that builds the response body from the shared helper and parses it with `GetFieldLabelsResponseSchema`. Nothing had ever put the emitted value and the declared contract in one assertion, which is precisely why a bare string sat under an object schema unnoticed. Third and last of the declared != enforced gaps on this endpoint family, after #3676 and #3833. BREAKING: `labels[field]` is now `{ label, help?, options? }` rather than a string. No consumer in this repo or objectui read it — objectui never calls this route, and in-repo use is the SDK method plus URL-shape tests — so the practical blast radius is nil. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0196gwfwMK5vW8RToPyzGMJo
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 28, 2026 12:56
os-zhuang
deleted the
claude/gettranslationsrequest-namespace-keys-rv08r7
branch
July 28, 2026 12:56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3847. Third and last of the declared ≠ enforced gaps on this endpoint family, after #3676 (request filters no server read) and #3833 (a derivation scanning a retired dialect).
The gap
GetFieldLabelsResponseSchemahas always declared each label as an object:Both serving surfaces emitted
Record<string, string>— a bare label per field. A client typed againstGetFieldLabelsResponsereadlabels[field].labeland gotundefined, because the value was the string. The SDK's type was right the whole time; the servers were wrong.Why it is more than a type mismatch
FieldTranslationSchemacarrieshelpandoptions, bundles populate them, and the endpoint threw them away.The issue asked whether this endpoint is even worth keeping, which turned on how objectui gets field labels. It does not call this route. It pulls the whole bundle from
/i18n/translations/:localeand resolves client-side —packages/i18n/src/utils/spec-translations.tstransformsTranslationDatainto i18next resources, anduseObjectLabelreadsfields.<obj>.<fld>.That transform reads
labelandoptions(asfieldOptions.<obj>.<fld>.<value>). So the data this endpoint was discarding is exactly the data its most likely consumer needs — it could not have served objectui even if objectui wanted it, because the loss happened at the emit site.That answers the "should it exist" question in favor of fixing it: the declared shape is the useful one, and the endpoint was the only thing standing between it and a caller. Option 1 from the issue.
The fix
One change at the shared emit site,
resolveObjectFieldLabels— which both surfaces already call as of #3833, so this covers the dispatcher and service-i18n together. That is the payoff from sharing it last time: this fix would otherwise have been two edits with one of them easy to miss, which is exactly how #3833 happened.helpandoptionsattach only when non-empty. Anoptions: {}would claim a field has translated options and hand back none; ahelp: ''would erase a caller's source help text.labelare still omitted entirely — which is what letsResolvedFieldLabel.labelbe a requiredstringrather than optional.The response schema is unchanged. This moves the implementation onto the contract, not the contract onto the implementation —
check:docsreports the 250 generated files byte-identical for that reason, andcheck:api-surfaceshows 0 breaking / 1 added (the newResolvedFieldLabeltype).On
placeholderDeliberately left out.
FieldTranslationSchemahas it; the response schema does not. Emitting it would be widening the contract rather than satisfying it, and that is a separate decision with no consumer asking. Adding an optional response field later is additive and non-breaking — the same low-regret reasoning that justified trimmingnamespacein #3676 rather than implementing it speculatively.The regression guard
The part worth keeping:
Nothing had ever put the emitted value and the declared contract in the same assertion. That is precisely how a bare string sat under an object schema unnoticed across three issues in this area — the tests asserted emitted-vs-expected-literal, never emitted-vs-declared.
This is the small version of the CI gate worth generalizing: fixture and response bodies validated against the Zod schema that declares them would have caught this one, and would also have caught #3833's fixture (flat
o.data cannot parse asTranslationDataSchema).Verification
@objectstack/spec— 261 files / 6790 tests pass@objectstack/runtime— 47 files / 694 tests pass@objectstack/service-i18n— 5 files / 63 tests passcheck:api-surface— regenerated (0 breaking, 1 added), re-verified cleancheck:docs— 250 generated files in sync, unchanged@objectstack/clientbuilds clean — itsGetFieldLabelsResponsetype already described this shapeBreaking
labels[field]is now{ label, help?, options? }rather than a string. No consumer in this repo or objectui read it — objectui never calls the route, and in-repo use is the SDK method plus URL-shape tests — so the practical blast radius is nil, and this is the cheap moment to align it.Generated by Claude Code