Skip to content

fix(service-analytics): a dataset label authored as an inline locale map reaches the wire resolved (#6761) - #6951

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-6761-dataset-label-resolve
Aug 9, 2026
Merged

fix(service-analytics): a dataset label authored as an inline locale map reaches the wire resolved (#6761)#6951
os-project-manager merged 1 commit into
mainfrom
claude/issue-6761-dataset-label-resolve

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Closes #6761

The services half of maintainer ruling B (2026-08-08). The contract half — the shared I18nLabel to string resolver — landed as #6765 / PR #6901; this PR is only its consumption, and deliberately implements no resolver of its own.

The defect

I18nLabelSchema has 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:

dataset declares fields[] carried, before after
label: 'Owner' label: 'Owner' label: 'Owner' (unchanged)
label: { en: 'Owner', 'zh-CN': '负责人' } (no label key) label: '负责人' at zh-CN, 'Owner' at en
(no label) (no label key) (no label key) (unchanged)

One layer earlier, dataset-compiler substituted the machine name for the same map (typeof d.label === 'string' ? d.label : d.name), so /analytics/meta additionally published title: '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 resolveI18nLabel in packages/spec/src/ui/i18n-label-resolver.ts, exported from @objectstack/spec/ui:

resolveI18nLabel(label: I18nLabel | undefined, locale: string | undefined): string | undefined

Not the candidate signature that card's body sketched: there is no fallback parameter (the author's note says ?? fallback at 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 is undefined, not '' — chosen precisely for producers like this one, whose downstream enrichment is guarded by if (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-pickLocalized test did land: packages/spec/src/ui/i18n-label-resolver.test.ts carries a verbatim copy of objectui's pickLocalized as a fixture and asserts resolveI18nLabel(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[].label stays string | undefined on both ends (packages/spec/src/contracts/analytics-service.ts, objectui's DatasetResultField). This PR resolves to a string; it does not widen the contract (that was option C, rejected). Every case in the new test asserts typeof label === 'string', so a raw map cannot reach objectui's headerLabel — which feeds the value into fieldLabel(...) as a plain string with no pickLocalized on that path — and render as [object Object].

Which locale each site uses

site locale why
queryDataset measure enrichment ExecutionContext.locale the per-request BCP-47 tag resolveExecutionContext derives from the caller's Accept-Language, falling back to the workspace localization setting
queryDataset dimension enrichment the same, via one hoisted requestLocale both sites are in one method, so a single response cannot mix two audiences
dataset-compiler (3 sites) noneREGISTRY_LOCALE, the resolver's documented nullish answer en see below

The 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: 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; the route calls it without one). Baking the request locale there would let one zh-CN query leave a Chinese-labelled cube behind and make /analytics/meta answer whoever queried last. A test pins that it does not.

What the unresolvable case writes, and why it is safe against the f.label == null guard

This 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. resolveI18nLabel answering undefined means "nothing was picked" (an absent label, or a map with no usable entry), and both enrichment sites write only when the answer is not undefined. No key is invented, no null is 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.label and Dimension.label are required z.string() in analytics.zod.ts, so an unresolvable label must still produce some string; ?? d.name keeps exactly what this compiler already wrote. That fallback cannot pre-empt a document-sourced label downstream because a cube label never reaches AnalyticsResult.fields[] — verified by reading all four producers of that shape:

So the enrichment sites still observe f.label == null and write the locale-resolved label over nothing. (Cube.title is optional, 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:

Tests  12 failed | 7 passed (19)

AssertionError: expected '(no label key)' to be '负责人' // Object.is equality
 ~ src/__tests__/dataset-i18n-label-resolution.test.ts:182:26
     expect(cols.owner).toBe('负责人');

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 via describe.each: map resolved at the requested locale; the same map at en; a bare base language resolving to its region-qualified sibling (zh to zh-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's en. Three more cover the /analytics/meta face, including the registry-independence pin.

pnpm --filter @objectstack/service-analytics test
  Test Files  70 passed (70)
       Tests  1434 passed (1434)

pnpm --filter @objectstack/rest test
  Test Files  72 passed (72)
       Tests  1131 passed (1131)

pnpm --filter @objectstack/runtime test
  Test Files  115 passed (115)
       Tests  1743 passed (1743)

pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck
  packages/rest typecheck: Done
  packages/runtime typecheck: Done

npx eslint --no-inline-config (the three touched files)   exit 0
node scripts/check-nul-bytes.mjs                          OK (6442 files)

@objectstack/service-analytics has no typecheck script (it carries a measured DEBT entry of 10). tsc --noEmit -p tsconfig.json re-measured 10 on this branch — all pre-existing, in measure-source-field-gate.test.ts / objectql-timedimension-projection.test.ts / an unused import in analytics-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

… 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
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 6:14am

Request Review

@github-actions github-actions Bot added the size/m label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/packages.mdx (via @objectstack/service-analytics)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 9, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 9, 2026 06:41
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit ab54608 Aug 9, 2026
26 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-6761-dataset-label-resolve branch August 9, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants