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
13 changes: 13 additions & 0 deletions .changeset/30822-dataset-dimension-virtual-field-guide-entry.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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'] },
};
7 changes: 4 additions & 3 deletions packages/spec/src/migrations/semantic-relevance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const EXPECTED_RELEVANCE: Readonly<Record<string, readonly string[]>> = {
'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'],
Expand Down Expand Up @@ -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)', () => {
Expand Down
Loading