Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/25391-dataset-dimension-null-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': minor
---

feat(spec): a dataset dimension may name its null bucket with `nullLabel`

Clause-②: yes (widening)
Metadata-protocol: added

`DatasetDimensionSchema` gains an optional `nullLabel` (`I18nLabel`: a plain string or an inline locale map such as `{ en: 'System', 'zh-CN': '系统' }`). It names the bucket of rows whose value for that dimension is null, for example `'System'` on a `user_id` axis where the rows no user wrote carry no user. It is display copy only and changes no grouping.

- **What is accepted now.** `dimensions[].nullLabel` on a dataset, in either label form. Every dimension that parsed before parses unchanged, and the dimension still refuses any other undeclared key.
- **What reads it.** Nothing yet. The chart renderer that draws the bucket under this label ships separately, and until it does the bucket keeps the renderer's own default label. The liveness ledger records the key as `planned`, so `os lint` and `os validate` warn when you set it.
- **What to do.** Nothing. No existing key, value or default changes.
2 changes: 2 additions & 0 deletions content/docs/references/ui/dataset.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const result = DatasetSchema.parse(data);
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **nullLabel** | `string \| Record<string, string>` | optional | Label for the bucket of rows whose value for this dimension is null (e.g. "System" on a user axis). Display copy only: grouping is unchanged. When absent, consumers fall back to their own default label for that bucket. |

### Nested Shape: `Dataset.measures[number]`

Expand Down Expand Up @@ -111,6 +112,7 @@ const result = DatasetSchema.parse(data);
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **nullLabel** | `string \| Record<string, string>` | optional | Label for the bucket of rows whose value for this dimension is null (e.g. "System" on a user axis). Display copy only: grouping is unchanged. When absent, consumers fall back to their own default label for that bucket. |


---
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@
"ui/DatasetDimension:field",
"ui/DatasetDimension:label",
"ui/DatasetDimension:name",
"ui/DatasetDimension:nullLabel",
"ui/DatasetDimension:type",
"ui/DatasetMeasure:aggregate",
"ui/DatasetMeasure:currency",
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/liveness/dataset.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@
"proof": "packages/qa/dogfood/test/analytics-timezone.dogfood.test.ts#analytics-tz-bucketing",
"note": "ADR-0054 high-risk class (analytics): a time dimension's single granularity auto-buckets at query time (dataset-executor.ts:272-287); the proof asserts the day bucket SHIFTS with the org timezone (2024-03-01T03:00Z buckets to 2024-02-29 in America/Los_Angeles) — guarding the analytics-strategy ↔ in-memory count ↔ REST exec-context integration that #2018 fixed. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED — `:146` had rotted onto a docblock opener `/**`, and the note's `dataset-executor.ts:272-287` onto a closing brace and a comment line about the PRESENTATION's choice; the precedence resolver is at ~:315. On a row that carries an ADR-0054 high-risk proof, both pointers were wrong. Re-closed by hand against c459da6bc; DATED for the first time (this file carried no `verifiedAt` anywhere, which the review accepting that re-anchoring sorts as OLDEST).",
"verifiedAt": "2026-08-28"
},
"nullLabel": {
"status": "planned",
"verifiedAt": "2026-10-23",
"evidenceScope": "cross-repo",
"authorWarn": true,
"authorHint": "Keep it, but nothing reads it yet: no chart renderer draws a dimension's null bucket under its `nullLabel`, so the bucket still shows the renderer's own default label until the renderer half ships.",
"note": "PLANNED RECORD (#25391, 2026-10-23): declared spec-first by ruling 6358843349 on #25330 (letter A), whose consumer half follows under #25330 — the objectui chart renderer reads `nullLabel` over its global `chart.nullCategory` default, and `packages/platform-objects` `system.datasets.ts` sets `user_id`'s null label. Display copy in the `label` slot's own type (I18nLabelSchema); it changes no grouping. No reader on either side today: objectstack — the dataset compiler (packages/services/service-analytics/src/dataset-compiler.ts#compileDataset) copies `name` / `label` / `field` / `type` / `dateGranularity` into the Cube dimension and nothing else, and a dataset response's dimension descriptor is `{ name, type, label }`; objectui @0d12e953 (the `.objectui-sha` pin) and main @a14737b — the only `nullLabel` spellings are the local parameter of objectui: packages/core/src/utils/chart-series.ts#pivotSeriesBuckets, fed from `ChartSeriesOptions.nullCategoryLabel ?? NULL_CATEGORY_LABEL`, never from a dataset dimension. Which channel carries the key to the renderer (the served dataset document or the response's dimension descriptor) is the consumer half's decision, not this row's."
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/dataset.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `dataset` | 27 | 0 | 0 | 0 | 0 | 27 |
| `dataset` | 27 | 0 | 0 | 0 | 1 | 28 |
11 changes: 11 additions & 0 deletions packages/spec/src/system/metadata-form-zod-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,17 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
key: 'conditionalFormatting',
why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints",
},
// #25391 — a dataset dimension's null-bucket label lands spec-first the same
// way: `planned` in the liveness ledger, read by no chart renderer until the
// consumer half (ruling 6358843349 on #25330) ships. One list down, on the
// `dimensions` repeater rather than the root.
{
kind: 'omit',
type: 'dataset',
path: 'dimensions',
key: 'nullLabel',
why: "planned, not yet rendered — the label of a dimension's null bucket is validated at parse (an I18nLabel), but no chart renderer draws the bucket under it until objectui reads it over its own default, after the spec publishes. A repeater column offered today would let an author set a label nothing shows",
},
{
kind: 'omit',
type: 'view',
Expand Down
62 changes: 62 additions & 0 deletions packages/spec/src/ui/dataset-dimension-null-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `DatasetDimensionSchema.nullLabel` — the display name of a dimension's null
* bucket (ruling 6358843349 on #25330, letter A; the spec half, #25391).
*
* The key is optional display copy in the `label` slot's own type
* (`I18nLabelSchema`): the plain string and the inline locale map both parse,
* an absent key parses exactly as before it existed, and the dimension stays a
* closed shape — the new key widens the accept set by one name and nothing else.
*/

import { describe, it, expect } from 'vitest';
import { DatasetDimensionSchema, DatasetSchema } from './dataset.zod';

const dimension = { name: 'user', field: 'user_id', type: 'lookup' as const };

describe('DatasetDimensionSchema.nullLabel', () => {
it('accepts the plain-string form and keeps the value', () => {
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel: 'System' });
expect(parsed.success).toBe(true);
expect(parsed.data?.nullLabel).toBe('System');
});

it('accepts the inline locale-map form and keeps the value', () => {
const nullLabel = { en: 'System', 'zh-CN': '系统' };
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel });
expect(parsed.success).toBe(true);
expect(parsed.data?.nullLabel).toEqual(nullLabel);
});

it('an absent nullLabel parses exactly as before: no key is materialized', () => {
const parsed = DatasetDimensionSchema.safeParse(dimension);
expect(parsed.success).toBe(true);
expect(parsed.data).toEqual(dimension);
expect(Object.prototype.hasOwnProperty.call(parsed.data, 'nullLabel')).toBe(false);
});

it('refuses a value that is not a label, at the key', () => {
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel: 42 });
expect(parsed.success).toBe(false);
expect(parsed.error?.issues.map((i) => [i.code, i.path])).toEqual([['invalid_union', ['nullLabel']]]);
});

it('the dimension still refuses an undeclared key beside it', () => {
const parsed = DatasetDimensionSchema.safeParse({ ...dimension, nullLabel: 'System', emptyText: 'System' });
expect(parsed.success).toBe(false);
expect(parsed.error?.issues.map((i) => i.code)).toContain('unrecognized_keys');
});

it('a whole dataset carrying it parses clean', () => {
const parsed = DatasetSchema.safeParse({
name: 'events_by_user',
label: 'Events by User',
object: 'sys_event',
dimensions: [{ ...dimension, nullLabel: 'System' }],
measures: [{ name: 'event_count', aggregate: 'count' }],
});
expect(parsed.success).toBe(true);
expect(parsed.data?.dimensions[0]?.nullLabel).toBe('System');
});
});
13 changes: 13 additions & 0 deletions packages/spec/src/ui/dataset.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ export const DatasetDimensionSchema = lazySchema(() => strictObject({
type: z.enum(['string', 'number', 'date', 'boolean', 'lookup']).optional().meta({ title: 'Type' }),
/** Default bucketing for date dimensions (day/week/month/quarter/year). */
dateGranularity: DateGranularity.optional().meta({ title: 'Date Granularity' }),
/**
* The display name of this dimension's null bucket — the group of rows whose
* value for the dimension is null (e.g. `'System'` on a `user_id` axis, where
* the rows nobody wrote carry no user). Display copy only: it names the
* bucket, it never changes which rows fall in it or how they are grouped.
* When absent, a consumer draws its own default for that bucket.
*/
nullLabel: I18nLabelSchema.optional()
.describe(
'Label for the bucket of rows whose value for this dimension is null (e.g. "System" on a user axis). '
+ 'Display copy only: grouping is unchanged. When absent, consumers fall back to their own default label for that bucket.',
)
.meta({ title: 'Null Label' }),
}));

/**
Expand Down
Loading