From 012839b62658f75173cfa3a3e7d2c970f6ad3443 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 11:16:54 +0000 Subject: [PATCH] docs(spec): upgrade-guide entry for a dataset or cube dimension over a formula field The dataset compile and the authoring rules now refuse a dataset or analyticsCubes dimension over a virtual (formula) field. Register the semantic migration entry that tells an upgrading author what to do: store the value in a stored field and point the dimension at it, or remove the dimension. No automatic rewrite; relevant when the stack declares datasets or analyticsCubes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvg2LHxbiRKsqRA6iR --- ...set-dimension-virtual-field-guide-entry.md | 13 +++++ ...dataset-dimension-virtual-field-refused.ts | 52 +++++++++++++++++++ .../src/migrations/semantic-relevance.test.ts | 7 +-- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .changeset/30822-dataset-dimension-virtual-field-guide-entry.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.dataset-dimension-virtual-field-refused.ts diff --git a/.changeset/30822-dataset-dimension-virtual-field-guide-entry.md b/.changeset/30822-dataset-dimension-virtual-field-guide-entry.md new file mode 100644 index 00000000000..d30a1b9032a --- /dev/null +++ b/.changeset/30822-dataset-dimension-virtual-field-guide-entry.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the protocol upgrade guide names a dataset or cube dimension over a formula field, which the dataset compile and the authoring rules now refuse (#30822) + +Clause-②: no +Metadata-protocol: none + +- **What.** A new semantic migration entry, `dataset-dimension-virtual-field-refused`, in protocol 18's step of the upgrade guide and `os migrate meta`: a `datasets[].dimensions[].field` or an `analyticsCubes[].dimensions.KEY.sql` naming a `formula` field. It quotes both refusals as they ship: the dataset compile's `400 DATASET_INVALID` and the authoring rule `dimension-virtual-field-refused` (`os validate` / `os build` / `os lint`, and `422` at the runtime dataset save). +- **No automatic rewrite.** Which stored field should carry the value is the author's judgement. The remedy: store the value in a field of its own on the object (written by a hook or a flow when its source changes) and point the dimension at that field, or remove the dimension. +- **Relevance.** `os migrate meta` lists the entry unless the stack provably declares no `datasets` and no `analyticsCubes`. +- **Not changed.** No schema, export or accepted input moves: the refusal itself shipped with `@objectstack/service-analytics` and `@objectstack/lint`, and this is its guide entry, the dimension-side sibling of `dataset-measure-aggregate-field-type-refused`. diff --git a/packages/spec/src/migrations/entries/semantic/18.dataset-dimension-virtual-field-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dataset-dimension-virtual-field-refused.ts new file mode 100644 index 00000000000..bc5105fe19f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.dataset-dimension-virtual-field-refused.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The dimension-side sibling of `dataset-measure-aggregate-field-type-refused`: +// a group key over a field no driver stores. The narrowing itself registered +// `not-required (no-migration-prescription)` — no key is added, removed or +// re-shaped — so this entry carries no conversion and has no automatic +// rewrite: which stored field should carry the value is the author's call. +export const entry: SemanticMigration = { + id: 'dataset-dimension-virtual-field-refused', + surface: 'dataset and cube dimensions over a VIRTUAL field — `datasets[].dimensions[].field` ' + + '(`DatasetDimensionSchema`) and `analyticsCubes[].dimensions.KEY.sql` naming a field whose ' + + 'declared type is `formula` (`SEARCH_VIRTUAL_TYPES`, `isVirtualSearchField`), whatever its ' + + '`returnType`, on the base object or, for a dotted path, on the leaf\'s own object', + replacement: 'store the value in a field of its own on the object (written by a hook or a flow ' + + 'when its source changes) and point the dimension at that field, or remove the dimension. ' + + 'There is no automatic rewrite: which stored field should carry the value, and what writes ' + + 'it, is the author\'s judgement. A stored field the formula already reads is a candidate only ' + + 'when grouping by it answers the same question the formula did', + reason: + 'A dimension is a GROUP KEY: a dataset dimension compiles to a cube dimension whose `sql` is ' + + 'its `field`, and a query selecting it groups (or date-buckets) by that column. A `formula` ' + + 'is computed on read and never stored, so no driver materialises a column for it and there is ' + + 'nothing to group by. Before this narrowing the dataset compiled, registered and published, ' + + 'and its stored dimensions were served; only a query grouping by the formula dimension was ' + + 'refused — `400 INVALID_FIELD` at `groupBy.N`, in front of whoever opened the dashboard tile — ' + + 'and authoring reported nothing. The measure side already refused a total over a formula at ' + + 'both doors; this is the dimension half. ⚠️ The refusal is the WHOLE dataset, not the one ' + + 'member: a dataset carrying such a dimension no longer compiles, so its other, stored ' + + 'dimensions stop being served with it until the dimension moves', + acceptanceCriteria: + 'Every dataset compiles and `os validate` / `os build` / `os lint` report no ' + + '`dimension-virtual-field-refused`. The two doors that refuse it, in their own words: the ' + + 'dataset compile (`registerDataset`, `queryDataset` and every door that compiles through ' + + 'them) answers `400 DATASET_INVALID` with "[dataset-compiler] dataset "NAME" dimension "DIM" (dimensions.N) ' + + 'groups by field "FIELD", which object "OBJECT" declares as `formula` — its value is computed ' + + 'on read and never stored, so no driver materialises a column to group by, and every query ' + + 'selecting this dimension is refused. Denormalise the value onto "OBJECT" (a stored field, ' + + 'written when the source changes) and point the dimension at that field."; the authoring ' + + 'rules — the three CLI commands and the runtime dataset save (`422`) — answer ' + + '`dimension-virtual-field-refused` (error) at `datasets[N].dimensions[M].field` or ' + + '`analyticsCubes[N].dimensions.KEY.sql` with "groups by field "FIELD" (`formula` on object "OBJECT"), a value ' + + 'computed on read and never stored, so no query can group by it" and the hint "Store the ' + + 'value in a stored field of "OBJECT" (written when its source changes) and point this ' + + 'dimension at that field, or remove the dimension." `os explain ' + + 'dimension-virtual-field-refused` gives the reasoning. A dimension over a stored field and a ' + + 'date or datetime dimension, with or without `dateGranularity`, are unchanged, as is every ' + + 'measure verdict. ⚠️ The compile does not judge a dotted dimension (it answers for the base ' + + 'object only); the lint rule and the query-time refusal still do, so sweep with `os validate`', + relevantWhen: { kind: 'stack-declares', keys: ['datasets', 'analyticsCubes'] }, +}; diff --git a/packages/spec/src/migrations/semantic-relevance.test.ts b/packages/spec/src/migrations/semantic-relevance.test.ts index 75f76088ce7..95bcc4caae1 100644 --- a/packages/spec/src/migrations/semantic-relevance.test.ts +++ b/packages/spec/src/migrations/semantic-relevance.test.ts @@ -55,6 +55,7 @@ const EXPECTED_RELEVANCE: Readonly> = { '18:cube-refresh-key-retired': ['analyticsCubes'], '18:dashboard-header-modal-target-page-only': ['dashboards'], '18:dashboard-refresh-interval-unit-in-key': ['dashboards'], + '18:dataset-dimension-virtual-field-refused': ['datasets', 'analyticsCubes'], '18:dataset-measure-aggregate-field-type-refused': ['datasets'], '18:dataset-measure-selecting-aggregate-field-type-refused': ['datasets'], '18:hook-timeout-unit-in-key': ['hooks'], @@ -83,11 +84,11 @@ describe('which entries carry a relevance question (ENUMERATION)', () => { expect(actual).toEqual(EXPECTED_RELEVANCE); }); - it('is 26 entries: 5 of protocol 17 and 21 of protocol 18', () => { + it('is 27 entries: 5 of protocol 17 and 22 of protocol 18', () => { const keys = Object.keys(EXPECTED_RELEVANCE); - expect(keys).toHaveLength(26); + expect(keys).toHaveLength(27); expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(5); - expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(21); + expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(22); }); it('the table names only registered entries (anti-vacuity for the comparison above)', () => {