diff --git a/.changeset/dataset-i18n-label-resolution.md b/.changeset/dataset-i18n-label-resolution.md new file mode 100644 index 0000000000..70f9bece03 --- /dev/null +++ b/.changeset/dataset-i18n-label-resolution.md @@ -0,0 +1,58 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(service-analytics): a dataset `label` written as an inline locale map reaches the wire resolved, instead of being dropped (#6761) + +`I18nLabelSchema` has authorized two forms of a display label since #5728: a +plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`. The +analytics producer only understood the first one, so a dataset written the way +the schema documents came back with **no label at all**: + +| dataset declares | `fields[]` carried, before | +|---|---| +| `label: 'Owner'` | `label: 'Owner'` | +| `label: { en: 'Owner', 'zh-CN': '负责人' }` | *(no `label` key)* | +| *(no label)* | *(no `label` key)* | + +Measured identically on both strategies. All three renderers that read +`fields[].label` first — `DatasetWidget`, `DatasetPreview`, +`DatasetReportRenderer` — then fell back to humanizing the raw key, so a Chinese +deployment authoring exactly what the spec documents got English-ish machine +names for its column headers. + +One layer earlier, `dataset-compiler` substituted the machine **name** for the +same map (`typeof d.label === 'string' ? d.label : d.name`), which additionally +made `/analytics/meta` publish `title: 'owner'` as a *display title* — a face +that lied rather than one that was merely bare. + +Both are fixed by calling the shared `I18nLabel → string` resolver +(`resolveI18nLabel`, `@objectstack/spec`, #6765), which is pinned in its own +package to rule parity with objectui's `pickLocalized`. Nothing is +re-implemented here: the maintainer's ruling on #6761 chose one shared resolver +precisely so the two ends cannot answer the same authored map differently. + +**The wire is unchanged.** `AnalyticsResult.fields[].label` is still +`string | undefined` on both ends — this resolves *to* a string rather than +widening the contract, so no consumer changes and no map can reach a renderer +that would print `[object Object]`. + +**Which locale each site uses:** + +* `queryDataset`'s two field-enrichment sites resolve at + `ExecutionContext.locale` — the per-request BCP-47 tag derived from the + caller's `Accept-Language`, falling back to the workspace `localization` + setting. Both sites read one hoisted value, so a single response cannot mix + two audiences. +* `dataset-compiler` resolves with **no** locale, i.e. the resolver's documented + nullish answer `en`. A compiled Cube is a registry artifact shared by every + later reader, and `getMeta()` — the `/analytics/meta` face — takes no + execution context at all; baking a request locale there would make + `/analytics/meta` answer whoever queried last. + +**Nothing is invented on a miss.** A label the resolver cannot resolve (an +absent label, or an empty map) writes no `label` key on the wire at all — a +placeholder would permanently pre-empt the real label under the downstream +`if (field.label == null)` guard. In the compiler, where `Metric.label` / +`Dimension.label` are required strings, the machine-name fallback is unchanged +from before; it never reaches `fields[]`, so it cannot pre-empt anything either. diff --git a/packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts b/packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts new file mode 100644 index 0000000000..e182150121 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6761 — a dataset dimension/measure `label` written as an inline locale map + * must reach the wire as a **resolved string**, not be dropped and not be + * replaced by the machine name. + * + * `I18nLabelSchema` has authorized two forms of a display label since #5728: a + * plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`. + * Every producer in this service tested `typeof label === 'string'` and dropped + * anything else, so a dataset written the way the schema documents shipped: + * + * ``` + * label: 'Owner' → fields[] carries label: 'Owner' ✅ + * label: { en: 'Owner', 'zh-CN': '负责人' } → fields[] carries NO label ❌ + * (no label declared) → fields[] carries NO label ✅ (unchanged) + * ``` + * + * measured identically on both strategies at `origin/main`. One layer up, + * `dataset-compiler` substituted the machine NAME for the same map, so + * `/analytics/meta` additionally published `title: 'owner'` as a display title — + * a face that lies rather than one that is merely bare. + * + * ## What resolves it, and why it is imported rather than written here + * + * `resolveI18nLabel` (`@objectstack/spec/ui`, #6765) — the one shared + * `I18nLabel → string` resolver, pinned in its own package to rule parity with + * objectui's `pickLocalized`. Maintainer ruling B (#6761, 2026-08-08) chose a + * shared resolver over a private twin inside this service precisely so the two + * ends cannot answer the same authored map differently. These tests therefore + * assert *that this service asks the resolver*, and assert the resolver's own + * documented fallback rule where it applies — never a locally guessed rule. + * + * The wire is unchanged: `AnalyticsResult.fields[].label` is `string | undefined` + * on both ends (`packages/spec/src/contracts/analytics-service.ts`, objectui's + * `DatasetResultField`), and objectui's `headerLabel` feeds it into + * `fieldLabel(...)` as a plain string with no `pickLocalized` on that path — a + * raw map would render `[object Object]`. Widening the wire was option C and was + * rejected. Every case below asserts `typeof label === 'string'`. + * + * ## Which locale each site uses + * + * * **`queryDataset`'s two enrichment sites** — `ExecutionContext.locale`, the + * per-request BCP-47 tag `resolveExecutionContext` derives from the caller's + * `Accept-Language` (falling back to the workspace `localization` setting). + * Both sites are inside one method and read one hoisted `requestLocale`, so + * a single response cannot mix two audiences. + * * **`dataset-compiler`** — deliberately **no** locale (`REGISTRY_LOCALE`), + * i.e. the resolver's documented nullish answer `en`. A compiled Cube is a + * registry artifact shared by every later reader, and `getMeta()` — the + * `/analytics/meta` face — takes no execution context at all. Baking a + * request locale there would make `/analytics/meta` answer whoever queried + * last; the last describe block pins that it does not. + * + * ## Reverse verification, direction predicted BEFORE running + * + * Unhooking the resolution (restoring `typeof … === 'string'` at both + * enrichment sites and in the compiler) must turn RED exactly the cases whose + * label is a MAP, and leave GREEN every plain-string, absent-label and + * empty-map case — those 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, + * so nothing downstream can gain a finding from it. + * + * Per strategy, RED: the `zh-CN`, `en`, base-language, last-resort-limb and + * no-locale cases (5); GREEN: plain string, no label, empty map (3). Plus, on + * `/analytics/meta`: RED the resolved-title and no-locale-leak cases (2), GREEN + * the plain-string/machine-name-fallback case (1). + * + * **Predicted 12 red / 7 green. Measured exactly that** — the run is quoted in + * the PR body. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; + +// ── the fixture ───────────────────────────────────────────────────────────── + +/** + * One dataset carrying every label shape the schema authorizes, so a single + * selection describes all of them in one response: + * + * * `owner` / `opp_count` — inline map with both `en` and `zh-CN`: the shape + * the defect dropped, and the one the three renderers are waiting for. + * * `stage` / `total_amount` — plain string: the control that must not move. + * * `lead_source` / `bare_count` — no label at all: the control that must stay + * key-less (an invented `label` would be worse than none — see #5537's note + * on descriptors describing columns rather than minting them). + * * `region` — a map that names NEITHER the requested locale nor `en`: the + * resolver's last-resort limb, asserted as the resolver's rule. + * * `blank` — an empty map: the only in-contract input on which the resolver + * misses entirely, so it pins "a miss writes nothing". + * + * The dataset's own `label` is a map too, which is what `/analytics/meta` + * published as `title: 'ownership'`. + */ +const dataset = DatasetSchema.parse({ + name: 'ownership', + label: { en: 'Ownership', 'zh-CN': '归属' }, + object: 'opportunity', + include: [], + dimensions: [ + { name: 'owner', field: 'owner_id', type: 'string', label: { en: 'Owner', 'zh-CN': '负责人' } }, + { name: 'stage', field: 'stage', type: 'string', label: 'Stage' }, + { name: 'lead_source', field: 'lead_source', type: 'string' }, + { name: 'region', field: 'region', type: 'string', label: { 'ja-JP': '地域' } }, + { name: 'blank', field: 'blank', type: 'string', label: {} }, + ], + measures: [ + { name: 'opp_count', aggregate: 'count', label: { en: 'Opportunities', 'zh-CN': '商机数' } }, + { name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount' }, + { name: 'bare_count', aggregate: 'count' }, + ], +}); + +const ALL_DIMENSIONS = ['owner', 'stage', 'lead_source', 'region', 'blank']; +const ALL_MEASURES = ['opp_count', 'total_amount', 'bare_count']; + +/** Every field descriptor keyed by name, with `label` present only when written. */ +function labels(fields: { name: string; label?: string }[]): Record { + const out: Record = {}; + for (const f of fields) { + // The wire carries a RESOLVED STRING or nothing. A map that reached this + // point would render `[object Object]` in every consumer. + if (f.label !== undefined) expect(typeof f.label).toBe('string'); + out[f.name] = 'label' in f && f.label !== undefined ? f.label : '(no label key)'; + } + return out; +} + +// ── the two strategies ────────────────────────────────────────────────────── + +/** NativeSQLStrategy — the raw-SQL path. */ +function sqlService() { + return new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [ + { owner: 'usr_1', stage: 'open', lead_source: 'web', region: 'NA', blank: 'b', opp_count: 2, total_amount: 170, bare_count: 2 }, + ], + }); +} + +/** ObjectQLStrategy — the aggregate-bridge path. */ +function aggregateService() { + return new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: string, options: Record) => { + const groupBy = (options.groupBy ?? []) as Array; + const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>; + const row: Record = {}; + for (const g of groupBy) row[typeof g === 'string' ? g : g.field] = 'x'; + for (const a of aggregations) row[a.alias] = 1; + return [row]; + }, + }); +} + +const STRATEGIES: [string, () => AnalyticsService][] = [ + ['NativeSQLStrategy', sqlService], + ['ObjectQLStrategy', aggregateService], +]; + +async function describeColumns(svc: AnalyticsService, context?: ExecutionContext) { + const result = await svc.queryDataset( + dataset, + { dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES }, + context, + ); + return labels(result.fields); +} + +// ── the wire ──────────────────────────────────────────────────────────────── + +describe.each(STRATEGIES)( + '#6761 — dataset field labels on the wire (%s)', + (_name, service) => { + it('resolves an inline locale map to the REQUESTED locale (zh-CN)', async () => { + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); + // The defect: both of these were absent entirely before this change. + expect(cols.owner).toBe('负责人'); + expect(cols.opp_count).toBe('商机数'); + }); + + it('resolves the same map to `en` for an English request', async () => { + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'en-US' } as ExecutionContext); + // `en-US` misses the exact tag and hits the base-language limb (`en`). + expect(cols.owner).toBe('Owner'); + expect(cols.opp_count).toBe('Opportunities'); + }); + + it('resolves a bare base language to its region-qualified sibling (`zh` → `zh-CN`)', async () => { + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh' } as ExecutionContext); + // Neither `zh` (limb 2) nor an exact `zh` key (limb 1) exists; limb 3 + // takes the first region-qualified sibling sharing the base. + expect(cols.owner).toBe('负责人'); + expect(cols.opp_count).toBe('商机数'); + }); + + it('leaves a plain-string label exactly as authored', async () => { + const zh = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); + const en = await describeColumns(service(), { tenantId: 'org_A', locale: 'en' } as ExecutionContext); + // A string is already the answer — the locale cannot change it. + expect(zh.stage).toBe('Stage'); + expect(zh.total_amount).toBe('Total Amount'); + expect(en.stage).toBe('Stage'); + expect(en.total_amount).toBe('Total Amount'); + }); + + it('invents no `label` key when the dataset declares none', async () => { + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); + // Not `null`, not `''`, not the machine name — the key is simply absent, + // exactly as before this change. + expect(cols.lead_source).toBe('(no label key)'); + expect(cols.bare_count).toBe('(no label key)'); + }); + + it('writes nothing when the map itself resolves to nothing (empty map)', async () => { + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); + // The one in-contract input on which every limb misses. A placeholder + // here would permanently pre-empt any later label under the downstream + // `if (f.label == null)` guard (#5199 route A). + expect(cols.blank).toBe('(no label key)'); + }); + + it('follows the shipped resolver\'s last-resort limb for a map missing the requested locale', async () => { + const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext); + // `{ 'ja-JP': '地域' }` under `zh-CN` misses limbs 1–5 (exact, base, + // regional sibling, `default`, `en`) and lands on limb 6: any string in + // the map, in key insertion order. This asserts `resolveI18nLabel`'s + // documented rule ("a label in the wrong language still beats a column + // with no header"), not a locally invented preference. + expect(cols.region).toBe('地域'); + }); + + it('falls back to the platform source language when the request states no locale', async () => { + // Anonymous requests skip localization, so `context.locale` is undefined. + // The resolver documents nullish as "no locale known" ⇒ `en`; this + // service passes it through rather than choosing its own default. + const noLocale = await describeColumns(service(), { tenantId: 'org_A' } as ExecutionContext); + const noContext = await describeColumns(service()); + expect(noLocale.owner).toBe('Owner'); + expect(noLocale.opp_count).toBe('Opportunities'); + expect(noContext.owner).toBe('Owner'); + expect(noContext.opp_count).toBe('Opportunities'); + }); + }, +); + +// ── the `/analytics/meta` face ────────────────────────────────────────────── + +describe('#6761 — /analytics/meta no longer publishes the machine name as a display title', () => { + /** `getMeta` reduced to `{ name → title }` for the one cube under test. */ + async function meta(svc: AnalyticsService) { + const [cube] = await svc.getMeta('ownership'); + const titles: Record = { '(cube)': cube.title }; + for (const m of cube.measures) titles[m.name] = m.title; + for (const d of cube.dimensions) titles[d.name] = d.title; + return titles; + } + + it('publishes the resolved label, not the machine name, for a map-labelled cube/dimension/measure', async () => { + const svc = sqlService(); + svc.registerDataset(dataset); + const titles = await meta(svc); + // Before: 'ownership' / 'owner' / 'opp_count' — the machine names, published + // as display titles by `typeof … === 'string' ? … : d.name`. + expect(titles['(cube)']).toBe('Ownership'); + expect(titles['ownership.owner']).toBe('Owner'); + expect(titles['ownership.opp_count']).toBe('Opportunities'); + }); + + it('leaves plain-string labels and the no-label machine-name fallback unchanged', async () => { + const svc = sqlService(); + svc.registerDataset(dataset); + const titles = await meta(svc); + expect(titles['ownership.stage']).toBe('Stage'); + expect(titles['ownership.total_amount']).toBe('Total Amount'); + // `Metric.label` / `Dimension.label` are REQUIRED strings in the Cube + // schema, so an unresolvable label must still produce one. The machine name + // is what this compiler already wrote, and it stays — the map case is the + // only one that moves. + expect(titles['ownership.lead_source']).toBe('lead_source'); + expect(titles['ownership.bare_count']).toBe('bare_count'); + expect(titles['ownership.blank']).toBe('blank'); + }); + + it('stays request-independent — a zh-CN query does not leak its locale into the registry', async () => { + const svc = sqlService(); + // `queryDataset` re-registers the cube on every call. If the compiler baked + // the request locale in, this Chinese query would leave a Chinese-labelled + // cube behind and `/analytics/meta` — which takes no execution context at + // all — would answer whoever queried last. + await svc.queryDataset( + dataset, + { dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES }, + { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext, + ); + const titles = await meta(svc); + expect(titles['(cube)']).toBe('Ownership'); + expect(titles['ownership.owner']).toBe('Owner'); + expect(titles['ownership.opp_count']).toBe('Opportunities'); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 172adadece..3bc9d86f20 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -10,6 +10,11 @@ import type { import { percentScaleOf, type Cube, type FilterCondition } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { Dataset } from '@objectstack/spec/ui'; +// [#6761] The ONE shared `I18nLabel → string` resolver (#6765, maintainer +// ruling B). Imported, never re-implemented: a private twin here is exactly the +// fork the ruling exists to prevent — it would render the same authored map +// differently from objectui's `pickLocalized` with neither end erroring. +import { resolveI18nLabel } from '@objectstack/spec/ui'; import type { Logger } from '@objectstack/spec/contracts'; import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from '@objectstack/core'; import { CubeRegistry } from './cube-registry.js'; @@ -856,6 +861,22 @@ export class AnalyticsService implements IAnalyticsService { } } + // [#6761] The audience's language for THIS request, and the only locale + // either field-label enrichment site below is entitled to use. + // + // `ExecutionContext.locale` is the BCP-47 tag `resolveExecutionContext` + // resolves per request — the caller's `Accept-Language` when it expressed a + // preference, else the workspace `localization` setting. It is the same + // context field the currency chain a few lines down already reads, so both + // display decisions in this response answer to one request identity. + // + // `undefined` (no context, or an anonymous request that skips localization) + // is passed through deliberately rather than defaulted here: the shared + // resolver documents nullish as "no locale known" and answers `en`, the + // platform's source language. Choosing a different default in this file + // would be this service disagreeing with the renderer about the same map. + const requestLocale = context?.locale; + // #3602 — every label lookup in this request (sort keys below, display // labels further down) reads the REFERENCED object, so bind that object's // own read scope to this request once, up front. @@ -1092,12 +1113,26 @@ export class AnalyticsService implements IAnalyticsService { // so presentations show "Tasks" / "$616,000" instead of the raw measure // name "task_count" / "616000". Carried on the result fields; the renderer // applies the format (it can't be baked into the numeric row value). + // + // [#6761] The label is resolved through the shared `I18nLabel → string` + // resolver, so BOTH authorized forms reach the wire: a plain string, and the + // inline locale map `I18nLabelSchema` has authorized since #5728. The old + // `typeof m.label === 'string'` test dropped the map silently — a dataset + // written the way the schema documents shipped a column with no header at + // all. The wire type is unchanged (`fields[].label?: string`, both ends): + // this resolves TO a string rather than widening the contract. if (result.fields?.length && dataset.measures?.length) { const measureByName = new Map(dataset.measures.map((m) => [m.name, m])); for (const f of result.fields) { const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, '')); if (!m) continue; - if (f.label == null && typeof m.label === 'string') f.label = m.label; + // `undefined` from the resolver means "nothing was picked" — an absent + // label, or a map with no usable entry. Nothing is written in that case: + // this enrichment describes columns, it never invents a header. + if (f.label == null) { + const label = resolveI18nLabel(m.label, requestLocale); + if (label !== undefined) f.label = label; + } if (f.format == null && m.format) f.format = m.format; // ADR-0053 currency chain. A MONETARY measure resolves its display // currency from: explicit measure `currency` → source-field @@ -1172,7 +1207,11 @@ export class AnalyticsService implements IAnalyticsService { // Result fields may be keyed by the dataset dimension NAME or the // underlying cube FIELD depending on strategy — match either. const d = dimByName.get(f.name) ?? dimByField.get(f.name); - if (d && typeof d.label === 'string') f.label = d.label; + if (!d) continue; + // [#6761] Same resolver, same locale, same "write nothing on a miss" + // rule as the measure enrichment above. + const label = resolveI18nLabel(d.label, requestLocale); + if (label !== undefined) f.label = label; } } return result; diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index b73a726382..703e6740a9 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -3,6 +3,7 @@ import type { Cube, Metric, Dimension as CubeDimension, CubeJoin } from '@objectstack/spec/data'; import { AggregationFunction } from '@objectstack/spec/data'; import type { Dataset, DatasetMeasure, DatasetDimension } from '@objectstack/spec/ui'; +import { resolveI18nLabel } from '@objectstack/spec/ui'; import type { FilterCondition } from '@objectstack/spec/data'; import { datasetInvalidError } from './dataset-refusal.js'; @@ -202,6 +203,38 @@ const MAX_JOIN_HOPS = 3; * so single-hop joins stay byte-for-byte identical. */ const joinAlias = (path: string): string => path.replace(/\./g, '__'); +/** + * [#6761] The locale this compiler resolves an inline-locale-map label at: + * **none**, i.e. the platform source language `en` per `resolveI18nLabel`'s + * documented nullish-tolerance. + * + * This is a decision, not an omission, and it is spelled as a named constant so + * it stays visible and greppable rather than reading as a forgotten argument + * (the resolver takes `locale` positionally for exactly that reason). + * + * **A compiled Cube is a REGISTRY artifact, not a response.** `registerDataset` + * writes it into `CubeRegistry` under the dataset's name, `queryDataset` + * re-registers on every call, and `getMeta()` — the `/analytics/meta` face — + * reads it back with **no execution context at all** (`IAnalyticsService.getMeta` + * takes `cubeName?` and nothing else, and the route calls it without one). So + * the request locale must NOT be baked in here: one `zh-CN` query would leave a + * Chinese-labelled cube in a registry every later reader shares, and + * `/analytics/meta` would answer whoever queried last. Request-scoped + * resolution belongs where a request is in hand — `queryDataset`'s two field + * enrichment sites, which read `context.locale`. + * + * **The fallback stays `d.name`, and that is safe against the `f.label == null` + * guard** (#5199 route A / #6761). `Metric.label` and `Dimension.label` are + * REQUIRED strings in `analytics.zod.ts`, so an unresolvable label must still + * produce one, and the machine name is what this compiler already wrote. It + * cannot pre-empt the document-sourced label downstream because a cube label + * never reaches `AnalyticsResult.fields[]`: both strategies' `buildFieldMeta`, + * the draft preview evaluator, and `DatasetExecutor`'s #5537 descriptor + * adoption all emit `{ name, type }` only. The enrichment sites therefore still + * see `f.label == null` and write the locale-resolved label over nothing. + */ +const REGISTRY_LOCALE: string | undefined = undefined; + export function compileDataset( dataset: Dataset, resolver?: RelationshipResolver, @@ -373,7 +406,11 @@ export function compileDataset( assertDeclared(d.field, 'dimension', d.name); const dim: CubeDimension = { name: d.name, - label: typeof d.label === 'string' ? d.label : d.name, + // [#6761] An inline locale map is a label, not a missing one. Before this, + // the `typeof === 'string'` test dropped the map and substituted the + // machine name, which `/analytics/meta` then published as a display title + // (`title: 'owner'` for a dimension labelled `{ en: 'Owner', … }`). + label: resolveI18nLabel(d.label, REGISTRY_LOCALE) ?? d.name, type: dimensionType(d), sql: d.field, }; @@ -398,7 +435,8 @@ export function compileDataset( if (m.field) assertDeclared(m.field, 'measure', m.name); const metric: Metric = { name: m.name, - label: typeof m.label === 'string' ? m.label : m.name, + // [#6761] Same as the dimension label above — see {@link REGISTRY_LOCALE}. + label: resolveI18nLabel(m.label, REGISTRY_LOCALE) ?? m.name, type: aggregateToMetricType(m), // `count` with no field aggregates over rows (*). sql: m.field ?? '*', @@ -410,7 +448,10 @@ export function compileDataset( const cube: Cube = { name: dataset.name, - title: typeof dataset.label === 'string' ? dataset.label : dataset.name, + // [#6761] The cube's own display title, same rule. `Cube.title` is optional + // in the schema, but an absent dataset label already produced the machine + // name here and that is not what this card changes — only the map case moves. + title: resolveI18nLabel(dataset.label, REGISTRY_LOCALE) ?? dataset.name, sql: dataset.object, measures, dimensions,