Skip to content

Commit 75237a9

Browse files
os-warrenclaude
andauthored
fix(spec)!: the dateRange array arm is exactly two string bounds, and each refusal origin gets a true sentence (#18230)
Fixes #17598 Ruling A (comment 5642584462, decision batch #117 item 3, 2026-09-12; re-affirmed 5651068700 on 2026-09-13). Both items of the card land here — see "Why item 2 lands here" below. ## 1. The array arm refuses anything that is not exactly two string bounds `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with no length constraint; it is now `z.tuple([z.string(), z.string()])`. A tuple rather than `z.array(z.string()).length(2)` on purpose: the arity is then stated to the author's compiler before any parse runs, which is the ruling's "the type says what the prose says". The union's `error` mapper is unchanged in kind, so an arity refusal is still ONE prescriptive `invalid_union` issue at `timeDimensions.N.dateRange` and still carries the structural handle `isAnalyticsDateRangeRefusalIssue` lifts into `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — not the tuple arm's own `too_big` / `too_small` text. Pinned in `packages/spec/src/data/analytics-date-range-two-bound-window.test.ts`: `[]`, `['2026-01-01']`, `['2026-01-01','2026-01-31','2026-02-28']` and `['{7_days_ago}']` are each refused with one issue at the field's own path, through `AnalyticsQuerySchema` and through the `POST /analytics/query` body schema; the two-bound windows and every preset name still parse; and the authored TYPE narrows too (a `@ts-expect-error` case). ## 2. Each refusal ORIGIN gets a true sentence, with the origin as a PARAMETER `analyticsDateRangeRefusalMessage(input)` becomes `analyticsDateRangeRefusalMessage(input, origin)`, `origin` being `'schema'` or `'runtime'`, required, with deliberately no default — a defaulted origin makes the same false assertion silently for every caller who does not think about it. Two clauses were false for an array refused past the schema door: - "Refused at the schema" — now `'runtime'` answers "Refused past the schema door, by the analytics reader that received it", and the two sentences are otherwise byte-identical (pinned by a replace-and-compare assertion, so the #5240 one-condition-one-wording convention is mechanical rather than asserted). - "received an array with a non-string bound" — a new `describeRefusedDateRange` names the ARITY and the bad bound separately and claims neither when it is not true: `['2026-01-01']` now reads "received a 1-element array, not the two bounds [start, end]", while `['2026-01-01', 3]` keeps exactly the description it had, because that one was true. The ruling's acceptance criterion — the wording is true BOTH BEFORE AND AFTER the arm narrows — is itself a test: `['2026-01-01']` is described correctly at both origins, and answering item 1 did not falsify item 2's wording. ## 3. Changeset and ADR-0087 entry `.changeset/17598-analytics-date-range-two-bound-window.md` declares **`minor`**, not the `major` the ruling's execution note 1 names, and says so in its own body. `scripts/check-changeset-no-major.mjs` refuses a `major` outright: every publishable package is in the Changesets `fixed` group, so one `major` promotes the whole stack, and during the launch window this repo ships breaking changes as `minor`. The information carriers the guard names in its place are both present — the **BREAKING** banner and the ADR-0087 disposition marker. Measured: `check-changeset-no-major.mjs --base origin/main` exit 0, `check-adr-0087-registration.mjs --base origin/main` exit 0. The ADR-0087 semantic entry is `packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts`. The `18.` prefix is the TARGET PROTOCOL MAJOR, not a sequence number: `packages/spec` is at `17.4.0`, the sibling `18.analytics-time-dimension-date-range-vocabulary-closed.ts` (#16322, the same schema's string arm) is filed the same way, and the generated table lands it in `MIGRATIONS_BY_MAJOR[18].semantic` — `check:migration-registry`, `check:spec-changes` and `check:upgrade-guide` all green, so the projections agree. Conversions, per the ruling: one element becomes the same day twice; the empty array and 3+ bounds get the structured TODO and no conversion, because there is nothing to derive a pair FROM. No D2 conversion and no stored-metadata rewrite — rewriting `['2026-01-01']` at load would be the platform deciding silently that the author meant one day rather than a window whose end they forgot. ## Scope: the two out-of-declaration edits, measured and then decided The claim declared `packages/spec/src/data/analytics.zod.ts` plus its tests, one ADR-0087 entry and a changeset. Two edits reach outside it. Both were MEASURED before being kept, by ablation with on-disk mutation proof and a restore proof. ### A. `packages/core/src/utils/analytics-date-range.ts` — REQUIRED CONSEQUENCE, kept It is **not** one of the four analytics faces PR #17593 landed. Measured from that PR's own file list: all 8 files are under `packages/services/service-analytics/` plus its changeset; `packages/core` is not among them. This file is the shared ADR-0112 envelope constructor `analyticsDateRangeUnrecognizedError` that those faces — and driver-memory, and the REST selection door — all call, and it predates #17593. It is the only non-test call site of `analyticsDateRangeRefusalMessage` in the monorepo (grep over `packages/**`: the other five files name only the constructor, whose signature is unchanged). Making `origin` required is item 2 of the ruling, so this call site MUST state the origin it alone knows. Ablation, one file reverted to the merge base, nothing else: ``` revert packages/core/src/utils/analytics-date-range.ts to 1bdbf82 on-disk proof: two-arg call sites 1 -> 0; blob 50509de -> 4f6d482 pnpm --filter @objectstack/core exec tsc --noEmit :: exit 1 src/utils/analytics-date-range.ts(222,25): error TS2554: Expected 2 arguments, but got 1. restore (git checkout HEAD -- path; blob back to 50509de, git diff HEAD empty) pnpm --filter @objectstack/core exec tsc --noEmit :: exit 0 ``` The narrowing cannot land without it: `TypeScript Type Check` is one of the six required contexts. ### B. `packages/spec/scripts/lib/format-type.ts` — REQUIRED CONSEQUENCE, kept The reference-docs renderer read only `items`. Draft-2020-12 spells `z.tuple([...])` as `prefixItems` and leaves `items` ABSENT, so the array branch rendered the element type of nothing and printed `any[]`. Measured directly, both renderer versions run over the REAL generated schema for this field (`packages/spec/json-schema/data/AnalyticsQuery.json`, `properties.timeDimensions.items.properties.dateRange`): ``` origin/main arm z.array(z.string()) + base renderer -> string[] NARROWED arm z.tuple([string, string]) + base renderer -> any[] ** THE REGRESSION ** NARROWED arm z.tuple([string, string]) + this renderer -> [string, string] ``` So landing item 1 WITHOUT this fix does not fail the docs build — it silently rewrites `content/docs/references/data/analytics.mdx` and `api/analytics.mdx` from `string[]` to `any[]`, i.e. this card's narrowing would make the published reference page say the two bounds may be anything. A regenerated page that is wrong is the criterion, and this is it. Confirmed the other way too: with the branch reverted, `check:docs` exits 1 and names all four pages, and `scripts/format-type.test.ts` goes 1 failed / 89 passed. Collateral, and unavoidable: the branch is one branch in one renderer and cannot be scoped to `dateRange` alone, so two pre-existing tuples stop reading `any[]` in the same regeneration — `FilterCondition.$between` (`[number | string, number | string]`) and `ListView.map.center` (`[number, number]`). Both are corrections in the same direction, on generated pages, with no source change behind them. They are called out here rather than filed, because there is no PR in which they could land separately. ### C. `packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts` — a THIRD required consequence, found this round Not in the inherited diff. Narrowing the authored type breaks three sites in this suite, measured: ``` HEAD (narrowed) pnpm --filter @objectstack/service-analytics exec tsc --noEmit 39 errors total, 3 in objectql-daterange.test.ts (220, 499, 503) arm reverted + spec rebuilt 36 errors total, 0 in objectql-daterange.test.ts HEAD + this commit 36 errors total, 0 in objectql-daterange.test.ts ``` (The 36 that remain are 24 TS2307 plus their implicit-any cascade, from `@objectstack/core` having no `dist` in this worktree. They are identical in all three runs and CI builds the closure.) Line 220 is a deliberate one-element window: the value is KEPT and gains `@ts-expect-error`, because the refusal this test pins is the FACE's, raised past a door `POST /analytics/dataset/query` never opens — deleting the case would delete #17593's own coverage. Lines 499/503 are an ordinary two-bound window whose `const` had no annotation, so TypeScript widened the literal to `string[]`; it is annotated `AnalyticsQuery`. Neither touches any face's behaviour. No open PR touches `packages/services/service-analytics`. A repo-wide sweep for the same two shapes found no other site: exactly two `dateRange` array literals in the tree have arity other than 2 (this one, and this card's own `@ts-expect-error` case), and every remaining literal sits in a directly contextually-typed position. ## Premise checks **Two line numbers in the ruling and the card are stale.** The ruling cites the array arm at `analytics.zod.ts:326` and `analyticsDateRangeRefusalMessage` at `:284`. On `origin/main` `1bdbf82cb5` those lines carry unrelated text; the live ones before this diff were **`:418`** and **`:376`** (they are `:496` and `:425` after it). Both symbols are unique by name, so the ruling's subject is unambiguous — only the citations moved. **Why item 2 lands here: `#17918` does not exist.** The 2026-09-13 re-affirmation routes item 2 to a split card, `#17918`, and permits "one PR or #17918 first". `GET /repos//issues/17918` returns **HTTP 404** on a credential that answers 200 for #17593, #17596, #17124 and #16322 on the same call shape, so the 404 is that card's own and not a permissions artefact. Item 2 therefore has no other carrier, and the ruling's own permission to land both in one PR is what is being used here. This PR does not name that number in any closing keyword. **`Clause-②: no` holds against the mechanical check.** `scripts/pm/check-widening-tells.mjs --declaration no --diff` over this branch's full diff: **exit 0**, no widening tell. 3 files judged against a declared surface (the schema source, the migrations registry and the new entry), 11 NOT MEASURED — the ADR-0087 registration did not fire T4. The declaration stands as the ruling wrote it and is not flipped. ## Verification Control, at the tip: ``` pnpm --filter @objectstack/spec exec vitest run --project local src/data/analytics-date-range-two-bound-window.test.ts src/data/analytics-date-range-closed-vocabulary.test.ts scripts/format-type.test.ts :: exit 0 — 3 files, 113 tests passed pnpm --filter @objectstack/core exec vitest run --project local src/utils/analytics-date-range.test.ts :: exit 0 — 45 tests passed pnpm --filter @objectstack/spec typecheck :: exit 0 pnpm --filter @objectstack/core exec tsc --noEmit :: exit 0 pnpm --filter @objectstack/spec check:generated :: exit 0 — all 15 generated artifacts up to date pnpm lint :: exit 0 (full repo scan, at 5db9a5d) pnpm check:nul-bytes :: exit 0 ``` Negative control for item 1, the whole point of the new suite: with `analytics.zod.ts` reverted to the merge base (on-disk proof: tuple-arm occurrences 1 -> 0; blob 69e3ff5 -> 8c2cce9), the two spec suites go **10 failed / 13 passed** — every `refuses ...` case and every origin case. Restored from `HEAD`, `git diff HEAD` empty. Honest reading of the sibling suite: `analytics-date-range-closed-vocabulary.test.ts` PASSES under that ablation. Its only edits are the now-required `'schema'` argument (ignored by the one-parameter function) and one renamed `it` title — "keeps the array arm exactly as it was" became "still accepts every two-bound array window", because the old title is false after this change. **No assertion in it was weakened, deleted, loosened or skipped**; its three windows all had two bounds already, and the diff is 3 changed lines. 60 gate families run in total, 58 exit 0. Two did not measure anything and are recorded as such, not as passes: `check:type-check-debt` exits **3** = `PREREQUISITE NOT MET` (29 workspace dependencies have no built type entry point in this worktree) and `check:skill-examples` exits 1 on the same prerequisite (`packages/client-react/dist` holds no declarations). Both build the closure in CI. The remaining derived families are repo-wide scans left to CI, per `scripts/pm/dispatch-gates.mjs --ran` (115 derived, 30 recorded in the first reconciliation pass, 30 more run after it). ## Acceptance notes Noted, not filed — each names who would meet it: - `packages/services/service-analytics/src/date-range-array-arm.ts` overwrites the shared refusal sentence and documents why: "the shared wording ... ends with 'Refused at the schema', and neither is true of an array refused past the schema door by a face". After this PR that justification is gone — `analyticsDateRangeRefusalMessage(dateRange, 'runtime')` now says something true — so the repo keeps one condition with two wordings for no remaining reason. That file is fenced to PR #17593's landing for this round. Successor: whoever next touches that file, or a follow-up that finishes the #5240 convergence item 2 opened the door for. - The same file's header states "`AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no length constraint" and "Tightening `AnalyticsDateRangeSchema` itself is `packages/spec`'s call and is deliberately NOT done here". Both sentences are true of the tree this PR starts from and false of the tree it produces. Same fence, same successor. - `content/docs/references/data/filter.mdx` and `ui/view.mdx` gain corrected tuple cells with no source change behind them, as set out in scope decision B. Successor: none needed — they are generated, and the regeneration is in this PR. --- ## Fix-up round 2 — F1 + F2 + F4 Scope from the at-tier review record (PR comment `5672172206`) and the seat's rulings (card comment `5672187836`): **F1 + F2 + F4, nothing else.** Run in `session_01KB5PFtxuy1x3dcR5gxudx6`. Head `ae337c01a9`. The LEVEL of the changeset is untouched — `minor` is ruled. ### Verdict on the rescue commit `1974dd56dc` — right to act, wrong on the fact; superseded That commit (seat-made, marked INCOMPLETE AND UNREVIEWED) rewrote the justification to read "the string-arm closing on this same schema (#16322) declared `"@objectstack/spec": patch`". It was right that the original sentence ("the same grade every other accept-set narrowing on this schema has taken") sourced nothing. **Its replacement attribution does not hold, measured on this tree:** - `.changeset/analytics-daterange-driver-alignment.md` (#16322) is still in `.changeset/`, unreleased, and describes its own spec entry as **"a `PROVENANCE_WAIVERS` row only"** — the driver half of #16041, not an accept-set narrowing at the schema. - The accept-set narrowing on this schema is **#16041**, and it shipped `"@objectstack/spec": minor` — `packages/spec/CHANGELOG.md` 17.4.0, commit `233222e`, under `### Minor Changes` (heading at :5, entry at :60, no intervening heading). So the review's F4 comparison was drawn against the wrong entry, and repeating it would have compiled a second inaccurate justification into the shipped CHANGELOG — the exact defect F4 names. The sentence now names #16041, its grade and where to read it, and disposes of #16322 explicitly so the next reader does not re-litigate it from the same mis-reading. The ruling is executed (the justification is corrected), not reopened (the level is not touched). ### F1 — a sentence that ships to npm was false **Door measurement taken here, not adopted from the review.** `datasetSelectionRefusal` driven directly over the built dist: | `dateRange` | status | code | message ends | |:--|--:|:--|:--| | `['2026-01-01']` | 400 | `ANALYTICS_DATE_RANGE_UNRECOGNIZED` | `… received a 1-element array, not the two bounds [start, end]. Refused at the schema (…)` | | `[]` | 400 | same | `… received an empty array … Refused at the schema (…)` | | `['a','b','c']` | 400 | same | `… received a 3-element array … Refused at the schema (…)` | | `['2026-01-01','2026-01-31']` | — | — | passes (`undefined`) | Mechanism confirmed in source: `analytics-selection-door.ts` parses the selection's shared members — `timeDimensions` included — against `AnalyticsQuerySchema.pick(…)`, and `rest-server.ts:10981` calls it before `queryDataset`. ⇒ the one route the TSDoc named as a "past the door" origin is a **schema-origin door**. Corrected at four sites, all naming the in-process `AnalyticsService.query` / `queryDataset` / dataset-executor path as the runtime origin and stating that every REST analytics route is a schema-origin door: 1. `packages/spec/src/data/analytics.zod.ts` — the shipped one (`files[]` carries `src/**/*.zod.ts` literally). 2. `packages/core/src/utils/analytics-date-range.ts` Reachability — pre-existing on the base, carried per the ruling. 3. `packages/core/src/utils/analytics-date-range.ts` "Every refusal raised here is raised PAST the schema door" — this PR's own new prose, which the review flagged as untrue of the REST dataset door. Narrowed to refusals whose MESSAGE leaves the constructor, with the one caller that keeps only `.code`/`.status` named. 4. the `service-analytics` `objectql-daterange.test.ts` comment. ### F2 — one condition, two wordings on the wire **Premise check first (falsifiable, and it holds):** on the base the arm was `z.array(z.string())` with no length constraint, so a 1-element array was not refused by the schema at all. No schema refusal, no second entry ⇒ this PR introduces the second wording, and it is this PR's to collapse. **Wire measurement, before:** on the dataset door, `['2026-01-01']` served `code: ANALYTICS_DATE_RANGE_UNRECOGNIZED` with a message ending `… Refused at the schema (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400).; selection.timeDimensions.0.dateRange: Too small: expected array to have >=2 items` — the prescription, then zod's restatement of the very arity it prescribes. A body wrong in more than the dateRange carried the same pair as two `fields[]` entries, so the defect was not confined to the lifted path. **Wire measurement, after** (same probe, same tree): `Too small` / `Too big` occurrences = 0 for `['2026-01-01']`, `[]` and `['a','b','c']`; prescription occurrences = 1; the legal window and the preset still pass; and the mixed body still carries `selection.measures` beside exactly one `selection.timeDimensions.0.dateRange` entry. **Where the collapse lives, and why there.** In `fieldsFromZodIssues` (`packages/types/src/validation-failure.ts`), keyed on the exported structural recogniser `isAnalyticsDateRangeRefusalIssue`. Both REST doors report through that one mapper — the runtime `/analytics/query` door already did; the dataset door now does too, instead of reading the raw D3 `zodIssuesToFields` — so the rule has ONE implementation. The alternatives were each worse: a copy at both doors is the two-copies-of-one-policy shape #8124/#8318 exist to end; the selection policy in `shared/union-branch-policy.ts` is shared with the terminal renderer and pinned against it by a parity test, so a rule added there would have to be schema-agnostic; and a schema-agnostic rule ("drop branch issues at the union's own path") silences the `#4001` unknown-key prescriptions, which are raised at exactly that position. A new exported helper was rejected too: T3 of `check-widening-tells.mjs` reads a new `api-surface` row as a widening tell, and this PR's claim declares `Clause-②: no`. **What is deliberately NOT collapsed** — this narrows a restatement, never a diagnosis: - a branch issue naming a DEEPER position is kept. `['2026-01-01', 3]` still reports `selection.timeDimensions.0.dateRange.1` — WHICH bound is not a string is a location the prescription does not carry. - every other union expands exactly as before. ⛔ Union-branch expansion is not suppressed wholesale, so the stop condition in the ruling was not reached. **Pinned on the wire, in both directions** (the review recorded that nothing pinned it either way): 4 cases at the dataset door, 4 at `/analytics/query`, 5 in the shared mapper's own suite — including a CONTROL that a branch issue at its own branch root (`unrecognized_keys`, the same structural position) still reaches the wire for every other key. `@objectstack/types` and `@objectstack/rest` join the changeset: both publish a changed wire. ### Verification on `ae337c01a9` - Derived gate union (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived on the final head): **113 of 115 exit 0.** - The other 2 are `PREREQUISITE NOT MET`, exit 3, printing "⛔ This is NOT a pass: nothing was measured" — `check:dual-build-cjs-loads` and `check:type-check-debt`, both of which need the whole monorepo built. **NOT MEASURED**, not red; CI builds fresh. `check:skill-examples` refused the same way at first and was cleared by building `client-react` / `client`: 258 examples over 3 surfaces, exit 0. - `pnpm lint` (repo-wide, `eslint . --no-inline-config`): exit 0 — the whole population, so no narrowing to justify. - Full suites, all exit 0: spec 13624 · runtime 3644 · rest 3201 (+1 skipped) · service-analytics 2392 · core 1316 · types 671. - `typecheck` exit 0: spec, core, types, rest, runtime; `service-analytics tsc --noEmit` exit 0 with 0 errors (the review saw 38 there, all from unbuilt workspace deps in its worktree). - `check:type-source-resolution` exit 0 (135 tsc programs, 80 packages) — the new `@objectstack/spec/data` import in `packages/types` is a fifth subpath on a dependency that package already declares. - `dispatch-gates` prints a STALE TREE warning: its one stale input is `scripts/pm/check-harness-current.mjs`, added on `main` after this branch, whose own header says "⛔ not a CI gate" — so it adds no family here. ### Acceptance notes (noted, not filed) - `packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts:32` carries the same "which types its selection from `AnalyticsQuery` and NEVER Zod-parses it" sentence about `POST /analytics/dataset/query`. It is pre-existing (not in this diff) and the ruling scoped this round to F1 + F2 + F4 naming three sites, so it is left alone. Not shipped prose — a test comment. Carrier: the next PR in this family, or #18232. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eac58c3 commit 75237a9

20 files changed

Lines changed: 892 additions & 52 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/types": patch
5+
"@objectstack/rest": patch
6+
---
7+
8+
fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bounds, and each refusal ORIGIN gets a true sentence (#17598; ruling A, decision batch #117 item 3)
9+
10+
<!-- adr-0087: registered analytics-date-range-array-two-bounds-required -->
11+
12+
**BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as
13+
`minor` under this repo's launch-window convention for breaking changes
14+
(`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix`
15+
commit type sets, and the same grade the one comparable precedent took: the
16+
STRING-arm closing on this same schema is #16041, and it shipped
17+
`"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor
18+
Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but
19+
that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row
20+
only", not an accept-set narrowing, so it is not a grade this one is measured
21+
against. The maintainer
22+
ruling calls it a "major changeset"; under the launch window that phrase maps to
23+
the protocol MAJOR the migration registers against (18), not to the changeset's
24+
bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic
25+
prescription is registered under protocol major 18 as
26+
`analytics-date-range-array-two-bounds-required`.
27+
28+
### What changed
29+
30+
`AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length
31+
constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid.
32+
It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length
33+
refinement, so the arity is stated to the author's compiler before any parse runs.
34+
Preset names, two-bound windows and an absent `dateRange` parse byte-identically
35+
to before.
36+
37+
`analyticsDateRangeRefusalMessage(input)` becomes
38+
`analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or
39+
`'runtime'` and is **required** — there is deliberately no default.
40+
41+
### Migration: FROM → TO
42+
43+
| You wrote | Write instead |
44+
| --- | --- |
45+
| `dateRange: ['2026-01-20']` | `dateRange: ['2026-01-20', '2026-01-20']` — a single day is that day as both bounds, the shape the shipped #16322 table already prescribes |
46+
| `dateRange: []` | no conversion. An empty array names no window: write the two bounds the widget was meant to show, or omit `dateRange` (it is optional, and absent means the query is not time-bounded) |
47+
| `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them |
48+
| `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one |
49+
50+
`os migrate meta --from 17` emits the first three as a structured TODO rather than
51+
rewriting them: rewriting a one-element array to the same day twice at load would
52+
be the platform deciding, silently, that the author meant one day rather than a
53+
window whose end they forgot, and for the other two shapes there is nothing to
54+
decide from.
55+
56+
### Why it is not a new class of breakage
57+
58+
Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`,
59+
the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything
60+
that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so
61+
every stored range this narrowing refuses was **already failing at query time**.
62+
The contract door was looser than every reader behind it; this moves the refusal
63+
to authoring time and states it accurately. Blast radius is the WIDGET, not the
64+
page: a stored dashboard carrying a now-refused range loses that widget with the
65+
refusal shown and still loads.
66+
67+
### The wording half
68+
69+
The shared sentence ended `"Refused at the schema"` and described every refused
70+
array as `"received an array with a non-string bound"`. For a one-element window
71+
refused by a face **both clauses were false** — every bound present is a string,
72+
and it was refused past the schema, not at it — which is why
73+
`@objectstack/service-analytics` had to overwrite the message rather than reuse it,
74+
leaving one condition with two wordings. The origin is now a parameter and the
75+
`received …` clause names the arity and the bad bound separately, so the sentence
76+
is true for each origin both before and after the arm narrows.
77+
78+
The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a
79+
second voice: its arm answers `Too small: expected array to have >=2 items` for
80+
the very arity the prescription just prescribed, and the ADR-0114 union
81+
expansion emitted both as `fields[]` entries on `POST /analytics/query` and
82+
`POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`),
83+
the one mapper both doors report through, now drops the branch issues that land
84+
at the union's OWN path for this refusal — recognised structurally through
85+
`isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names
86+
a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports
87+
`timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a
88+
location the prescription does not carry. Every other union expands exactly as
89+
before. Client-visible effect: one `fields[]` entry for an arity refusal instead
90+
of two, with the prescriptive one kept.

‎content/docs/references/api/analytics.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ const result = AnalyticsEndpoint.parse(data);
8484
| **measures** | `string[]` | ✅ | List of metrics to calculate |
8585
| **dimensions** | `string[]` | optional | List of dimensions to group by |
8686
| **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`). |
87-
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'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. |
87+
| **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. |
8888
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
8989
| **limit** | `number` | optional | |
9090
| **offset** | `number` | optional | |
@@ -98,7 +98,7 @@ const result = AnalyticsEndpoint.parse(data);
9898
| :--- | :--- | :--- | :--- |
9999
| **dimension** | `string` | ✅ | |
100100
| **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
101-
| **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`. |
101+
| **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`. |
102102

103103

104104
---

‎content/docs/references/data/analytics.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Allowed Values: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `l
6060

6161
#### Option 2
6262

63-
Type: `string[]`
63+
Type: `[string, string]`
6464

6565
---
6666

@@ -98,7 +98,7 @@ Type: `string[]`
9898
| **measures** | `string[]` | ✅ | List of metrics to calculate |
9999
| **dimensions** | `string[]` | optional | List of dimensions to group by |
100100
| **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`). |
101-
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'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. |
101+
| **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. |
102102
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
103103
| **limit** | `number` | optional | |
104104
| **offset** | `number` | optional | |
@@ -110,7 +110,7 @@ Type: `string[]`
110110
| :--- | :--- | :--- | :--- |
111111
| **dimension** | `string` | ✅ | |
112112
| **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
113-
| **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`. |
113+
| **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`. |
114114

115115

116116
---

0 commit comments

Comments
 (0)