diff --git a/docs/adr/0021-analytics-dataset-semantic-layer.md b/docs/adr/0021-analytics-dataset-semantic-layer.md index 158b0d5a820..14af5955620 100644 --- a/docs/adr/0021-analytics-dataset-semantic-layer.md +++ b/docs/adr/0021-analytics-dataset-semantic-layer.md @@ -197,6 +197,8 @@ export const DatasetSchema = z.object({ **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. +> **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. + ### D2 — `report` becomes a pure pivot presentation over a dataset `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. diff --git a/docs/qa/platform-checklist/areas/dashboards.json b/docs/qa/platform-checklist/areas/dashboards.json index 85e8f6f4a70..d13a0f64106 100644 --- a/docs/qa/platform-checklist/areas/dashboards.json +++ b/docs/qa/platform-checklist/areas/dashboards.json @@ -964,7 +964,7 @@ "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", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -973,7 +973,7 @@ "fixtures": { "app": "showcase", "requires": [ - "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)", + "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)", "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" ] }, @@ -988,9 +988,9 @@ ], "acceptance": [ { - "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)", + "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)", "oracle": "api", - "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`)", + "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`)", "evidence": "the /analytics/meta?cube=showcase_delivery body" }, { @@ -1033,8 +1033,7 @@ "variants": [ "measure count (type: count)", "measure total_estimate_hours (type: sum)", - "measure avg_estimate_hours (type: avg)", - "measure done_rate (type: number, computed CASE expression)" + "measure avg_estimate_hours (type: avg)" ], "traps": [ "dispatcher-vs-hono-route", @@ -1061,6 +1060,12 @@ "date": "2026-09-28", "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", "ref": "#20348" + }, + { + "revision": 3, + "date": "2026-10-01", + "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", + "ref": "#20943" } ] }, diff --git a/skills/objectstack-ui/evals/README.md b/skills/objectstack-ui/evals/README.md index 4b66ef5f3da..0aabc3ffc13 100644 --- a/skills/objectstack-ui/evals/README.md +++ b/skills/objectstack-ui/evals/README.md @@ -7,7 +7,7 @@ view / page / dashboard / report metadata. - `analytics-inline-vs-dataset.json` — dataset-envelope decisions for dashboard/report widgets: when a data need fits a `defineDataset`, when it - must escalate to a Cube or a stored rollup field, and when an ad-hoc + must escalate to a stored field or app code, and when an ad-hoc in-page `` needs no dataset at all. - `views-apps-actions-pages.json` — `defineView` containers, `App.create` navigation, `defineAction` surfaces, record / interface pages, package diff --git a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json index 85f58717776..756b73ad097 100644 --- a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json +++ b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json @@ -15,11 +15,11 @@ { "id": 2, "prompt": "Add a dashboard chart showing 30-day rolling revenue (a moving average / running total over time). Model the data need.", - "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.", + "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.", "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.", "files": [], "assertions": { - "must_contain": ["window", "Cube"], + "must_contain": ["window"], "must_not_contain": ["dateGranularity"] } }, diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index c43b389e3a6..a3afc088afe 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -67,7 +67,7 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B. |:--|:--| | one base object + **to-one** joins (`include`, ≤3 hops) | a join that **changes grain** / a **to-many** rollup onto the parent | | 0..N dimensions; date-bucket `day/week/month/quarter/year` | a **computed dimension** / CASE bucket / numeric bin | -| 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 | +| measures `count/sum/avg/min/max/count_distinct` | list aggregation (collect-into-array / concatenate — retired in protocol 17, no spelling exists) | | **derived measures** — `ratio/sum/difference/product` of other measures | scalar math on raw fields (`amount*0.8`), aggregate-of-aggregate | | WHERE (`$and/$or/$not` on the base object) + measure-scoped filters | **HAVING** (filtering the aggregate result) | | `compareTo` (previous period/year) + `totals` (matrix subtotals) | **window** (rank, running total, lag/lead, %-of-total); **union**; reshaping params | @@ -75,10 +75,10 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B. > **The iron rule:** a dataset is a governed, *narrow* semantic layer — NOT a > general analytics escape hatch (no raw SQL, no hand-authored joins, no > window/having). If the need is in the right column, a dataset **cannot** express -> it — escalate to a hand-authored **Cube** (raw SQL / explicit joins), a **stored -> rollup or formula field** on the object (to-many rollups, computed columns), or -> app code. Do not force it into a dataset: it fails to compile or renders an empty -> series. +> it — escalate to a **stored field** on the object (a `summary` rollup, or a field +> holding the computed value or bucket) or app code, not to a **Cube**: a cube +> member's `sql` is a column reference. Do not force it into a dataset: it fails to +> compile or renders an empty series. Standardized answers to the recurring ambiguous cases: