Skip to content

Commit ab54608

Browse files
fix(service-analytics): resolve inline-locale-map dataset labels to a string (#6761) (#6951)
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). Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd6572b commit ab54608

4 files changed

Lines changed: 448 additions & 5 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): a dataset `label` written as an inline locale map reaches the wire resolved, instead of being dropped (#6761)
6+
7+
`I18nLabelSchema` has authorized two forms of a display label since #5728: a
8+
plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`. The
9+
analytics producer only understood the first one, so a dataset written the way
10+
the schema documents came back with **no label at all**:
11+
12+
| dataset declares | `fields[]` carried, before |
13+
|---|---|
14+
| `label: 'Owner'` | `label: 'Owner'` |
15+
| `label: { en: 'Owner', 'zh-CN': '负责人' }` | *(no `label` key)* |
16+
| *(no label)* | *(no `label` key)* |
17+
18+
Measured identically on both strategies. All three renderers that read
19+
`fields[].label` first — `DatasetWidget`, `DatasetPreview`,
20+
`DatasetReportRenderer` — then fell back to humanizing the raw key, so a Chinese
21+
deployment authoring exactly what the spec documents got English-ish machine
22+
names for its column headers.
23+
24+
One layer earlier, `dataset-compiler` substituted the machine **name** for the
25+
same map (`typeof d.label === 'string' ? d.label : d.name`), which additionally
26+
made `/analytics/meta` publish `title: 'owner'` as a *display title* — a face
27+
that lied rather than one that was merely bare.
28+
29+
Both are fixed by calling the shared `I18nLabel → string` resolver
30+
(`resolveI18nLabel`, `@objectstack/spec`, #6765), which is pinned in its own
31+
package to rule parity with objectui's `pickLocalized`. Nothing is
32+
re-implemented here: the maintainer's ruling on #6761 chose one shared resolver
33+
precisely so the two ends cannot answer the same authored map differently.
34+
35+
**The wire is unchanged.** `AnalyticsResult.fields[].label` is still
36+
`string | undefined` on both ends — this resolves *to* a string rather than
37+
widening the contract, so no consumer changes and no map can reach a renderer
38+
that would print `[object Object]`.
39+
40+
**Which locale each site uses:**
41+
42+
* `queryDataset`'s two field-enrichment sites resolve at
43+
`ExecutionContext.locale` — the per-request BCP-47 tag derived from the
44+
caller's `Accept-Language`, falling back to the workspace `localization`
45+
setting. Both sites read one hoisted value, so a single response cannot mix
46+
two audiences.
47+
* `dataset-compiler` resolves with **no** locale, i.e. the resolver's documented
48+
nullish answer `en`. A compiled Cube is a registry artifact shared by every
49+
later reader, and `getMeta()` — the `/analytics/meta` face — takes no
50+
execution context at all; baking a request locale there would make
51+
`/analytics/meta` answer whoever queried last.
52+
53+
**Nothing is invented on a miss.** A label the resolver cannot resolve (an
54+
absent label, or an empty map) writes no `label` key on the wire at all — a
55+
placeholder would permanently pre-empt the real label under the downstream
56+
`if (field.label == null)` guard. In the compiler, where `Metric.label` /
57+
`Dimension.label` are required strings, the machine-name fallback is unchanged
58+
from before; it never reaches `fields[]`, so it cannot pre-empt anything either.
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #6761 — a dataset dimension/measure `label` written as an inline locale map
5+
* must reach the wire as a **resolved string**, not be dropped and not be
6+
* replaced by the machine name.
7+
*
8+
* `I18nLabelSchema` has authorized two forms of a display label since #5728: a
9+
* plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`.
10+
* Every producer in this service tested `typeof label === 'string'` and dropped
11+
* anything else, so a dataset written the way the schema documents shipped:
12+
*
13+
* ```
14+
* label: 'Owner' → fields[] carries label: 'Owner' ✅
15+
* label: { en: 'Owner', 'zh-CN': '负责人' } → fields[] carries NO label ❌
16+
* (no label declared) → fields[] carries NO label ✅ (unchanged)
17+
* ```
18+
*
19+
* measured identically on both strategies at `origin/main`. One layer up,
20+
* `dataset-compiler` substituted the machine NAME for the same map, so
21+
* `/analytics/meta` additionally published `title: 'owner'` as a display title —
22+
* a face that lies rather than one that is merely bare.
23+
*
24+
* ## What resolves it, and why it is imported rather than written here
25+
*
26+
* `resolveI18nLabel` (`@objectstack/spec/ui`, #6765) — the one shared
27+
* `I18nLabel → string` resolver, pinned in its own package to rule parity with
28+
* objectui's `pickLocalized`. Maintainer ruling B (#6761, 2026-08-08) chose a
29+
* shared resolver over a private twin inside this service precisely so the two
30+
* ends cannot answer the same authored map differently. These tests therefore
31+
* assert *that this service asks the resolver*, and assert the resolver's own
32+
* documented fallback rule where it applies — never a locally guessed rule.
33+
*
34+
* The wire is unchanged: `AnalyticsResult.fields[].label` is `string | undefined`
35+
* on both ends (`packages/spec/src/contracts/analytics-service.ts`, objectui's
36+
* `DatasetResultField`), and objectui's `headerLabel` feeds it into
37+
* `fieldLabel(...)` as a plain string with no `pickLocalized` on that path — a
38+
* raw map would render `[object Object]`. Widening the wire was option C and was
39+
* rejected. Every case below asserts `typeof label === 'string'`.
40+
*
41+
* ## Which locale each site uses
42+
*
43+
* * **`queryDataset`'s two enrichment sites** — `ExecutionContext.locale`, the
44+
* per-request BCP-47 tag `resolveExecutionContext` derives from the caller's
45+
* `Accept-Language` (falling back to the workspace `localization` setting).
46+
* Both sites are inside one method and read one hoisted `requestLocale`, so
47+
* a single response cannot mix two audiences.
48+
* * **`dataset-compiler`** — deliberately **no** locale (`REGISTRY_LOCALE`),
49+
* i.e. the resolver's documented nullish answer `en`. A compiled Cube is a
50+
* registry artifact shared by every later reader, and `getMeta()` — the
51+
* `/analytics/meta` face — takes no execution context at all. Baking a
52+
* request locale there would make `/analytics/meta` answer whoever queried
53+
* last; the last describe block pins that it does not.
54+
*
55+
* ## Reverse verification, direction predicted BEFORE running
56+
*
57+
* Unhooking the resolution (restoring `typeof … === 'string'` at both
58+
* enrichment sites and in the compiler) must turn RED exactly the cases whose
59+
* label is a MAP, and leave GREEN every plain-string, absent-label and
60+
* empty-map case — those pin the behaviour this change converges ON rather than
61+
* changes. Ordinary direction, no inversion and no count movement: the change
62+
* ADDS resolutions that were absent, narrows no rule and removes no `??` limb,
63+
* so nothing downstream can gain a finding from it.
64+
*
65+
* Per strategy, RED: the `zh-CN`, `en`, base-language, last-resort-limb and
66+
* no-locale cases (5); GREEN: plain string, no label, empty map (3). Plus, on
67+
* `/analytics/meta`: RED the resolved-title and no-locale-leak cases (2), GREEN
68+
* the plain-string/machine-name-fallback case (1).
69+
*
70+
* **Predicted 12 red / 7 green. Measured exactly that** — the run is quoted in
71+
* the PR body.
72+
*/
73+
74+
import { describe, it, expect } from 'vitest';
75+
import { DatasetSchema } from '@objectstack/spec/ui';
76+
import type { ExecutionContext } from '@objectstack/spec/kernel';
77+
import { AnalyticsService } from '../analytics-service.js';
78+
79+
// ── the fixture ─────────────────────────────────────────────────────────────
80+
81+
/**
82+
* One dataset carrying every label shape the schema authorizes, so a single
83+
* selection describes all of them in one response:
84+
*
85+
* * `owner` / `opp_count` — inline map with both `en` and `zh-CN`: the shape
86+
* the defect dropped, and the one the three renderers are waiting for.
87+
* * `stage` / `total_amount` — plain string: the control that must not move.
88+
* * `lead_source` / `bare_count` — no label at all: the control that must stay
89+
* key-less (an invented `label` would be worse than none — see #5537's note
90+
* on descriptors describing columns rather than minting them).
91+
* * `region` — a map that names NEITHER the requested locale nor `en`: the
92+
* resolver's last-resort limb, asserted as the resolver's rule.
93+
* * `blank` — an empty map: the only in-contract input on which the resolver
94+
* misses entirely, so it pins "a miss writes nothing".
95+
*
96+
* The dataset's own `label` is a map too, which is what `/analytics/meta`
97+
* published as `title: 'ownership'`.
98+
*/
99+
const dataset = DatasetSchema.parse({
100+
name: 'ownership',
101+
label: { en: 'Ownership', 'zh-CN': '归属' },
102+
object: 'opportunity',
103+
include: [],
104+
dimensions: [
105+
{ name: 'owner', field: 'owner_id', type: 'string', label: { en: 'Owner', 'zh-CN': '负责人' } },
106+
{ name: 'stage', field: 'stage', type: 'string', label: 'Stage' },
107+
{ name: 'lead_source', field: 'lead_source', type: 'string' },
108+
{ name: 'region', field: 'region', type: 'string', label: { 'ja-JP': '地域' } },
109+
{ name: 'blank', field: 'blank', type: 'string', label: {} },
110+
],
111+
measures: [
112+
{ name: 'opp_count', aggregate: 'count', label: { en: 'Opportunities', 'zh-CN': '商机数' } },
113+
{ name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount' },
114+
{ name: 'bare_count', aggregate: 'count' },
115+
],
116+
});
117+
118+
const ALL_DIMENSIONS = ['owner', 'stage', 'lead_source', 'region', 'blank'];
119+
const ALL_MEASURES = ['opp_count', 'total_amount', 'bare_count'];
120+
121+
/** Every field descriptor keyed by name, with `label` present only when written. */
122+
function labels(fields: { name: string; label?: string }[]): Record<string, unknown> {
123+
const out: Record<string, unknown> = {};
124+
for (const f of fields) {
125+
// The wire carries a RESOLVED STRING or nothing. A map that reached this
126+
// point would render `[object Object]` in every consumer.
127+
if (f.label !== undefined) expect(typeof f.label).toBe('string');
128+
out[f.name] = 'label' in f && f.label !== undefined ? f.label : '(no label key)';
129+
}
130+
return out;
131+
}
132+
133+
// ── the two strategies ──────────────────────────────────────────────────────
134+
135+
/** NativeSQLStrategy — the raw-SQL path. */
136+
function sqlService() {
137+
return new AnalyticsService({
138+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
139+
executeRawSql: async () => [
140+
{ owner: 'usr_1', stage: 'open', lead_source: 'web', region: 'NA', blank: 'b', opp_count: 2, total_amount: 170, bare_count: 2 },
141+
],
142+
});
143+
}
144+
145+
/** ObjectQLStrategy — the aggregate-bridge path. */
146+
function aggregateService() {
147+
return new AnalyticsService({
148+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
149+
executeAggregate: async (_object: string, options: Record<string, unknown>) => {
150+
const groupBy = (options.groupBy ?? []) as Array<string | { field: string }>;
151+
const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>;
152+
const row: Record<string, unknown> = {};
153+
for (const g of groupBy) row[typeof g === 'string' ? g : g.field] = 'x';
154+
for (const a of aggregations) row[a.alias] = 1;
155+
return [row];
156+
},
157+
});
158+
}
159+
160+
const STRATEGIES: [string, () => AnalyticsService][] = [
161+
['NativeSQLStrategy', sqlService],
162+
['ObjectQLStrategy', aggregateService],
163+
];
164+
165+
async function describeColumns(svc: AnalyticsService, context?: ExecutionContext) {
166+
const result = await svc.queryDataset(
167+
dataset,
168+
{ dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES },
169+
context,
170+
);
171+
return labels(result.fields);
172+
}
173+
174+
// ── the wire ────────────────────────────────────────────────────────────────
175+
176+
describe.each(STRATEGIES)(
177+
'#6761 — dataset field labels on the wire (%s)',
178+
(_name, service) => {
179+
it('resolves an inline locale map to the REQUESTED locale (zh-CN)', async () => {
180+
const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
181+
// The defect: both of these were absent entirely before this change.
182+
expect(cols.owner).toBe('负责人');
183+
expect(cols.opp_count).toBe('商机数');
184+
});
185+
186+
it('resolves the same map to `en` for an English request', async () => {
187+
const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'en-US' } as ExecutionContext);
188+
// `en-US` misses the exact tag and hits the base-language limb (`en`).
189+
expect(cols.owner).toBe('Owner');
190+
expect(cols.opp_count).toBe('Opportunities');
191+
});
192+
193+
it('resolves a bare base language to its region-qualified sibling (`zh` → `zh-CN`)', async () => {
194+
const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh' } as ExecutionContext);
195+
// Neither `zh` (limb 2) nor an exact `zh` key (limb 1) exists; limb 3
196+
// takes the first region-qualified sibling sharing the base.
197+
expect(cols.owner).toBe('负责人');
198+
expect(cols.opp_count).toBe('商机数');
199+
});
200+
201+
it('leaves a plain-string label exactly as authored', async () => {
202+
const zh = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
203+
const en = await describeColumns(service(), { tenantId: 'org_A', locale: 'en' } as ExecutionContext);
204+
// A string is already the answer — the locale cannot change it.
205+
expect(zh.stage).toBe('Stage');
206+
expect(zh.total_amount).toBe('Total Amount');
207+
expect(en.stage).toBe('Stage');
208+
expect(en.total_amount).toBe('Total Amount');
209+
});
210+
211+
it('invents no `label` key when the dataset declares none', async () => {
212+
const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
213+
// Not `null`, not `''`, not the machine name — the key is simply absent,
214+
// exactly as before this change.
215+
expect(cols.lead_source).toBe('(no label key)');
216+
expect(cols.bare_count).toBe('(no label key)');
217+
});
218+
219+
it('writes nothing when the map itself resolves to nothing (empty map)', async () => {
220+
const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
221+
// The one in-contract input on which every limb misses. A placeholder
222+
// here would permanently pre-empt any later label under the downstream
223+
// `if (f.label == null)` guard (#5199 route A).
224+
expect(cols.blank).toBe('(no label key)');
225+
});
226+
227+
it('follows the shipped resolver\'s last-resort limb for a map missing the requested locale', async () => {
228+
const cols = await describeColumns(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
229+
// `{ 'ja-JP': '地域' }` under `zh-CN` misses limbs 1–5 (exact, base,
230+
// regional sibling, `default`, `en`) and lands on limb 6: any string in
231+
// the map, in key insertion order. This asserts `resolveI18nLabel`'s
232+
// documented rule ("a label in the wrong language still beats a column
233+
// with no header"), not a locally invented preference.
234+
expect(cols.region).toBe('地域');
235+
});
236+
237+
it('falls back to the platform source language when the request states no locale', async () => {
238+
// Anonymous requests skip localization, so `context.locale` is undefined.
239+
// The resolver documents nullish as "no locale known" ⇒ `en`; this
240+
// service passes it through rather than choosing its own default.
241+
const noLocale = await describeColumns(service(), { tenantId: 'org_A' } as ExecutionContext);
242+
const noContext = await describeColumns(service());
243+
expect(noLocale.owner).toBe('Owner');
244+
expect(noLocale.opp_count).toBe('Opportunities');
245+
expect(noContext.owner).toBe('Owner');
246+
expect(noContext.opp_count).toBe('Opportunities');
247+
});
248+
},
249+
);
250+
251+
// ── the `/analytics/meta` face ──────────────────────────────────────────────
252+
253+
describe('#6761 — /analytics/meta no longer publishes the machine name as a display title', () => {
254+
/** `getMeta` reduced to `{ name → title }` for the one cube under test. */
255+
async function meta(svc: AnalyticsService) {
256+
const [cube] = await svc.getMeta('ownership');
257+
const titles: Record<string, unknown> = { '(cube)': cube.title };
258+
for (const m of cube.measures) titles[m.name] = m.title;
259+
for (const d of cube.dimensions) titles[d.name] = d.title;
260+
return titles;
261+
}
262+
263+
it('publishes the resolved label, not the machine name, for a map-labelled cube/dimension/measure', async () => {
264+
const svc = sqlService();
265+
svc.registerDataset(dataset);
266+
const titles = await meta(svc);
267+
// Before: 'ownership' / 'owner' / 'opp_count' — the machine names, published
268+
// as display titles by `typeof … === 'string' ? … : d.name`.
269+
expect(titles['(cube)']).toBe('Ownership');
270+
expect(titles['ownership.owner']).toBe('Owner');
271+
expect(titles['ownership.opp_count']).toBe('Opportunities');
272+
});
273+
274+
it('leaves plain-string labels and the no-label machine-name fallback unchanged', async () => {
275+
const svc = sqlService();
276+
svc.registerDataset(dataset);
277+
const titles = await meta(svc);
278+
expect(titles['ownership.stage']).toBe('Stage');
279+
expect(titles['ownership.total_amount']).toBe('Total Amount');
280+
// `Metric.label` / `Dimension.label` are REQUIRED strings in the Cube
281+
// schema, so an unresolvable label must still produce one. The machine name
282+
// is what this compiler already wrote, and it stays — the map case is the
283+
// only one that moves.
284+
expect(titles['ownership.lead_source']).toBe('lead_source');
285+
expect(titles['ownership.bare_count']).toBe('bare_count');
286+
expect(titles['ownership.blank']).toBe('blank');
287+
});
288+
289+
it('stays request-independent — a zh-CN query does not leak its locale into the registry', async () => {
290+
const svc = sqlService();
291+
// `queryDataset` re-registers the cube on every call. If the compiler baked
292+
// the request locale in, this Chinese query would leave a Chinese-labelled
293+
// cube behind and `/analytics/meta` — which takes no execution context at
294+
// all — would answer whoever queried last.
295+
await svc.queryDataset(
296+
dataset,
297+
{ dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES },
298+
{ tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext,
299+
);
300+
const titles = await meta(svc);
301+
expect(titles['(cube)']).toBe('Ownership');
302+
expect(titles['ownership.owner']).toBe('Owner');
303+
expect(titles['ownership.opp_count']).toBe('Opportunities');
304+
});
305+
});

0 commit comments

Comments
 (0)