Skip to content

Commit ebe0a98

Browse files
docs(spec): upgrade-guide entry for a dataset or cube dimension over a formula field (#30925)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoEYgj6SzALKLMB6zCkz6wQZ)_ Fixes #30822 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:升级指南(`os migrate meta` 与协议升级文档)新增一条语义迁移条目 `dataset-dimension-virtual-field-refused`:数据集或 `analyticsCubes` 的维度指向公式字段(不落库的虚拟字段)现在在编写时就被拒,条目告诉作者怎么改。 - 为什么改:#30773 已让数据集编译(`400 DATASET_INVALID`)和 lint(`dimension-virtual-field-refused`)拒绝这种维度,但升级指南没有对应条目,作者升级后只会在数据集突然编译失败时才知道;度量侧的同类收窄早已有条目。 - 风险与回滚:只加一条指南文字和它的相关性登记,不改任何 schema、导出或接受集;回滚即 revert。 - 先看:`packages/spec/src/migrations/entries/semantic/18.dataset-dimension-virtual-field-refused.ts`(条目正文,引用两道拒绝的原文);`packages/spec/src/migrations/semantic-relevance.test.ts`(相关性登记表 +1)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec tsx scripts/build-upgrade-guide.ts --out /tmp/g.md && grep -n dataset-dimension-virtual-field-refused /tmp/g.md`,预期在协议 18 一节看到该条目及其「Why not automatic / Done when」两行。 - 需要你做的:无,席位落地。 ## What changes - **New semantic migration entry** `dataset-dimension-virtual-field-refused` (protocol 18 step), the dimension-side sibling of `dataset-measure-aggregate-field-type-refused`, for a `datasets[].dimensions[].field` or an `analyticsCubes[].dimensions.KEY.sql` naming a `formula` field (`SEARCH_VIRTUAL_TYPES`). - No automatic rewrite: which stored field should carry the value is the author's judgement. - Replacement, as the card words it: 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. - `acceptanceCriteria` quotes both refusals as PR #30815 shipped them: the dataset compile's `400 DATASET_INVALID` text from `assertDimensionFieldStored` (`dataset-compiler.ts`), and the lint finding's message and hint from `virtualDimensionFinding` (`validate-dataset-measure-aggregates.ts`), with placeholders for the names. It also carries the two scope facts from that PR's changeset: the whole dataset stops compiling (not only the member), and the compile does not judge a dotted dimension while lint does. - `relevantWhen: { kind: 'stack-declares', keys: ['datasets', 'analyticsCubes'] }`. - **`semantic-relevance.test.ts`**: the reviewed ENUMERATION table gains the entry; the count pins move 26 → 27 overall and 21 → 22 for protocol 18. - **Changeset** `@objectstack/spec` patch with both declaration lines. ## Why The upgrade guide is the only channel that reaches an author upgrading across the release that ships #30773 before their dataset stops compiling. The narrowing PR registered `not-required (no-migration-prescription)`, so no conversion exists to replay; this entry is the prescription. The card asks for it to land before that release. ## Verification All runs on head `012839b6` in a dedicated worktree, base `origin/main` `6ff27b34` (`main` has moved since with no change under `packages/spec/src/migrations`). <details><summary>Tests</summary> - `pnpm --filter @objectstack/spec build` :: exit 0 - `vitest run src/migrations scripts/upgrade-guide-docs.test.ts scripts/upgrade-guide-surface-span.test.ts` (spec): Test Files 7 passed (7), Tests 228 passed (228) - `pnpm --filter @objectstack/spec test`: Test Files 818 passed (818), Tests 25838 passed, 1 todo - `pnpm --filter @objectstack/spec typecheck` :: exit 0 - Rendered guide (`build-upgrade-guide.ts --out`): the entry appears in protocol 18's section, its surface in one intact code span, with its "Why not automatic" and "Done when" lines. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 81 families for the 3-path change set; all 81 ran with exit 0. `--ran` reconciliation: 81 derived, 81 run, 0 NOT-MEASURED, 0 UNRUN. Four first runs answered exit 3 (prerequisite, nothing measured) and were re-run green after the prerequisite: `check-plugin-teardown-shape --self-test` (clone deepened), `check:doc-formula-expressions`, `check:lean-entry-closure`, `check:dual-build-cjs-loads` (dependency builds). Record: `/mnt/project-files/spec-seat-1/r2026-11-06/out/30822-gates-ran-012839b6.list`. </details> Dogfood: n/a (no `!` in the title, no `(narrowing)` arm). ## Acceptance notes - `relevantWhen` names only `datasets` and `analyticsCubes`, as the card rules. Measured on `packages/spec/src`: `DatasetSchema` is reachable from the stack only through `datasets` (`stack.zod.ts`), and `CubeSchema` only through `analyticsCubes`, so no third key carries the surface. - A dataset stored at runtime (Studio, `/meta`) is outside the relevance question by its own definition (`SemanticRelevance`: runtime-stored metadata is not part of the stack it reads), so a deployment whose only datasets are stored rows sees this entry counted as absent under `os migrate meta` and listed under `--all`. The entry's sweep is source-first (`os validate`); it names the runtime save's `422` only as one of the doors that refuse. - No ablation: the diff changes no tested behaviour, only registry data and its enumeration pin (the pin itself goes red without the table row, which is the reviewed-edit design). - The PR assignee is left to the seat at hand-over. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (CI-owned). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed (none committed for an entry: `registry.ts` and the guide are generated, not tracked). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibvg2LHxbiRKsqRA6iR --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvg2LHxbiRKsqRA6iR)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 256a4b7 commit ebe0a98

3 files changed

Lines changed: 69 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **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).
11+
- **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.
12+
- **Relevance.** `os migrate meta` lists the entry unless the stack provably declares no `datasets` and no `analyticsCubes`.
13+
- **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`.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The dimension-side sibling of `dataset-measure-aggregate-field-type-refused`:
6+
// a group key over a field no driver stores. The narrowing itself registered
7+
// `not-required (no-migration-prescription)` — no key is added, removed or
8+
// re-shaped — so this entry carries no conversion and has no automatic
9+
// rewrite: which stored field should carry the value is the author's call.
10+
export const entry: SemanticMigration = {
11+
id: 'dataset-dimension-virtual-field-refused',
12+
surface: 'dataset and cube dimensions over a VIRTUAL field — `datasets[].dimensions[].field` '
13+
+ '(`DatasetDimensionSchema`) and `analyticsCubes[].dimensions.KEY.sql` naming a field whose '
14+
+ 'declared type is `formula` (`SEARCH_VIRTUAL_TYPES`, `isVirtualSearchField`), whatever its '
15+
+ '`returnType`, on the base object or, for a dotted path, on the leaf\'s own object',
16+
replacement: 'store the value in a field of its own on the object (written by a hook or a flow '
17+
+ 'when its source changes) and point the dimension at that field, or remove the dimension. '
18+
+ 'There is no automatic rewrite: which stored field should carry the value, and what writes '
19+
+ 'it, is the author\'s judgement. A stored field the formula already reads is a candidate only '
20+
+ 'when grouping by it answers the same question the formula did',
21+
reason:
22+
'A dimension is a GROUP KEY: a dataset dimension compiles to a cube dimension whose `sql` is '
23+
+ 'its `field`, and a query selecting it groups (or date-buckets) by that column. A `formula` '
24+
+ 'is computed on read and never stored, so no driver materialises a column for it and there is '
25+
+ 'nothing to group by. Before this narrowing the dataset compiled, registered and published, '
26+
+ 'and its stored dimensions were served; only a query grouping by the formula dimension was '
27+
+ 'refused — `400 INVALID_FIELD` at `groupBy.N`, in front of whoever opened the dashboard tile — '
28+
+ 'and authoring reported nothing. The measure side already refused a total over a formula at '
29+
+ 'both doors; this is the dimension half. ⚠️ The refusal is the WHOLE dataset, not the one '
30+
+ 'member: a dataset carrying such a dimension no longer compiles, so its other, stored '
31+
+ 'dimensions stop being served with it until the dimension moves',
32+
acceptanceCriteria:
33+
'Every dataset compiles and `os validate` / `os build` / `os lint` report no '
34+
+ '`dimension-virtual-field-refused`. The two doors that refuse it, in their own words: the '
35+
+ 'dataset compile (`registerDataset`, `queryDataset` and every door that compiles through '
36+
+ 'them) answers `400 DATASET_INVALID` with "[dataset-compiler] dataset "NAME" dimension "DIM" (dimensions.N) '
37+
+ 'groups by field "FIELD", which object "OBJECT" declares as `formula` — its value is computed '
38+
+ 'on read and never stored, so no driver materialises a column to group by, and every query '
39+
+ 'selecting this dimension is refused. Denormalise the value onto "OBJECT" (a stored field, '
40+
+ 'written when the source changes) and point the dimension at that field."; the authoring '
41+
+ 'rules — the three CLI commands and the runtime dataset save (`422`) — answer '
42+
+ '`dimension-virtual-field-refused` (error) at `datasets[N].dimensions[M].field` or '
43+
+ '`analyticsCubes[N].dimensions.KEY.sql` with "groups by field "FIELD" (`formula` on object "OBJECT"), a value '
44+
+ 'computed on read and never stored, so no query can group by it" and the hint "Store the '
45+
+ 'value in a stored field of "OBJECT" (written when its source changes) and point this '
46+
+ 'dimension at that field, or remove the dimension." `os explain '
47+
+ 'dimension-virtual-field-refused` gives the reasoning. A dimension over a stored field and a '
48+
+ 'date or datetime dimension, with or without `dateGranularity`, are unchanged, as is every '
49+
+ 'measure verdict. ⚠️ The compile does not judge a dotted dimension (it answers for the base '
50+
+ 'object only); the lint rule and the query-time refusal still do, so sweep with `os validate`',
51+
relevantWhen: { kind: 'stack-declares', keys: ['datasets', 'analyticsCubes'] },
52+
};

‎packages/spec/src/migrations/semantic-relevance.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const EXPECTED_RELEVANCE: Readonly<Record<string, readonly string[]>> = {
5555
'18:cube-refresh-key-retired': ['analyticsCubes'],
5656
'18:dashboard-header-modal-target-page-only': ['dashboards'],
5757
'18:dashboard-refresh-interval-unit-in-key': ['dashboards'],
58+
'18:dataset-dimension-virtual-field-refused': ['datasets', 'analyticsCubes'],
5859
'18:dataset-measure-aggregate-field-type-refused': ['datasets'],
5960
'18:dataset-measure-selecting-aggregate-field-type-refused': ['datasets'],
6061
'18:hook-timeout-unit-in-key': ['hooks'],
@@ -83,11 +84,11 @@ describe('which entries carry a relevance question (ENUMERATION)', () => {
8384
expect(actual).toEqual(EXPECTED_RELEVANCE);
8485
});
8586

86-
it('is 26 entries: 5 of protocol 17 and 21 of protocol 18', () => {
87+
it('is 27 entries: 5 of protocol 17 and 22 of protocol 18', () => {
8788
const keys = Object.keys(EXPECTED_RELEVANCE);
88-
expect(keys).toHaveLength(26);
89+
expect(keys).toHaveLength(27);
8990
expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(5);
90-
expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(21);
91+
expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(22);
9192
});
9293

9394
it('the table names only registered entries (anti-vacuity for the comparison above)', () => {

0 commit comments

Comments
 (0)