Skip to content

Commit 830a63a

Browse files
feat(analytics): carry a dataset dimension's nullLabel on its column descriptor, and name System Overview's null user bucket "System" (#25402)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoJ8yb43iqvYRAnady5tqTZG)_ Part of #25330 Clause-②: no Metadata-protocol: none The label half of #25330, landing 1 of 3 (seat-approved order). Ruled A (6358843349, maintainer 「同意」); the key itself landed in #25397 (`DatasetDimensionSchema.nullLabel`). This PR carries the key to the renderer and declares it on System Overview. The objectui consumer follows with a `.objectstack-sha` bump to this PR's merge commit, then a last objectstack PR flips the `nullLabel` liveness row to live and closes the card. ## Channel: the response's column descriptor, and why Measured, not assumed. objectui's `DatasetWidget` stores the dataset answer's `fields[]` and reads every column's display metadata from it (`headerLabel`, `buildChartSeries`). It never fetches the dataset document; its only metadata read is the base object's (`/api/v1/meta/object/NAME`, for option labels). So the null bucket's name rides the descriptor the renderer already reads, as `fields[].nullLabel`. The document channel would have added a new metadata read per widget beside it. That descriptor is typed by `@objectstack/spec` (objectui's `DatasetResultField` is `AnalyticsResult['fields'][number]`), so the carry needs one response member. It is response-only and nothing an author writes changes, so `Clause-②: no` and `Metadata-protocol: none` hold (seat ruling on the claim, declared on #18549). ## What changes - **`@objectstack/spec`:** `AnalyticsResult.fields[]` (`contracts/analytics-service.ts`) and its mirror `AnalyticsResultResponseSchema` (`api/analytics.zod.ts`) gain `nullLabel?: string`. The compile-time drift guard in `analytics.test.ts` keeps the two equal; a new case pins that the parse preserves the member (the schema strips undeclared keys). - **`@objectstack/service-analytics`:** `enrichResultColumns`, the column-description pass the live query and the draft-data preview share, sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`. It resolves through `resolveI18nLabel` at the request locale, exactly like `label`, so the wire carries a string and never a locale map. It is written whether or not the header was already set, and never onto a measure column whose name matches a dimension's field. Absent ⇒ no key, so an answer over a dimension without `nullLabel` is byte-identical to before. - **`@objectstack/platform-objects`:** `sys_audit_log_metrics`' `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`, every locale the platform bundles translate the board into. An inline map is the ruled route: the translation bundles have no slot for a dimension's null label. `check:i18n` is in sync with no regeneration (the extractor does not read `nullLabel`). ## Pins - `service-analytics/src/__tests__/dataset-dimension-null-label.test.ts`, on both strategies: zh-CN reads `系统`, en reads `System`, no locale reads `System`, a plain string passes as authored; a dimension without `nullLabel` and every measure column (one of them over the same `user_id` field) carry no key. - `platform-objects/src/apps/dashboards/system-overview-null-label.test.ts`: "Events by User" groups `sys_audit_log_metrics` by `user_id`, the dimension carries the map, the map names every locale `SetupAppTranslations` carries for the board, and it survives a strict `DatasetSchema` parse. - `spec/src/api/analytics.test.ts`: the response parse preserves `fields[].nullLabel`. **Ablation** (`scripts/ablation-replace.mjs`, under the verify lock): the write `if (nullLabel !== undefined) f.nullLabel = nullLabel;` replaced by `void nullLabel;` (anchor 1 → 0, blob changed) turns the service pin **6 failed / 2 passed**; the 2 green are the no-key controls, green by design. Restored: blob equals HEAD and `git diff HEAD` is empty. The source is imported directly by the test, so no build leg applies. ## Verification All at head `0c232759ab` (base `7c68b1082`; `origin/main` has moved one commit since, touching none of these paths). - `pnpm --filter @objectstack/{service-analytics,platform-objects,spec} run typecheck`: exit 0 (spec's runs `check:scripts-typecheck` and `check:test-typecheck` too). - `pnpm --filter @objectstack/service-analytics test`: Test Files 185 passed (185), Tests 4513 passed, 262 skipped. - `pnpm --filter @objectstack/platform-objects test`: Test Files 93 passed (93), Tests 1341 passed. - `pnpm --filter @objectstack/spec test`: Test Files 711 passed (711), Tests 21634 passed, 1 todo. Lock VERDICT command-exit 0. - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date (no regeneration was needed). - `node scripts/pm/dispatch-gates.mjs --commands`: 93 derived families, 92 run with exit 0, reconciled with `--ran` (0 unrun). 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET, because it reads every package's `dist/` and this worktree built only the affected closure; CI runs it in Lint & Repo Gates. - `pnpm check:i18n`: exit 0, all 10 packages' bundles in sync. ## Acceptance notes - **Pivot charts.** objectui's `buildChartSeries` takes one null label per chart, used for both arms of a two-dimension pivot. The objectui consumer therefore applies a declared name on a pivot only when both dimensions declare the same one. Recorded in that PR as well. - **Other renderers.** `DatasetReportRenderer` and `ObjectChart` in objectui still read only `chart.nullCategory`; this card's consumer is the dashboard chart. They can read the same descriptor member later. carrier: none, noted only. - **The flat table** renders a null dimension value as `—`, not as the chart's bucket label, and is out of scope. --- _Generated by [Claude Code](https://claude.ai/code/session_016qAuAwFXMw94v5vFqTVcw2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2dfeb1a commit 830a63a

9 files changed

Lines changed: 246 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-analytics': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
feat(analytics): a dataset answer carries each dimension column's null-bucket name, and System Overview names its null user bucket "System"
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
A dataset dimension's `nullLabel` now reaches the renderer. The chart reads every column's display metadata from the dataset answer's `fields[]` and never fetches the dataset document, so the name rides that descriptor:
13+
14+
- **`@objectstack/spec`:** `AnalyticsResult.fields[]` and its mirror `AnalyticsResultResponseSchema` gain the optional response member `nullLabel?: string`, the display name of a dimension column's null bucket. It is a response member only; nothing an author writes changes.
15+
- **`@objectstack/service-analytics`:** the column-description pass shared by the live query and the draft-data preview sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`, resolved to a string at the request locale by the same resolver as `label`. A dimension without one, and every measure column, carry no `nullLabel` key, so those answers are byte-identical to before.
16+
- **`@objectstack/platform-objects`:** the `sys_audit_log_metrics` dataset's `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`. The rows the platform writes itself carry no user, and System Overview's "Events by User" drew them, its largest bar, under the renderer's default "(None)". Once the console reads `fields[].nullLabel`, that bar reads "System".

‎content/docs/api/data-api.mdx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
474474
<Callout type="info">
475475
**`fields[]` is the resolved presentation surface — read it first.** Each entry carries
476476
`name` and `type` and, when the producer declares them, `label`, `format`, `currency`,
477-
`percentScale`, `builtinAggregate` and `aggregate`: the optional members
477+
`percentScale`, `builtinAggregate`, `aggregate` and `nullLabel`: the optional members
478478
`AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`),
479479
mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to
480480
it at compile time. Optional means a given column may omit them — read them defensively,
@@ -505,6 +505,12 @@ chart can tell a count from a sum. `builtinAggregate` is narrower: it is present
505505
when the measure has no `label`, to mark a header that is the server's default. Neither
506506
is set on a dimension column or a derived measure, and the cube query on this page
507507
carries neither.
508+
509+
A dimension column of a dataset answer carries `nullLabel` when its dataset dimension
510+
declares one: the name of the bucket of rows whose value for that dimension is null,
511+
resolved to the request's locale like `label`. A chart draws its null bucket under it
512+
instead of its own default. It is never set on a measure column, nor on the cube query
513+
on this page.
508514
</Callout>
509515

510516
### `GET /analytics/meta`
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// System Overview "Events by User": the rows the platform writes itself carry
4+
// no `user_id`, and they were the board's largest bar, drawn under the
5+
// renderer's default "(None)". The dataset dimension names that bucket with
6+
// `nullLabel`, which the analytics service carries to the chart on the column
7+
// descriptor (`fields[].nullLabel`).
8+
//
9+
// The label is an inline locale map because the translation bundles have no
10+
// slot for a dimension's null label, so the map must name every locale the
11+
// board is translated into; a missing one would show that viewer the English.
12+
13+
import { describe, it, expect } from 'vitest';
14+
import { DatasetSchema } from '@objectstack/spec/ui';
15+
import { SysAuditLogDataset } from './system.datasets.js';
16+
import { SystemOverviewDashboard } from './system_overview.dashboard.js';
17+
import { SetupAppTranslations } from '../translations/index.js';
18+
19+
const userDimension = () => SysAuditLogDataset.dimensions?.find((d) => d.name === 'user_id');
20+
21+
describe('System Overview: the null-user bucket reads "System"', () => {
22+
it('"Events by User" groups the audit dataset by user_id', () => {
23+
const widget = SystemOverviewDashboard.widgets?.find((w) => w.id === 'widget_events_by_user');
24+
expect(widget).toMatchObject({ dataset: SysAuditLogDataset.name, dimensions: ['user_id'] });
25+
});
26+
27+
it('declares the user_id null label as an inline locale map', () => {
28+
expect(userDimension()?.nullLabel).toEqual({
29+
en: 'System',
30+
'zh-CN': '系统',
31+
'ja-JP': 'システム',
32+
'es-ES': 'Sistema',
33+
});
34+
});
35+
36+
it('names the bucket in every locale the platform bundles translate the board into', () => {
37+
const map = userDimension()?.nullLabel as Record<string, string>;
38+
for (const locale of Object.keys(SetupAppTranslations)) {
39+
expect(SetupAppTranslations[locale]?.dashboards?.system_overview, locale).toBeDefined();
40+
expect(typeof map[locale], locale).toBe('string');
41+
}
42+
});
43+
44+
it('survives a strict parse of the dataset', () => {
45+
const parsed = DatasetSchema.parse(SysAuditLogDataset);
46+
expect(parsed.dimensions?.find((d) => d.name === 'user_id')?.nullLabel).toEqual(userDimension()?.nullLabel);
47+
});
48+
});

‎packages/platform-objects/src/apps/dashboards/system.datasets.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,16 @@ export const SysAuditLogDataset = defineDataset({
4949
object: 'sys_audit_log',
5050
dimensions: [
5151
{ name: 'action', label: 'Action', field: 'action', type: 'string' },
52-
{ name: 'user_id', label: 'User', field: 'user_id', type: 'lookup' },
52+
// Rows the platform writes itself carry no `user_id`; their bucket reads
53+
// "System", not the renderer's default "(None)". An inline locale map:
54+
// the translation bundles have no slot for a dimension's null label.
55+
{
56+
name: 'user_id',
57+
label: 'User',
58+
field: 'user_id',
59+
type: 'lookup',
60+
nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' },
61+
},
5362
],
5463
measures: [{ name: 'event_count', label: 'Events', aggregate: 'count' }],
5564
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `DatasetDimension.nullLabel` reaches the renderer on the dimension column's
5+
* descriptor, as `fields[].nullLabel`.
6+
*
7+
* The chart renderer (objectui `DatasetWidget`) reads a dataset answer's
8+
* `fields[]` for every column's display metadata and never fetches the dataset
9+
* document, so the null bucket's authored name has to ride that descriptor. It
10+
* is resolved to a string at the request locale by the same `resolveI18nLabel`
11+
* the column's `label` uses, so a viewer reads the bucket in their own
12+
* language and the wire never carries a raw locale map.
13+
*
14+
* The other half is the control: a dimension that declares no `nullLabel`, and
15+
* every measure column, carry no `nullLabel` key at all, so an answer over a
16+
* dataset that never declares one is byte-identical to before.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { DatasetSchema } from '@objectstack/spec/ui';
21+
import type { ExecutionContext } from '@objectstack/spec/kernel';
22+
import { AnalyticsService } from '../analytics-service.js';
23+
24+
const dataset = DatasetSchema.parse({
25+
name: 'audit_metrics',
26+
label: 'Audit Metrics',
27+
object: 'sys_audit_log',
28+
include: [],
29+
dimensions: [
30+
{ name: 'action', label: 'Action', field: 'action', type: 'string' },
31+
{
32+
name: 'user_id',
33+
label: 'User',
34+
field: 'user_id',
35+
type: 'string',
36+
nullLabel: { en: 'System', 'zh-CN': '系统' },
37+
},
38+
{ name: 'source', field: 'source', type: 'string', nullLabel: 'Unknown' },
39+
],
40+
// `user_id` is also a measure's field: the measure column must not inherit
41+
// the dimension's null label through the descriptor pass's field match.
42+
measures: [
43+
{ name: 'event_count', label: 'Events', aggregate: 'count' },
44+
{ name: 'user_id_count', aggregate: 'count_distinct', field: 'user_id' },
45+
],
46+
});
47+
48+
const DIMENSIONS = ['action', 'user_id', 'source'];
49+
const MEASURES = ['event_count', 'user_id_count'];
50+
51+
function sqlService() {
52+
return new AnalyticsService({
53+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
54+
executeRawSql: async () => [
55+
{ action: 'create', user_id: null, source: null, event_count: 740, user_id_count: 0 },
56+
{ action: 'login', user_id: 'usr_1', source: 'web', event_count: 3, user_id_count: 1 },
57+
],
58+
});
59+
}
60+
61+
function aggregateService() {
62+
return new AnalyticsService({
63+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
64+
executeAggregate: async (_object: string, options: Record<string, unknown>) => {
65+
const groupBy = (options.groupBy ?? []) as Array<string | { field: string }>;
66+
const aggregations = (options.aggregations ?? []) as Array<{ alias: string }>;
67+
const row: Record<string, unknown> = {};
68+
for (const g of groupBy) row[typeof g === 'string' ? g : g.field] = null;
69+
for (const a of aggregations) row[a.alias] = 1;
70+
return [row];
71+
},
72+
});
73+
}
74+
75+
const STRATEGIES: [string, () => AnalyticsService][] = [
76+
['NativeSQLStrategy', sqlService],
77+
['ObjectQLStrategy', aggregateService],
78+
];
79+
80+
async function nullLabels(svc: AnalyticsService, context?: ExecutionContext) {
81+
const result = await svc.queryDataset(dataset, { dimensions: DIMENSIONS, measures: MEASURES }, context);
82+
const out: Record<string, unknown> = {};
83+
for (const f of result.fields) {
84+
if (f.nullLabel !== undefined) expect(typeof f.nullLabel).toBe('string');
85+
out[f.name] = 'nullLabel' in f ? f.nullLabel : '(no nullLabel key)';
86+
}
87+
return out;
88+
}
89+
90+
describe.each(STRATEGIES)('dataset dimension nullLabel on the wire (%s)', (_name, service) => {
91+
it('carries the declared null label, resolved to the request locale', async () => {
92+
const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
93+
const en = await nullLabels(service(), { tenantId: 'org_A', locale: 'en-US' } as ExecutionContext);
94+
expect(zh.user_id).toBe('系统');
95+
expect(en.user_id).toBe('System');
96+
});
97+
98+
it('carries a plain-string null label as authored, whatever the locale', async () => {
99+
const zh = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
100+
expect(zh.source).toBe('Unknown');
101+
});
102+
103+
it('falls back to the platform source language when the request states no locale', async () => {
104+
const noContext = await nullLabels(service());
105+
expect(noContext.user_id).toBe('System');
106+
});
107+
108+
it('writes no nullLabel key on a dimension that declares none, nor on any measure column', async () => {
109+
const cols = await nullLabels(service(), { tenantId: 'org_A', locale: 'zh-CN' } as ExecutionContext);
110+
expect(cols.action).toBe('(no nullLabel key)');
111+
expect(cols.event_count).toBe('(no nullLabel key)');
112+
expect(cols.user_id_count).toBe('(no nullLabel key)');
113+
});
114+
});

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3157,16 +3157,27 @@ export class AnalyticsService implements IAnalyticsService {
31573157
if (result.fields?.length && describableDims.length) {
31583158
const dimByName = new Map(describableDims.map((d) => [d.name, d]));
31593159
const dimByField = new Map(describableDims.filter((d) => !!d.field).map((d) => [d.field as string, d]));
3160+
const measureNames = new Set((dataset.measures ?? []).map((m) => m.name));
31603161
for (const f of result.fields) {
3161-
if (f.label != null) continue;
31623162
// Result fields may be keyed by the dataset dimension NAME or the
31633163
// underlying cube FIELD depending on strategy — match either.
31643164
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
31653165
if (!d) continue;
31663166
// [#6761] Same resolver, same locale, same "write nothing on a miss"
31673167
// rule as the measure enrichment above.
3168-
const label = resolveI18nLabel(d.label, requestLocale);
3169-
if (label !== undefined) f.label = label;
3168+
if (f.label == null) {
3169+
const label = resolveI18nLabel(d.label, requestLocale);
3170+
if (label !== undefined) f.label = label;
3171+
}
3172+
// The null bucket's display name (`DatasetDimension.nullLabel`), read
3173+
// whether or not the header was already set, and never onto a measure
3174+
// column whose name happens to match a dimension's field. Written only
3175+
// when declared and resolvable, so an answer over a dimension without
3176+
// one is byte-identical to before.
3177+
if (f.nullLabel == null && !measureNames.has(f.name.replace(/__compare$/, ''))) {
3178+
const nullLabel = resolveI18nLabel(d.nullLabel, requestLocale);
3179+
if (nullLabel !== undefined) f.nullLabel = nullLabel;
3180+
}
31703181
}
31713182
}
31723183
}

‎packages/spec/src/api/analytics.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,23 @@ describe('AnalyticsResultResponseSchema', () => {
205205
expect(resp.data.fields[2].format).toBe('0.0%');
206206
});
207207

208+
// A dimension column's null-bucket name rides the same channel. The parse
209+
// strips undeclared keys, so an undeclared `nullLabel` would vanish here.
210+
it('should preserve fields[].nullLabel on a dimension column', () => {
211+
const resp = AnalyticsResultResponseSchema.parse({
212+
success: true,
213+
data: {
214+
rows: [{ user_id: null, event_count: 740 }],
215+
fields: [
216+
{ name: 'user_id', type: 'string', label: 'User', nullLabel: 'System' },
217+
{ name: 'event_count', type: 'number', label: 'Events' },
218+
],
219+
},
220+
});
221+
expect(resp.data.fields[0].nullLabel).toBe('System');
222+
expect('nullLabel' in resp.data.fields[1]).toBe(false);
223+
});
224+
208225
// #14492 — the built-in aggregate discriminator rides the same channel, and
209226
// it is the CLOSED `AggregationFunction` vocabulary: a spelling outside it is
210227
// refused at the member, not stripped or passed through.

‎packages/spec/src/api/analytics.zod.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema
140140
+ 'single aggregate, and on a cube query answer (`POST /analytics/query`). Unlike '
141141
+ '`builtinAggregate`, it says nothing about who named the column.',
142142
),
143+
nullLabel: z.string().optional().describe(
144+
'Display name of a dimension column\'s null bucket: the dataset dimension\'s `nullLabel`, '
145+
+ 'resolved at the request locale like `label`. A renderer draws the column\'s null values '
146+
+ 'under it instead of its own default. Set only on a dimension column of a dataset answer '
147+
+ '(`POST /analytics/dataset/query`) whose dimension declares one; absent on measure columns '
148+
+ 'and on a cube query answer. Display copy only: grouping is unchanged.',
149+
),
143150
})).describe('Column metadata'),
144151
sql: z.string().optional().describe('Executed SQL (if debug enabled)'),
145152
totals: z.array(z.object({

‎packages/spec/src/contracts/analytics-service.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ export interface AnalyticsResult {
136136
* `AggregationFunction`, the one closed aggregate vocabulary.
137137
*/
138138
aggregate?: AggregationFunction;
139+
/**
140+
* The display name of a DIMENSION column's null bucket: the dataset
141+
* dimension's `nullLabel` (`DatasetDimensionSchema`), resolved to a
142+
* string at the request locale by the same `I18nLabel` resolver as
143+
* `label`. A renderer that buckets the column's null values draws
144+
* them under this name instead of its own default (`'(None)'`).
145+
*
146+
* Present only on a dimension column of a `queryDataset` answer whose
147+
* dataset dimension declares a `nullLabel` that resolves; absent
148+
* otherwise, on every measure column, and on a `query` (cube) answer.
149+
* Display copy only: it never changes which rows fall in the bucket.
150+
*/
151+
nullLabel?: string;
139152
}>;
140153
/** Generated SQL (if available) */
141154
sql?: string;

0 commit comments

Comments
 (0)