Skip to content

Commit d19da5d

Browse files
docs: a cube member's sql is a column reference, in ADR-0021, the dashboards skill and the cube checklist item (#21071)
Part of #20943 Clause-②: no Ruling D's governed half (Tier H, docs only). The code half landed as #20998 (`5d5e679873`). Five files, no package source: - `docs/adr/0021-analytics-dataset-semantic-layer.md`: a dated note at the end of D1 naming `MetricSchema` / `DimensionSchema` `sql` in `packages/spec/src/data/analytics.zod.ts`. - `skills/objectstack-ui/rules/dashboards.md`: the iron rule no longer escalates to a hand-authored Cube with raw SQL, or to a `formula` field (virtual, no stored column); the Level A table drops "any custom-SQL metric". File: 468 lines and 6211 tokens before and after. Package: every `skills/**/SKILL.md` together is 4395 lines before and after (none is touched). - `skills/objectstack-ui/evals/analytics-inline-vs-dataset.json` (eval 2) and `skills/objectstack-ui/evals/README.md`: the same skill's eval fixture and its index no longer name a Cube as an escalation. Eval 2's `must_contain` drops `"Cube"`, which a correct answer need no longer say. Tokens: the eval file goes from 1102 to 1097 (ceiling 1102), and the README from 287 to 286 (ceiling 289). Lines: 57 and 31, unchanged. - `docs/qa/platform-checklist/areas/dashboards.json`: `dashboards.cube-query` revision 3 drops the showcase cube's `done_rate`, which now lives on its dataset. No changeset: none of the five paths ships in a package's `files[]`. ## 维护者速读(草稿) - **改了什么:** 只改五个文档文件,不改代码。 - ADR-0021 在 D1 末尾加一条带日期的说明:分析 cube 的度量和维度,`sql` 只能写列引用;写表达式,解析时就会被拒。这是裁决 D,代码已随 #20998 落地。 - 看板技能不再教“dataset 表达不了,就去手写带原始 SQL 的 Cube”,也不再把公式字段当出路。改为:存成对象上的字段(汇总字段,或存好计算值、分桶的字段),或者写应用代码。 - 同一技能包里的评测文件和它的说明也一并改了:评测的标准答案不再把 Cube 当出路。 - QA 清单的 cube 条目删掉 `done_rate`,它已经搬到 dataset 上了。 - **为什么改:** 代码那一半落地后,这几处文字都不对了。看板技能是 AI 写元数据时读的教材:按旧教法写出的 cube,解析时就会被拒。 - **风险与代价(含回滚):** 只改文字,不发版。看板技能规则文件的行数和 token 数改前改后一样,两个评测文件略缩短。合并后是一个提交,回滚就是 revert 它。 - **席位意见:** 建议合并。终稿见 PR 上的维护者速读评论。 - **你要做的:** 看一眼 ADR 那条说明和技能里那一段的措辞,同意就批准。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b81314 commit d19da5d

5 files changed

Lines changed: 21 additions & 14 deletions

File tree

‎docs/adr/0021-analytics-dataset-semantic-layer.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ export const DatasetSchema = z.object({
197197

198198
**RLS/tenant is enforced by the runtime, not declared here** (D-C). The dataset compiles to a Cube query whose execution applies the sharing middleware's read filter **per joined object** — there is **one** place to reason about access, and the author cannot forget it.
199199

200+
> **Note (2026-10-01) — cube members.** Maintainer ruling D on [#20943](https://github.com/objectstack-ai/objectstack/issues/20943) (2026-09-30) carries "zero raw SQL / zero raw expressions" to the members of an analytics cube; it shipped in [#20998](https://github.com/objectstack-ai/objectstack/pull/20998). In `packages/spec/src/data/analytics.zod.ts`, the `sql` of a cube measure ([`MetricSchema`](../../packages/spec/src/data/analytics.zod.ts#MetricSchema)) and of a cube dimension ([`DimensionSchema`](../../packages/spec/src/data/analytics.zod.ts#DimensionSchema)) is a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` ([`CUBE_MEMBER_SQL`](../../packages/spec/src/data/analytics.zod.ts#CUBE_MEMBER_SQL)). A SQL expression there (a `CASE`, an aggregate, a ratio of aggregates) is refused at parse. A measure's refusal names the D1 form: a measure with its own `filter` for a conditional count or sum, and `derived: { op, of }` over named measures for a ratio, sum, difference or product. A dimension's refusal says to keep a computed bucket as a field of the object.
201+
200202
### D2 — `report` becomes a pure pivot presentation over a dataset
201203

202204
`ReportSchema` loses `objectName`, `columns`, `groupingsDown/Across`, `filter`, `blocks`, `chart`-as-query. The `tabular / summary / matrix` enum collapses into one pivot grammar (tabular = no groupings; summary = rows only; matrix = rows + columns). `joined` becomes `sections[]` — each section is just another dataset reference.

‎docs/qa/platform-checklist/areas/dashboards.json‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -964,7 +964,7 @@
964964
"title": "The showcase_delivery analytics cube serves /api/v1/analytics/*: meta discovers its measures/dimensions, a query answers a known aggregate that reconciles against a direct /data aggregate, and an unwired analytics slot degrades honestly to 404",
965965
"since": "v16",
966966
"status": "active",
967-
"revision": 2,
967+
"revision": 3,
968968
"priority": "P2",
969969
"surface": "api",
970970
"personas": [
@@ -973,7 +973,7 @@
973973
"fixtures": {
974974
"app": "showcase",
975975
"requires": [
976-
"the showcase_delivery cube (examples/app-showcase/src/data/analytics/showcase.cube.ts — verified present: base table showcase_task; measures count / total_estimate_hours / avg_estimate_hours / done_rate; dimensions status / priority / due_date / assignee), registered as `analyticsCubes` (examples/app-showcase/src/coverage.ts) and served by the `analytics` capability the CLI serve path auto-loads (packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS.analytics → @objectstack/service-analytics, configKey analyticsCubes)",
976+
"the showcase_delivery cube (examples/app-showcase/src/data/analytics/showcase.cube.ts — verified present: base table showcase_task; measures count / total_estimate_hours / avg_estimate_hours; dimensions status / priority / due_date / assignee), registered as `analyticsCubes` (examples/app-showcase/src/coverage.ts) and served by the `analytics` capability the CLI serve path auto-loads (packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS.analytics → @objectstack/service-analytics, configKey analyticsCubes)",
977977
"seeded showcase_task rows spanning multiple statuses (the same multi-bucket seed dashboards.chart-first-paint relies on) so the reconciled aggregate has more than one bucket"
978978
]
979979
},
@@ -988,9 +988,9 @@
988988
],
989989
"acceptance": [
990990
{
991-
"clause": "meta discovers the cube: GET /analytics/meta lists showcase_delivery with exactly its four measures (namespaced showcase_delivery.count / .total_estimate_hours / .avg_estimate_hours / .done_rate) and four dimensions (showcase_delivery.status / .priority / .due_date / .assignee)",
991+
"clause": "meta discovers the cube: GET /analytics/meta lists showcase_delivery with exactly its measures (namespaced showcase_delivery.count / .total_estimate_hours / .avg_estimate_hours) and four dimensions (showcase_delivery.status / .priority / .due_date / .assignee)",
992992
"oracle": "api",
993-
"verify": "the meta response's cubes[] entry for showcase_delivery names all four measures and four dimensions (getMeta keys them `${cube}.${key}`, and lists a cube only when its `public` is not `false`: a cube declaring `public: false` is omitted from meta and refused by query()/generateSql() with 404 CUBE_NOT_FOUND. showcase_delivery declares no `public`, so it is visible by the schema default `true`)",
993+
"verify": "the meta response's cubes[] entry for showcase_delivery names each measure and dimension the clause lists (getMeta keys them `${cube}.${key}`, and lists a cube only when its `public` is not `false`: a cube declaring `public: false` is omitted from meta and refused by query()/generateSql() with 404 CUBE_NOT_FOUND. showcase_delivery declares no `public`, so it is visible by the schema default `true`)",
994994
"evidence": "the /analytics/meta?cube=showcase_delivery body"
995995
},
996996
{
@@ -1033,8 +1033,7 @@
10331033
"variants": [
10341034
"measure count (type: count)",
10351035
"measure total_estimate_hours (type: sum)",
1036-
"measure avg_estimate_hours (type: avg)",
1037-
"measure done_rate (type: number, computed CASE expression)"
1036+
"measure avg_estimate_hours (type: avg)"
10381037
],
10391038
"traps": [
10401039
"dispatcher-vs-hono-route",
@@ -1061,6 +1060,12 @@
10611060
"date": "2026-09-28",
10621061
"change": "re-spelled the meta clause's verify text and two source lines, which stopped being true when the analytics service began reading `analytics_cube.public`: getMeta no longer returns every registry cube (it omits one declaring `public: false`, and query()/generateSql() refuse it with 404 CUBE_NOT_FOUND), and the showcase cube no longer declares `public: false` (it is visible by the default `true`). Text only: every clause, step and verdict is unchanged, because showcase_delivery stays visible and its meta entry still names the same four measures and four dimensions",
10631062
"ref": "#20348"
1063+
},
1064+
{
1065+
"revision": 3,
1066+
"date": "2026-10-01",
1067+
"change": "dropped done_rate from the fixture line and the meta clause, re-spelled the verify text that counted it, and removed its variant: a cube member's `sql` is now a column reference (MetricSchema in packages/spec/src/data/analytics.zod.ts refuses a SQL expression at parse), so the showcase_delivery cube no longer declares the CASE-expression done_rate measure, and the done rate is declared on the showcase_task_metrics dataset (examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts) as a filtered count over a count. The steps, the reconciliation clauses and the four dimensions are unchanged",
1068+
"ref": "#20943"
10641069
}
10651070
]
10661071
},

‎skills/objectstack-ui/evals/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ view / page / dashboard / report metadata.
77

88
- `analytics-inline-vs-dataset.json` — dataset-envelope decisions for
99
dashboard/report widgets: when a data need fits a `defineDataset`, when it
10-
must escalate to a Cube or a stored rollup field, and when an ad-hoc
10+
must escalate to a stored field or app code, and when an ad-hoc
1111
in-page `<ObjectChart>` needs no dataset at all.
1212
- `views-apps-actions-pages.json` — `defineView` containers, `App.create`
1313
navigation, `defineAction` surfaces, record / interface pages, package

‎skills/objectstack-ui/evals/analytics-inline-vs-dataset.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
{
1616
"id": 2,
1717
"prompt": "Add a dashboard chart showing 30-day rolling revenue (a moving average / running total over time). Model the data need.",
18-
"expected_output": "Recognizes that a window/rolling calculation is BEYOND the dataset envelope (a dataset has no window/having grammar and compareTo only does previous-period/year). Escalates to a hand-authored Cube (raw SQL) or a stored/materialized rolling field — it does not pretend a dataset date-bucket or a plain measure expresses a moving window.",
18+
"expected_output": "Recognizes that a window/rolling calculation is BEYOND the dataset envelope (a dataset has no window/having grammar and compareTo only does previous-period/year). Escalates to a stored/materialized rolling field or app code, not a Cube — it does not pretend a dataset date-bucket or a plain measure expresses a moving window.",
1919
"note": "The must_not_contain on 'dateGranularity' is deliberately strict: proposing date bucketing as the fix is the classic wrong answer. It can flag an otherwise-correct answer that merely mentions the field in passing — treat such a flag as a review cue, not an automatic fail.",
2020
"files": [],
2121
"assertions": {
22-
"must_contain": ["window", "Cube"],
22+
"must_contain": ["window"],
2323
"must_not_contain": ["dateGranularity"]
2424
}
2525
},

‎skills/objectstack-ui/rules/dashboards.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,18 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B.
6767
|:--|:--|
6868
| one base object + **to-one** joins (`include`, ≤3 hops) | a join that **changes grain** / a **to-many** rollup onto the parent |
6969
| 0..N dimensions; date-bucket `day/week/month/quarter/year` | a **computed dimension** / CASE bucket / numeric bin |
70-
| measures `count/sum/avg/min/max/count_distinct` | list aggregation (collect-into-array / concatenate — retired in protocol 17, no spelling exists) or any custom-SQL metric |
70+
| measures `count/sum/avg/min/max/count_distinct` | list aggregation (collect-into-array / concatenate — retired in protocol 17, no spelling exists) |
7171
| **derived measures** — `ratio/sum/difference/product` of other measures | scalar math on raw fields (`amount*0.8`), aggregate-of-aggregate |
7272
| WHERE (`$and/$or/$not` on the base object) + measure-scoped filters | **HAVING** (filtering the aggregate result) |
7373
| `compareTo` (previous period/year) + `totals` (matrix subtotals) | **window** (rank, running total, lag/lead, %-of-total); **union**; reshaping params |
7474

7575
> **The iron rule:** a dataset is a governed, *narrow* semantic layer — NOT a
7676
> general analytics escape hatch (no raw SQL, no hand-authored joins, no
7777
> window/having). If the need is in the right column, a dataset **cannot** express
78-
> it — escalate to a hand-authored **Cube** (raw SQL / explicit joins), a **stored
79-
> rollup or formula field** on the object (to-many rollups, computed columns), or
80-
> app code. Do not force it into a dataset: it fails to compile or renders an empty
81-
> series.
78+
> it — escalate to a **stored field** on the object (a `summary` rollup, or a field
79+
> holding the computed value or bucket) or app code, not to a **Cube**: a cube
80+
> member's `sql` is a column reference. Do not force it into a dataset: it fails to
81+
> compile or renders an empty series.
8282
8383
Standardized answers to the recurring ambiguous cases:
8484

0 commit comments

Comments
 (0)