diff --git a/.changeset/25391-dataset-dimension-null-label.md b/.changeset/25391-dataset-dimension-null-label.md new file mode 100644 index 00000000000..661dc7bc401 --- /dev/null +++ b/.changeset/25391-dataset-dimension-null-label.md @@ -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. diff --git a/content/docs/references/ui/dataset.mdx b/content/docs/references/ui/dataset.mdx index 3b379a2c613..14b3ab55887 100644 --- a/content/docs/references/ui/dataset.mdx +++ b/content/docs/references/ui/dataset.mdx @@ -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` | 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]` @@ -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` | 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. | --- diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 56408327116..985a29bc6b4 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -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", diff --git a/packages/spec/liveness/dataset.json b/packages/spec/liveness/dataset.json index a3f13ec585a..c23b5a83014 100644 --- a/packages/spec/liveness/dataset.json +++ b/packages/spec/liveness/dataset.json @@ -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." } } }, diff --git a/packages/spec/liveness/state-counts/dataset.md b/packages/spec/liveness/state-counts/dataset.md index 1649dd3c2d5..3cffdd5c36c 100644 --- a/packages/spec/liveness/state-counts/dataset.md +++ b/packages/spec/liveness/state-counts/dataset.md @@ -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 | diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index a4b4384222c..aaa41c31fe4 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -365,6 +365,17 @@ const LEDGER: ReadonlyArray = [ 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', diff --git a/packages/spec/src/ui/dataset-dimension-null-label.test.ts b/packages/spec/src/ui/dataset-dimension-null-label.test.ts new file mode 100644 index 00000000000..ee1880dfd36 --- /dev/null +++ b/packages/spec/src/ui/dataset-dimension-null-label.test.ts @@ -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'); + }); +}); diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index f386424e34e..27136740a6e 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -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' }), })); /**