|
| 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. |
0 commit comments