You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 5ce3705
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec): author DatasetSelectionSchema and parse the whole selection at the analytics dataset door (#19638)
Fixes#17551
Clause-②: yes
`Ruling-ref: 5754491527` — decision batch **#204 item 3**, **letter A**,
maintainer 「204 同意」 2026-09-21T02:08Z. Governing text:
`docs/NORTH-STAR.md` 〈优先级〉4 「错的必须被响亮拒绝并给处方,永不静默落库」. ⛔ This PR executes
that ruling; it does not reopen it. Option C (a second declaration in
`packages/rest`) was refused by name and is not taken.
## What was wrong
`DatasetSelection` was a TypeScript **interface** with no Zod schema
anywhere in the repo. PR #17548 (card #17058) doored `POST
/api/v1/analytics/dataset/query`, 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. #17550 is the measured consequence: `compareTo: {
kind: 'nonsense' }` returned a previous-period comparison under an
ordinary **200**.
## The placement, and why
**`packages/spec/src/api/analytics.zod.ts`.** The ruling left the choice
between that file and `contracts/analytics-service.ts` to this seat, ONE
place. Three readings decided it:
1. `DatasetSelection`'s own published text calls it 「the wire shape a
preview/query endpoint posts」. `api/analytics.zod.ts` is 「the HTTP
interface for the Semantic Layer」 and already holds
`AnalyticsQueryRequestSchema`, the **sibling routes' request body**. One
file now holds the analytics family's request-body declarations.
2. `contracts/analytics-service.ts` is type-only for this service —
every one of its analytics imports is `import type`. A Zod value there
would put `zod` on the import path of anything that imports
`@objectstack/spec/contracts` for types alone.
3. ⭐ Only in the zod tier can the seven shared members be taken **by
reference** off `AnalyticsQuerySchema.shape`. The door used to carry
that agreement as a hand-written array — a standing *claim* that two
declarations matched. Taking the declarations themselves makes it
structural: there is no second copy left to drift, and a member that
leaves `AnalyticsQuery` fails the build rather than becoming a private
dialect.
⇒ `@objectstack/spec/contracts` **re-exports** the `DatasetSelection` /
`DatasetCompareTo` types from the schema instead of declaring interfaces
beside it — the move `AnalyticsQuery` already made in that same file
(#4538), taken here before a mirror could drift rather than after. ⛔
There is no second declaration anywhere.
## The door's parse point
`packages/rest/src/rest-server.ts` →
`datasetSelectionRefusal(selection)`, unchanged in position: after the
route's own `selection.measures` check and **before** `queryDataset` is
called. What changed is what it parses — the whole selection against
`DatasetSelectionSchema`, where it used to parse a projection of seven
members. The envelope is unchanged (`400 VALIDATION_FAILED` +
`details.fields[]`, or the family's `ANALYTICS_DATE_RANGE_UNRECOGNIZED`
when every issue is that condition), and the route still forwards the
**caller's** object to the service by identity, never a parse output.
⚠️ One live consequence recorded at the code: the shared mapper's root
rename was inert under the projection and is live now. A `.strict()`
parse puts an unrecognized-keys issue at the ROOT, which the mapper
spells `(body)` — true for the sibling routes, false here — so the root
is re-spelled `selection`.
## The refusal messages, and their remedies
| condition | what the caller is told |
|:---|:---|
| `compareTo.kind` outside the closed pair | what arrived, the two
windows the executor implements (`'previousPeriod'` / `'previousYear'`,
each described), 「Name one of those, or drop compareTo」, where it was
refused, and that an unrecognised spelling used to answer a comparison
under a 200 |
| `compareTo.offset` (retired in #5011) | `{ offset: '1y' }` is exactly
`previousYear`; for any other duration, state the window on the
`timeDimensions[]` entry and compare with `previousPeriod` |
| `compareTo: 'previousPeriod'` (the bare-string form) | write
`compareTo: { kind: 'previousPeriod' }`; when to add `dimension` |
| an unknown key on the selection | the surface named, the key echoed,
and the canonical spelling (`where` → `runtimeFilter`, `orderBy` →
`order`, `granularity` → `dateGranularity`, `values` → `measures`) |
| `cube` / `dataset` / `datasetName` / `previewDrafts` | the wrong-layer
pointer: they live one level up, beside `selection` |
| `totals: { dimensions: […] }` | `dimensions` is how a total is
**reported back**; ask for it as `totals: { groupings: [[…], []] }` |
| `dateGranularity` outside the vocabulary | the closed enum |
| `runtimeFilter` | ⛔ deliberately **no dataset-only sentence**. It
carries the canonical `FilterCondition`, so its refusals are that
vocabulary's own — byte-identical to what the sibling body's `where`
answers. A second wording here would be exactly the defect the
`compareTo.kind` builder avoids. |
⭐ `datasetCompareKindRefusalMessage(input, origin)` is ONE builder for
ONE condition, on the `analyticsDateRangeRefusalMessage` pattern: the
schema door raises it with `'schema'`, and
`@objectstack/service-analytics`' `shiftRange` — a published export
reachable in-process by a caller that never posted a body — raises the
same sentence with `'runtime'`. The two differ only in the clause that
says where the refusal happened, which is the one clause no input can
supply. The executor's own refusal is **not** made redundant and is not
removed.
## #17550 as a refusal test
`packages/rest/src/analytics-dataset-selection-door.test.ts` §6 drives
that card's own specimen through the **real route**: 400,
`selection.compareTo.kind` in `details.fields[]`, the message carrying
`"nonsense"`, both legal kinds and 「drop compareTo」, the
`ApiErrorSchema` envelope check, `queryDataset` **never called**, a
CONTROL that the same selection with a declared kind still answers 200
with the caller's object by identity, and a pin that the door's sentence
is the builder's.
⚠️ **One correction the ruling's execution list deserves, stated rather
than silently absorbed:** the ruling says #17550 「closes as covered when
this lands」. #17550 was already **closed** (PR #17570, 2026-09-11) —
`shiftRange` gained an exhaustive `switch`, ten days before the ruling
was written. So this PR does not close it; it closes the half that fix
could not reach, at the door, for the route it names. Nothing else in
the execution list is affected.
## Generated artefacts
Regenerated with the repo's own tooling, ⛔ never by hand: `pnpm --filter
@objectstack/spec build` then `check:generated --fix`, which proved 5 of
15 stale and regenerated exactly those — `api-surface/`,
`export-origins/`, `declaration-map/`, `content/docs/references/**`, the
strictness-ledger counts — then re-checked all five green.
`authorable-surface/` and `json-schema.manifest/` were written by the
build's own `gen:schema`. Three hand-maintained ledgers moved with them
and each is a decision, not a regeneration:
`dropped-refinements.baseline.json` gains `api/DatasetSelection →
runtimeFilter.lazy` (the same `FilterCondition` projection gap
`api/AnalyticsQueryRequest → where.lazy` already records) with its two
header counts, and `type-alias-convention.pin.test.ts` gains three
isomorphic pins with its count.
## Acceptance notes
- `packages/spec/src/ui/dashboard.zod.ts`'s widget `compareTo` and the
new `DatasetCompareToSchema` declare the same two members with different
prescriptions, deliberately: the widget's point at neighbouring
**widget** keys (`options.dateGranularity`, the widget's own `filter`)
that do not exist on a wire selection, and it carries authoring-time
tombstones the wire never had. They share the vocabulary by construction
— `kind`'s refusal is the one builder — and nothing is filed.
- `packages/core/src/utils/analytics-date-range.ts`,
`packages/services/service-analytics/src/date-range-array-arm.ts` and
`packages/spec/src/data/analytics.zod.ts` each carried a sentence
asserting the dataset route parses 「the selection's shared members
against `AnalyticsQuerySchema.pick(…)`」. Each conclusion still holds and
only the mechanism sentence was stale; all three are corrected in place.
Comment-only, no publish surface.
- `SELECTION_MEMBERS_SHARED_WITH_ANALYTICS_QUERY` is deleted from
`packages/rest`. It was module-local with one consumer (its own test),
and the claim it encoded is now pinned at the schema by identity.
## Validation
Every command's exit code was captured **before** any pipe.
| command | exit | reading |
|:---|:---|:---|
| `pnpm --filter @objectstack/spec typecheck` | 0 | `tsc --noEmit` +
scripts + test layer |
| `pnpm --filter @objectstack/spec build` | 0 | under the shared verify
lock |
| `pnpm --filter @objectstack/spec check:generated` | 0 after `--fix` |
5 of 15 proved stale, regenerated, re-checked green |
| `pnpm --filter @objectstack/spec test` | 0 | 510 passed / 1 skipped
(511 files), 14930 tests |
| `pnpm --filter @objectstack/rest test` | 0 | 194 files, 3265 passed /
1 skipped |
| `pnpm --filter @objectstack/service-analytics test` | 0 | 113 files /
2411 tests |
| `pnpm --filter @objectstack/core test` | 0 | 51 files / 1321 tests |
| `pnpm --filter @objectstack/{spec,rest,service-analytics,core}
typecheck` | 0 | four packages, one run |
| `pnpm check:spec-parsed-alias` | 0 | 787 pinned isomorphic |
The gate family was derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` against the real change
set rather than from a list, and the derived commands were run and
reconciled with `--ran`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
`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).
8
+
9
+
`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.
10
+
11
+
-**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.
12
+
-**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.
13
+
-**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.
14
+
- ⚠️ **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.
15
+
-**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.
@@ -193,6 +193,71 @@ const result = AnalyticsEndpoint.parse(data);
193
193
|**traceId**|`string`| optional ||
194
194
195
195
196
+
---
197
+
198
+
## DatasetCompareTo
199
+
200
+
### Properties
201
+
202
+
| Property | Type | Required | Description |
203
+
| :--- | :--- | :--- | :--- |
204
+
|**kind**|`Enum<'previousPeriod' \| 'previousYear'>`| ✅ | Comparison window: previousPeriod (equal-length, immediately before) or previousYear (the same window one calendar year back) |
205
+
|**dimension**|`string`| optional | Time dimension to shift; omit when the selection has exactly one dated time dimension |
206
+
207
+
208
+
---
209
+
210
+
## DatasetSelection
211
+
212
+
### Properties
213
+
214
+
| Property | Type | Required | Description |
215
+
| :--- | :--- | :--- | :--- |
216
+
|**dimensions**|`string[]`| optional | List of dimensions to group by |
217
+
|**measures**|`string[]`| ✅ | List of metrics to calculate |
218
+
|**runtimeFilter**|`any`| optional | Presentation-scope filter (canonical Query DSL FilterCondition), ANDed with the dataset's intrinsic filter at render |
219
+
|**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. |
220
+
|**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 |
|**totals**|`{ groupings: string[][] }`| optional | Server-side marginal aggregates; each grouping is a dimension subset to additionally aggregate by, `[]` being the grand total |
|**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`. |
235
+
236
+
### Nested Shape: `DatasetSelection.compareTo`
237
+
238
+
| Property | Type | Required | Description |
239
+
| :--- | :--- | :--- | :--- |
240
+
|**kind**|`Enum<'previousPeriod' \| 'previousYear'>`| ✅ | Comparison window: previousPeriod (equal-length, immediately before) or previousYear (the same window one calendar year back) |
241
+
|**dimension**|`string`| optional | Time dimension to shift; omit when the selection has exactly one dated time dimension |
242
+
243
+
### Nested Shape: `DatasetSelection.totals`
244
+
245
+
| Property | Type | Required | Description |
246
+
| :--- | :--- | :--- | :--- |
247
+
|**groupings**|`string[][]`| ✅ | Dimension subsets to additionally aggregate by, in request order; the empty subset is the grand total |
248
+
249
+
250
+
---
251
+
252
+
## DatasetTotals
253
+
254
+
### Properties
255
+
256
+
| Property | Type | Required | Description |
257
+
| :--- | :--- | :--- | :--- |
258
+
|**groupings**|`string[][]`| ✅ | Dimension subsets to additionally aggregate by, in request order; the empty subset is the grand total |
|[Automation Protocol](/docs/references/automation)| 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
25
25
|[Data Protocol](/docs/references/data)| 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
0 commit comments