Skip to content
Merged
15 changes: 15 additions & 0 deletions .changeset/17551-dataset-selection-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/spec": minor
"@objectstack/rest": patch
"@objectstack/service-analytics": patch
---

`DatasetSelectionSchema` — the ADR-0021 dataset selection is a Zod declaration now, and `POST /api/v1/analytics/dataset/query` parses the whole selection against it (#17551).

`DatasetSelection` was a TypeScript **interface** with no Zod schema anywhere in the repo. PR #17548 doored that route, but only over the **seven** members the selection shares with `AnalyticsQuery`; the other **four** — `runtimeFilter`, `dateGranularity`, `compareTo`, `totals` — were declared in TypeScript, published in the api-surface, and enforced by nothing on the wire. The measured consequence is #17550: `compareTo: { kind: 'nonsense' }` came back as a previous-period comparison under an ordinary **200**, a number a dashboard renders and a person reads as fact.

- **One declaration, in `packages/spec`.** `DatasetSelectionSchema`, `DatasetCompareToSchema` and `DatasetTotalsSchema` are authored in `api/analytics.zod.ts`, beside the `AnalyticsQueryRequestSchema` the sibling routes parse. `@objectstack/spec/contracts` now **re-exports** the `DatasetSelection` and `DatasetCompareTo` types from that schema instead of declaring interfaces of its own — the same move `AnalyticsQuery` made in #4538, taken here before a mirror could drift.
- **A transcription, not a new contract.** The seven shared members are read straight off `AnalyticsQuerySchema.shape`, so the claim that the two agree is structural rather than a hand-written list; the four dataset-only members are the already-published TypeScript members made executable. No member is added and nothing the interface permitted is refused.
- **Refusals carry a prescription.** An unrecognised `compareTo.kind` answers the sentence `datasetCompareKindRefusalMessage` builds — what arrived, the two windows the executor implements, what to do — and `@objectstack/service-analytics`' `shiftRange` now raises that same sentence with its own origin clause, so one condition keeps one wording. An unknown key is named, echoed and pointed at the canonical spelling (`where` → `runtimeFilter`, `granularity` → `dateGranularity`), and the retired `{ offset }` arm and the pre-#5011 bare-string form each carry their rewrite.
- ⚠️ **What narrows on the wire**, so an upgrading caller can look for it: a selection member whose value the published interface never permitted now answers `400 VALIDATION_FAILED` with `details.fields[]` instead of travelling into the executor. Measured against the sibling route spelling for spelling, `runtimeFilter` now behaves exactly as `/analytics/query`'s `where` does — three structurally-malformed filter spellings (`{ $or: 'x' }`, an `$or` branch that is not a filter object, `{ $not: 5 }`) are refused at the schema on both routes, and the four semantic ones (`{ stage: {} }`, `{ amount: { $between: [10] } }`, `{ $nor: […] }`, `{ $or: [] }`) still pass both and are answered deeper. The dataset route was the looser of the two; it is not any more.
- **No valid selection changes.** Every in-repo specimen and all five `@object-ui` call sites that build a selection today still pass, pinned in both packages; the route still forwards the caller's object to the service by identity, never a parse output, and the schema carries no default or transform that could override the engine's own timezone resolution chain.
69 changes: 67 additions & 2 deletions content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Provides endpoints for executing analytical queries and discovering metadata.
## TypeScript Usage

```typescript
import { AnalyticsEndpoint, AnalyticsMetadataResponseSchema, AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, AnalyticsSqlResponseSchema, GetAnalyticsMetaRequestSchema } from '@objectstack/spec/api';
import type { AnalyticsEndpoint, AnalyticsMetadataResponse, AnalyticsQueryRequest, AnalyticsResultResponse, AnalyticsSqlResponse, GetAnalyticsMetaRequest } from '@objectstack/spec/api';
import { AnalyticsEndpoint, AnalyticsMetadataResponseSchema, AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, AnalyticsSqlResponseSchema, DatasetCompareToSchema, DatasetSelectionSchema, DatasetTotalsSchema, GetAnalyticsMetaRequestSchema } from '@objectstack/spec/api';
import type { AnalyticsEndpoint, AnalyticsMetadataResponse, AnalyticsQueryRequest, AnalyticsResultResponse, AnalyticsSqlResponse, DatasetCompareTo, DatasetSelection, DatasetTotals, GetAnalyticsMetaRequest } from '@objectstack/spec/api';

// Validate data
const result = AnalyticsEndpoint.parse(data);
Expand Down Expand Up @@ -193,6 +193,71 @@ const result = AnalyticsEndpoint.parse(data);
| **traceId** | `string` | optional | |


---

## DatasetCompareTo

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **kind** | `Enum<'previousPeriod' \| 'previousYear'>` | ✅ | Comparison window: previousPeriod (equal-length, immediately before) or previousYear (the same window one calendar year back) |
| **dimension** | `string` | optional | Time dimension to shift; omit when the selection has exactly one dated time dimension |


---

## DatasetSelection

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dimensions** | `string[]` | optional | List of dimensions to group by |
| **measures** | `string[]` | ✅ | List of metrics to calculate |
| **runtimeFilter** | `any` | optional | Presentation-scope filter (canonical Query DSL FilterCondition), ANDed with the dataset's intrinsic filter at render |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string] }[]` | optional | Time-bucketed dimensions. Each entry names a dimension, an optional bucket `granularity`, and an optional `dateRange` — a preset name from the closed date-range vocabulary (e.g. `'last_7_days'`) or an explicit `[start, end]` window; an unrecognised string answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead of silently widening. |
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Presentation-scope date bucketing applied to every selected `date` dimension; an explicit `timeDimensions` entry wins over it, and the dataset dimension's own default is used when neither is set |
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
| **limit** | `number` | optional | |
| **offset** | `number` | optional | |
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`); attaches `<measure>__compare` columns |
| **totals** | `{ groupings: string[][] }` | optional | Server-side marginal aggregates; each grouping is a dimension subset to additionally aggregate by, `[]` being the grand total |
| **timezone** | `string` | optional | |

### Nested Shape: `DatasetSelection.timeDimensions[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dimension** | `string` | ✅ | |
| **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| [string, string]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. |

### Nested Shape: `DatasetSelection.compareTo`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **kind** | `Enum<'previousPeriod' \| 'previousYear'>` | ✅ | Comparison window: previousPeriod (equal-length, immediately before) or previousYear (the same window one calendar year back) |
| **dimension** | `string` | optional | Time dimension to shift; omit when the selection has exactly one dated time dimension |

### Nested Shape: `DatasetSelection.totals`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **groupings** | `string[][]` | ✅ | Dimension subsets to additionally aggregate by, in request order; the empty subset is the grand total |


---

## DatasetTotals

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **groupings** | `string[][]` | ✅ | Dimension subsets to additionally aggregate by, in request order; the empty subset is the grand total |


---

## GetAnalyticsMetaRequest
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1531 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1534 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand All @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with
| Module | Pages | Schemas | Description |
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 441 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [API Protocol](/docs/references/api) | 31 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
Expand All @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 34 | 273 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 158 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **195** | **1531** | 14 protocol modules |
| **Total** | **195** | **1534** | 14 protocol modules |

---

Expand Down Expand Up @@ -62,13 +62,13 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.

## API Protocol

**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 441 schemas**
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 444 schemas**

REST contracts, endpoints, routing, realtime, batch, discovery.

| File | Schemas |
| :--- | :--- |
| [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `GetAnalyticsMetaRequest` |
| [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` |
| [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` |
| [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` |
| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
| `ai/` | 78 |
| `api/` | 451 |
| `api/` | 454 |
| `identity/` | 32 |
| `integration/` | 8 |
| `kernel/` | 247 |
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/utils/analytics-date-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,10 @@ export function resolveAnalyticsDateRangePreset(
* Reachability: on EVERY REST analytics route a schema door parses
* `timeDimensions` ahead of the reader. `POST /analytics/query` and
* `/analytics/sql` parse the whole body; `POST /analytics/dataset/query` has
* parsed its selection's shared members — `timeDimensions` included — against
* `AnalyticsQuerySchema.pick(…)` since PR #17548, the PR that landed that door
* for card #17058 (`rest/src/analytics-selection-door.ts`, wired ahead of the
* executor). So every `dateRange` the union CAN refuse is refused there, with
* parsed its selection's shared members — `timeDimensions` included — since
* PR #17548, and parses the WHOLE selection against
* `DatasetSelectionSchema` since #17551
* (`rest/src/analytics-selection-door.ts`, wired ahead of the executor). So every `dateRange` the union CAN refuse is refused there, with
* the schema's own sentence, and this constructor contributes only its
* `.code`/`.status` to that answer.
*
Expand Down
Loading
Loading