Skip to content
Merged
69 changes: 69 additions & 0 deletions .changeset/analytics-daterange-closed-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"@objectstack/spec": minor
"@objectstack/runtime": minor
---

feat(spec)!: `timeDimensions[].dateRange`'s string arm closes to the date-range preset vocabulary; any other string is refused with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16041)

<!-- adr-0087: registered analytics-time-dimension-date-range-vocabulary-closed -->

**BREAKING** — an accept-set narrowing on a published analytics contract.
`AnalyticsQuerySchema.timeDimensions[].dateRange` (and with it the
`POST /analytics/query` / `/analytics/sql` bodies, `AnalyticsQueryRequestSchema`,
and the `AnalyticsQuery` type every driver and `AnalyticsService.query` caller is
typed against) used to accept ANY string. It now accepts exactly the thirteen
dashboard date-range preset names, derived from `data/date-range-presets.ts`
(`z.enum(DATE_RANGE_PRESETS)` — the vocabulary's single source of truth since
#4614, so the two cannot drift), or the unchanged `[start, end]` array arm.
Shipped as `minor` under the repo's launch-window convention for breaking
changes; the hand-migration prescription is registered under protocol major 18.
Maintainer ruling on #16041 (2026-09-06, decision batch #57, option A —
contract first, 「同意」): 「本项目以协议为基准。所以开发应该对其协议,协议有问题应该立卡修改协议」.

## What was wrong

The arm was a bare `z.string()` whose only documented example — `"Last 7 days"`,
in the schema's own comment — was a value no driver could parse. `driver-memory`
recognised exactly `today` and a case-sensitive `last N <unit>` and fell every
other string through to a `[range, range]` pseudo-window that (measured through
mingo, 2026-09-05) matched **every `Date`-typed row**, 2099 included, because a
`Date` compares above a `String` under BSON cross-type ordering. The SQL
strategies read the same bare string as a single ISO day. A dashboard asking for
one week silently got all of history on one backend and one day on the other,
at HTTP 200 on both.

## What it does now

- The string arm is `AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS)`
(`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`); the schema example is corrected to
`'last_7_days'`.
- Any other value raises ONE prescriptive issue at `timeDimensions.N.dateRange`
(`analyticsDateRangeRefusalMessage`: the value, the vocabulary, the array
spelling for an explicit window). `@objectstack/spec/data` exports the
structural predicate `isAnalyticsDateRangeRefusalIssue` for doors.
- `POST /analytics/query` and `/analytics/sql` answer the ADR-0112 envelope
**`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`** — a new `ERROR_CODE_LEDGER` member
registered under `@objectstack/runtime` — and the analytics service is never
reached. A body wrong in more places than the `dateRange` stays the generic
`400 VALIDATION_FAILED` + `details.fields[]`.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` |
| `dateRange: 'Last 30 days'` / `'last 30 days'` | `dateRange: 'last_30_days'` |
| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` |
| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` |
| `dateRange: 'This week'` | `dateRange: 'this_week'` |
| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged |

Measured in this repository at the ruling: three authored `'Last 7 days'`, all
in `packages/spec` tests (re-spelled here), and no published dashboard authors
the string arm at all — the shipped console lowers presets to the array arm
before querying. The drivers' own refusal of a non-conforming value that reaches
them in-process (past the schema) is the sibling card #16322, blocked by this
one; the fenced `service-analytics` fixture that authors the retired bare-ISO
spelling is that card's to re-triage.
10 changes: 9 additions & 1 deletion content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,22 @@ const result = AnalyticsEndpoint.parse(data);
| **measures** | `string[]` | ✅ | List of metrics to calculate |
| **dimensions** | `string[]` | optional | List of dimensions to group by |
| **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: string \| string[] }[]` | optional | |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| 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. |
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
| **limit** | `number` | optional | |
| **offset** | `number` | optional | |
| **timezone** | `string` | optional | |
| **query** | `never` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. |
| **format** | `never` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. |

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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dimension** | `string` | ✅ | |
| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| 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`. |


---

Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +298 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +299 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -93,6 +93,7 @@ const result = ApiErrorSchema.parse(data);
* `ACTION_DISABLED`
* `ALREADY_REVERTED`
* `AMBIGUOUS_MATCH`
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
* `ANALYTICS_QUERY_FAILED`
* `APPROVAL_ACTIONS_FAILED`
* `APPROVAL_APPROVE_FAILED`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ const result = ErrorCode.parse(data);
* `ACTION_DISABLED`
* `ALREADY_REVERTED`
* `AMBIGUOUS_MATCH`
* `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
* `ANALYTICS_QUERY_FAILED`
* `APPROVAL_ACTIONS_FAILED`
* `APPROVAL_APPROVE_FAILED`
Expand Down
56 changes: 53 additions & 3 deletions content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ This layer decouples the "Physical Data" (Tables/Columns) from the
## TypeScript Usage

```typescript
import { AggregationMetricType, AnalyticsQuerySchema, CubeSchema, CubeJoinSchema, DimensionSchema, DimensionType, MetricSchema, TimeUpdateInterval } from '@objectstack/spec/data';
import type { AggregationMetricType, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data';
import { AggregationMetricType, AnalyticsDateRangeSchema, AnalyticsDateRangePresetSchema, AnalyticsQuerySchema, CubeSchema, CubeJoinSchema, DimensionSchema, DimensionType, MetricSchema, TimeUpdateInterval } from '@objectstack/spec/data';
import type { AggregationMetricType, AnalyticsDateRange, AnalyticsDateRangePreset, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data';

// Validate data
const result = AggregationMetricType.parse(data);
Expand All @@ -44,6 +44,48 @@ const result = AggregationMetricType.parse(data);
* `boolean`


---

## AnalyticsDateRange

### Union Options

This schema accepts one of the following structures:

#### Option 1

Allowed Values: `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`

---

#### Option 2

Type: `string[]`

---


---

## AnalyticsDateRangePreset

### Allowed Values

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


---

## AnalyticsQuery
Expand All @@ -56,12 +98,20 @@ const result = AggregationMetricType.parse(data);
| **measures** | `string[]` | ✅ | List of metrics to calculate |
| **dimensions** | `string[]` | optional | List of dimensions to group by |
| **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: string \| string[] }[]` | optional | |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| 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. |
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
| **limit** | `number` | optional | |
| **offset** | `number` | optional | |
| **timezone** | `string` | optional | |

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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dimension** | `string` | ✅ | |
| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| 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`. |


---

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 — 1575 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1577 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 @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with
| [API Protocol](/docs/references/api) | 31 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 13 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. |
| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Data Protocol](/docs/references/data) | 29 | 168 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
| [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
| [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
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) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **198** | **1575** | 14 protocol modules |
| **Total** | **198** | **1577** | 14 protocol modules |

---

Expand Down Expand Up @@ -149,13 +149,13 @@ Environments, packages and versions, marketplace, developer portal, tenancy.

## Data Protocol

**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 166 schemas**
**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 168 schemas**

Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer.

| File | Schemas |
| :--- | :--- |
| [`analytics.zod.ts`](/docs/references/data/analytics) | `AggregationMetricType`, `AnalyticsQuery`, `Cube`, `CubeJoin`, `Dimension`, `DimensionType`, `Metric`, `TimeUpdateInterval` |
| [`analytics.zod.ts`](/docs/references/data/analytics) | `AggregationMetricType`, `AnalyticsDateRange`, `AnalyticsDateRangePreset`, `AnalyticsQuery`, `Cube`, `CubeJoin`, `Dimension`, `DimensionType`, `Metric`, `TimeUpdateInterval` |
| [`context-tokens.zod.ts`](/docs/references/data/context-tokens) | `ContextToken`, `ContextTokenPlaceholder` |
| [`data-engine.zod.ts`](/docs/references/data/data-engine) | `BaseEngineOptions`, `DataEngineAggregateOptions`, `DataEngineAggregateRequest`, `DataEngineCountOptions`, `DataEngineCountRequest`, `DataEngineDeleteOptions`, `DataEngineDeleteRequest`, `DataEngineExecuteRequest`, `DataEngineFilter`, `DataEngineFindOneRequest`, `DataEngineFindRequest`, `DataEngineInsertOptions`, `DataEngineInsertRequest`, `DataEngineQueryOptions`, `DataEngineRequest`, `DataEngineSort`, `DataEngineUpdateOptions`, `DataEngineUpdateRequest`, `DataEngineVectorFindRequest`, `DroppedFieldsEvent`, `EngineAggregateOptions`, `EngineCountOptions`, `EngineDeleteOptions`, `EngineQueryOptions`, `EngineUpdateOptions` |
| [`datasource.zod.ts`](/docs/references/data/datasource) | `Datasource`, `DriverDefinition`, `DriverType`, `ExternalDatasourceSettings`, `SchemaMode` |
Expand Down
Loading
Loading