fix(service-analytics): a dataset label authored as an inline locale map reaches the wire resolved (#6761) - #6951
Merged
Conversation
… string (#6761) A dataset dimension/measure `label` authored as an inline locale map (`{ en: 'Owner', 'zh-CN': '负责人' }` — authorized by `I18nLabelSchema` since #5728) was dropped entirely from `AnalyticsResult.fields[]`, and replaced by the machine NAME one layer earlier in `dataset-compiler`, which made `/analytics/meta` publish `title: 'owner'` as a display title. Both producers now call the shared `I18nLabel -> string` resolver (`resolveI18nLabel`, `@objectstack/spec`, #6765) rather than testing `typeof label === 'string'`. Per maintainer ruling B on #6761 the resolver is imported, never re-implemented: a private twin here would answer the same authored map differently from objectui's `pickLocalized` with neither end erroring. The wire is unchanged — `fields[].label` stays `string | undefined` on both ends; this resolves TO a string rather than widening the contract. Locale per site: - `queryDataset`'s two field-enrichment sites resolve at `ExecutionContext.locale` (per-request `Accept-Language`, workspace `localization` fallback), read once into a hoisted `requestLocale` so one response cannot mix two audiences. - `dataset-compiler` resolves with NO locale (`REGISTRY_LOCALE`), i.e. the resolver's documented nullish answer `en`. A compiled Cube is a registry artifact and `getMeta()` takes no execution context, so baking a request locale would make `/analytics/meta` answer whoever queried last. Nothing is invented on a miss: an absent label or an empty map writes no `label` key at all, because a placeholder would permanently pre-empt the real label under the downstream `if (field.label == null)` guard (#5199 route A). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-project-manager
marked this pull request as ready for review
August 9, 2026 06:41
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 #6761
The services half of maintainer ruling B (2026-08-08). The contract half — the shared
I18nLabeltostringresolver — landed as #6765 / PR #6901; this PR is only its consumption, and deliberately implements no resolver of its own.The defect
I18nLabelSchemahas authorized two forms of a display label since #5728: a plain string, and an inline locale map. The analytics producer understood only the first, so a dataset written the way the schema documents shipped a column with no header:fields[]carried, beforelabel: 'Owner'label: 'Owner'label: 'Owner'(unchanged)label: { en: 'Owner', 'zh-CN': '负责人' }labelkey)label: '负责人'atzh-CN,'Owner'atenlabelkey)labelkey) (unchanged)One layer earlier,
dataset-compilersubstituted the machine name for the same map (typeof d.label === 'string' ? d.label : d.name), so/analytics/metaadditionally publishedtitle: 'owner'as a display title — a face that lied rather than one that was merely bare. That is fixed here too.Re-verified at the merged ref before implementing (#6465)
#6765 shipped
resolveI18nLabelinpackages/spec/src/ui/i18n-label-resolver.ts, exported from@objectstack/spec/ui:Not the candidate signature that card's body sketched: there is no
fallbackparameter (the author's note says?? fallbackat the call site is the same expression and keeps the miss decision visible where writing the wrong thing does the damage), and the miss answer isundefined, not''— chosen precisely for producers like this one, whose downstream enrichment is guarded byif (field.label == null). Fallback semantics are a six-limb chain (exact tag, base language, first region-qualified sibling,default,en, first string value), case-sensitive, own-properties-only. The parity-with-pickLocalizedtest did land:packages/spec/src/ui/i18n-label-resolver.test.tscarries a verbatim copy of objectui'spickLocalizedas a fixture and assertsresolveI18nLabel(l, loc) ?? '' === pickLocalized(l, loc)over a shared vector table.The delivered resolver expresses everything the two enrichment sites need, so no second resolver was written — which is the whole point of ruling B.
The wire is unchanged
AnalyticsResult.fields[].labelstaysstring | undefinedon both ends (packages/spec/src/contracts/analytics-service.ts, objectui'sDatasetResultField). This PR resolves to a string; it does not widen the contract (that was option C, rejected). Every case in the new test assertstypeof label === 'string', so a raw map cannot reach objectui'sheaderLabel— which feeds the value intofieldLabel(...)as a plain string with nopickLocalizedon that path — and render as[object Object].Which locale each site uses
queryDatasetmeasure enrichmentExecutionContext.localeresolveExecutionContextderives from the caller'sAccept-Language, falling back to the workspacelocalizationsettingqueryDatasetdimension enrichmentrequestLocaledataset-compiler(3 sites)REGISTRY_LOCALE, the resolver's documented nullish answerenThe compiler's choice is a decision, not an omission, and it is spelled as a named constant so it stays greppable rather than reading as a forgotten argument. A compiled Cube is a registry artifact:
registerDatasetwrites it intoCubeRegistryunder the dataset's name,queryDatasetre-registers on every call, andgetMeta()— the/analytics/metaface — reads it back with no execution context at all (IAnalyticsService.getMetatakescubeName?and nothing else; the route calls it without one). Baking the request locale there would let onezh-CNquery leave a Chinese-labelled cube behind and make/analytics/metaanswer whoever queried last. A test pins that it does not.What the unresolvable case writes, and why it is safe against the
f.label == nullguardThis is the trap #5199's route A was judged harmful rather than redundant, so it is answered explicitly for each of the two layers:
On the wire (
queryDataset) — nothing is written.resolveI18nLabelansweringundefinedmeans "nothing was picked" (an absent label, or a map with no usable entry), and both enrichment sites write only when the answer is notundefined. No key is invented, nonullis sprayed, no''placeholder is left. The empty-map fixture in the new test is the in-contract input that exercises this.In the compiler — the machine name, unchanged from before, and it cannot reach the guard.
Metric.labelandDimension.labelare requiredz.string()inanalytics.zod.ts, so an unresolvable label must still produce some string;?? d.namekeeps exactly what this compiler already wrote. That fallback cannot pre-empt a document-sourced label downstream because a cube label never reachesAnalyticsResult.fields[]— verified by reading all four producers of that shape:NativeSQLStrategy.buildFieldMetaemits{ name, type };ObjectQLStrategy.buildFieldMetaemits{ name, type };preview-evaluatoremits{ name, type };DatasetExecutor's [17.0-rc2验收] analytics: 带 measure-scoped filter / derived 度量的 dataset 查询,响应 fields 丢失维度描述符 → 表头回退成原始维度名(如 "owner" 而非 "Owner") #5537 descriptor adoption copies entries from a sub-result, i.e. from one of the three above.So the enrichment sites still observe
f.label == nulland write the locale-resolved label over nothing. (Cube.titleisoptional, but an absent dataset label already produced the machine name there and this PR does not change that — only the map case moves.)Reverse verification — direction predicted before running
Predicted, and written into the test file's header before the run: unhooking the resolution (restoring
typeof ... === 'string'at both enrichment sites and in the compiler) turns red exactly the map-labelled cases and leaves green every plain-string, absent-label and empty-map case, which pin the behaviour this change converges ON rather than changes. Ordinary direction, no inversion and no count movement — the change adds resolutions that were absent, narrows no rule and removes no??limb.Per strategy: red = zh-CN, en, base-language, last-resort-limb, no-locale (5); green = plain string, no label, empty map (3). Plus on
/analytics/meta: red = resolved title, no-locale-leak (2); green = plain-string / machine-name fallback (1).Predicted 12 red / 7 green. Observed:
Exact match, including the identity of the red set. Restored, and 19/19 green again.
Tests
New file
packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts— 19 cases. Both strategies (NativeSQLStrategy,ObjectQLStrategy) run the same 8 wire cases viadescribe.each: map resolved at the requested locale; the same map aten; a bare base language resolving to its region-qualified sibling (zhtozh-CN); plain string untouched; no label declared leaves no key; an empty map leaves no key; a map missing the requested locale follows the shipped resolver's last-resort limb (asserted as the resolver's documented rule, not a locally guessed preference); and no locale on the context falling through to the resolver'sen. Three more cover the/analytics/metaface, including the registry-independence pin.@objectstack/service-analyticshas notypecheckscript (it carries a measured DEBT entry of 10).tsc --noEmit -p tsconfig.jsonre-measured 10 on this branch — all pre-existing, inmeasure-source-field-gate.test.ts/objectql-timedimension-projection.test.ts/ an unused import inanalytics-service.test.ts, none in the files this PR touches. The ratchet is unmoved.One
.changeset/dataset-i18n-label-resolution.md(patch,@objectstack/service-analytics) — the change is user-visible.Generated with Claude Code
Generated by Claude Code