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
2 changes: 2 additions & 0 deletions docs/adr/0021-analytics-dataset-semantic-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 11 additions & 6 deletions docs/qa/platform-checklist/areas/dashboards.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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"
]
},
Expand All @@ -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"
},
{
Expand Down Expand Up @@ -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",
Expand All @@ -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"
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-ui/evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ObjectChart>` needs no dataset at all.
- `views-apps-actions-pages.json` — `defineView` containers, `App.create`
navigation, `defineAction` surfaces, record / interface pages, package
Expand Down
4 changes: 2 additions & 2 deletions skills/objectstack-ui/evals/analytics-inline-vs-dataset.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
},
Expand Down
10 changes: 5 additions & 5 deletions skills/objectstack-ui/rules/dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,18 @@ 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 |

> **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:

Expand Down
Loading