diff --git a/.changeset/17598-analytics-date-range-two-bound-window.md b/.changeset/17598-analytics-date-range-two-bound-window.md new file mode 100644 index 00000000000..bfc2e258f51 --- /dev/null +++ b/.changeset/17598-analytics-date-range-two-bound-window.md @@ -0,0 +1,90 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +"@objectstack/types": patch +"@objectstack/rest": patch +--- + +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) + + + +**BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as +`minor` under this repo's launch-window convention for breaking changes +(`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix` +commit type sets, and the same grade the one comparable precedent took: the +STRING-arm closing on this same schema is #16041, and it shipped +`"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor +Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but +that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row +only", not an accept-set narrowing, so it is not a grade this one is measured +against. The maintainer +ruling calls it a "major changeset"; under the launch window that phrase maps to +the protocol MAJOR the migration registers against (18), not to the changeset's +bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic +prescription is registered under protocol major 18 as +`analytics-date-range-array-two-bounds-required`. + +### What changed + +`AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length +constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid. +It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length +refinement, so the arity is stated to the author's compiler before any parse runs. +Preset names, two-bound windows and an absent `dateRange` parse byte-identically +to before. + +`analyticsDateRangeRefusalMessage(input)` becomes +`analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or +`'runtime'` and is **required** — there is deliberately no default. + +### Migration: FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `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 | +| `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) | +| `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them | +| `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one | + +`os migrate meta --from 17` emits the first three as a structured TODO rather than +rewriting them: rewriting a one-element array to the same day twice at load would +be the platform deciding, silently, that the author meant one day rather than a +window whose end they forgot, and for the other two shapes there is nothing to +decide from. + +### Why it is not a new class of breakage + +Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`, +the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything +that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so +every stored range this narrowing refuses was **already failing at query time**. +The contract door was looser than every reader behind it; this moves the refusal +to authoring time and states it accurately. Blast radius is the WIDGET, not the +page: a stored dashboard carrying a now-refused range loses that widget with the +refusal shown and still loads. + +### The wording half + +The shared sentence ended `"Refused at the schema"` and described every refused +array as `"received an array with a non-string bound"`. For a one-element window +refused by a face **both clauses were false** — every bound present is a string, +and it was refused past the schema, not at it — which is why +`@objectstack/service-analytics` had to overwrite the message rather than reuse it, +leaving one condition with two wordings. The origin is now a parameter and the +`received …` clause names the arity and the bad bound separately, so the sentence +is true for each origin both before and after the arm narrows. + +The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a +second voice: its arm answers `Too small: expected array to have >=2 items` for +the very arity the prescription just prescribed, and the ADR-0114 union +expansion emitted both as `fields[]` entries on `POST /analytics/query` and +`POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`), +the one mapper both doors report through, now drops the branch issues that land +at the union's OWN path for this refusal — recognised structurally through +`isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names +a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports +`timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a +location the prescription does not carry. Every other union expands exactly as +before. Client-visible effect: one `fields[]` entry for an arity refusal instead +of two, with the prescriptive one kept. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index da965b721c7..4c455e7e5bb 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -84,7 +84,7 @@ 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<'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. | +| **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. | | **order** | `Record>` | optional | | | **limit** | `number` | optional | | | **offset** | `number` | optional | | @@ -98,7 +98,7 @@ const result = AnalyticsEndpoint.parse(data); | :--- | :--- | :--- | :--- | | **dimension** | `string` | ✅ | | | **granularity** | `Enum<'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`. | +| **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`. | --- diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 817e13c5003..0212d3e742a 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -60,7 +60,7 @@ Allowed Values: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `l #### Option 2 -Type: `string[]` +Type: `[string, string]` --- @@ -98,7 +98,7 @@ Type: `string[]` | **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<'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. | +| **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. | | **order** | `Record>` | optional | | | **limit** | `number` | optional | | | **offset** | `number` | optional | | @@ -110,7 +110,7 @@ Type: `string[]` | :--- | :--- | :--- | :--- | | **dimension** | `string` | ✅ | | | **granularity** | `Enum<'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`. | +| **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`. | --- diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index 85714dbb1bc..e19865bc79f 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -109,7 +109,7 @@ const result = ComparisonOperatorSchema.parse(data); | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `any[]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -181,19 +181,19 @@ This schema accepts one of the following structures: #### Option 1 -Type: `any[]` +Type: `[string, string, any]` --- #### Option 2 -Type: `any[]` +Type: `[string, string]` --- #### Option 3 -Type: `[FilterArray](#filterarray)[]` +Type: `[string, [FilterArray](#filterarray), ...[FilterArray](#filterarray)[]]` --- @@ -235,7 +235,7 @@ Type: `[FilterArray](#filterarray)[]` | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `any[]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -258,7 +258,7 @@ Type: `[FilterArray](#filterarray)[]` | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `any[]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -281,7 +281,7 @@ Type: `[FilterArray](#filterarray)[]` | **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | | **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$between** | `any[]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -312,7 +312,7 @@ Type: `[FilterArray](#filterarray)[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$between** | `any[]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | +| **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string. A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 2fa435c1a5e..3bf385bc51c 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -773,7 +773,7 @@ Map view configuration | **titleField** | `string` | optional | Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on) | | **descriptionField** | `string` | optional | Field displayed as the marker description | | **zoom** | `number` | optional | Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records | -| **center** | `any[]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | +| **center** | `[number, number]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | --- @@ -1020,7 +1020,7 @@ View filter rule | **titleField** | `string` | optional | Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on) | | **descriptionField** | `string` | optional | Field displayed as the marker description | | **zoom** | `number` | optional | Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records | -| **center** | `any[]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | +| **center** | `[number, number]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | ### Nested Shape: `ListView.tree` @@ -1417,7 +1417,7 @@ View filter rule | **titleField** | `string` | optional | Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on) | | **descriptionField** | `string` | optional | Field displayed as the marker description | | **zoom** | `number` | optional | Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records | -| **center** | `any[]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | +| **center** | `[number, number]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | ### Nested Shape: `ObjectListView.tree` diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts index 9851dd32cbe..62aa519a77d 100644 --- a/packages/core/src/utils/analytics-date-range.test.ts +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -244,13 +244,20 @@ describe('#16322 — a string outside the vocabulary is REFUSED, not widened', ( expect(thrown!.status).toBe(400); }); - it('speaks the SPEC\'s wording — one condition, one sentence (#5240)', () => { + it('speaks the SPEC\'s wording for its OWN origin — one condition, one sentence (#5240)', () => { // ⛔ Not a second convention: the schema door answers this same text, // so an author correcting the value reads the same prescription - // wherever the refusal reached them. + // wherever the refusal reached them. [#17598 ②] What differs between + // the two doors is the one clause neither the input nor the wording + // can supply — WHERE it was refused — so the origin is asked for by + // name here. Passing `'schema'` instead would reproduce the defect this + // pins against: a refusal raised past the schema claiming it was the + // schema's. for (const bad of OUTSIDE) { expect(analyticsDateRangeUnrecognizedError(bad).message) - .toBe(analyticsDateRangeRefusalMessage(bad)); + .toBe(analyticsDateRangeRefusalMessage(bad, 'runtime')); + expect(analyticsDateRangeUnrecognizedError(bad).message) + .not.toBe(analyticsDateRangeRefusalMessage(bad, 'schema')); } }); diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 4f6d4829cab..3da399635e3 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -203,23 +203,55 @@ export function resolveAnalyticsDateRangePreset( * `service-analytics` strategies, because "memory and SQL refuse identically" * is a property a shared conformance fixture can only hold if there is one * refusal to hold. The wording is the spec's - * {@link analyticsDateRangeRefusalMessage} — the same sentence the schema door - * answers with (the #5240 convention: one condition, one wording), quoted - * rather than restated. + * {@link analyticsDateRangeRefusalMessage} (the #5240 convention: one + * condition, one wording), quoted rather than restated — asked for the + * `'runtime'` ORIGIN, which is the one this constructor has. + * + * ⚠️ That argument is not decoration (#17598 item ②). Every refusal whose + * MESSAGE leaves here is raised PAST the schema door, so the sentence the spec + * used to return unconditionally — "Refused at the schema" — was false for + * every one of them, and sent an author to inspect a parse call that never + * ran. The origin is a parameter precisely so this call site states the truth + * it alone knows; ⛔ it is never omitted and there is no default to omit it to. + * Three of the four callers keep the `.code`/`.status` and supply their OWN + * message: the REST dataset door (`rest/src/analytics-selection-door.ts`), + * which serves the schema's own prescription because its refusal IS the + * schema's, and the two face-side array arms + * (`service-analytics/src/date-range-array-arm.ts` and + * `driver-memory/src/memory-analytics.ts`), each of which names what its own + * face would otherwise have guessed. The sentence built here leaves only + * through this package's own string resolver below — reached in process, past + * every door — which is the caller the `'runtime'` origin describes. * * ⚠️ The code is registered under `@objectstack/runtime` (the door that names * the wire vocabulary) and this package carries a recorded provenance waiver * in `error-code-ledger.zod.ts` — the shared-constructor shape, the same one * `UPDATE_ID_MISMATCH` records. * - * Reachability: on `POST /analytics/query` and `/analytics/sql` the schema door - * refuses first and this never fires. It is the answer for the in-process - * caller past that door — `AnalyticsService.query`, a driver's cube face - * called directly, and `POST /analytics/dataset/query`, which types - * `selection.timeDimensions` from `AnalyticsQuery` but does not Zod-parse it. + * 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 + * the schema's own sentence, and this constructor contributes only its + * `.code`/`.status` to that answer. + * + * ⛔ Which is narrower than "this never fires". The array arm is + * `z.tuple([z.string(), z.string()])` — it judges arity and bound TYPE, never a + * bound's VALUE — so the residue it cannot refuse, a two-string tuple with an + * empty bound such as `['', '']`, passes every door and reaches this + * constructor at each face (`date-range-array-arm.ts`, `memory-analytics.ts`), + * which then replaces the message. What does hold on a REST route is about the + * SENTENCE: each of the three callers a route can reach supplies its own, and a + * string that passed the preset enum cannot reach the resolver's throw below. + * This is also the answer for the IN-PROCESS caller past those doors — + * `AnalyticsService.query`, `queryDataset` and the dataset executor behind it, + * and a driver's cube face called directly. */ export function analyticsDateRangeUnrecognizedError(input: unknown): Error { - const err = new Error(analyticsDateRangeRefusalMessage(input)) as Error & { + const err = new Error(analyticsDateRangeRefusalMessage(input, 'runtime')) as Error & { code?: string; status?: number; }; diff --git a/packages/rest/src/analytics-dataset-selection-door.test.ts b/packages/rest/src/analytics-dataset-selection-door.test.ts index db290ade1d2..7b5534cb435 100644 --- a/packages/rest/src/analytics-dataset-selection-door.test.ts +++ b/packages/rest/src/analytics-dataset-selection-door.test.ts @@ -371,3 +371,84 @@ describe('#17058 §5 — a valid selection still passes, and passes through unch } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// §5 — [#17598] an arity refusal is ONE condition with ONE wording ON THE WIRE +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The array arm is `z.tuple([z.string(), z.string()])` since #17598, so a + * 1-element window is refused HERE — on the base it was not refused by the + * schema at all, which is why the arm's own `Too small` text only started + * riding the wire with that narrowing. The prescription already names the + * arity, so the arm's restatement is dropped in `fieldsFromZodIssues` + * (`@objectstack/types`), the one mapper both analytics doors share. + * + * ⚠️ These assert the SERVED BODY. The review that found the second wording + * recorded that nothing pinned the wire in either direction; at `error.issues` + * the union has always been a single issue, so an issue-level pin would have + * stayed green through exactly this defect. + */ +describe('#17598 §5 — the arity refusal carries one wording on the wire', () => { + const arities: Array<[string, unknown]> = [ + ['one bound', ['2026-01-01']], + ['no bounds', []], + ['three bounds', ['2026-01-01', '2026-01-15', '2026-01-31']], + ]; + + for (const [name, dateRange] of arities) { + it(`${name}: the prescription, and NOT the arm's own arity text`, async () => { + const { res, queryDataset } = await post({ + dataset: inlineDataset, + selection: { + measures: ['revenue'], + timeDimensions: [{ dimension: 'close_date', dateRange }], + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(res.body.message).toContain('not the two bounds [start, end]'); + expect(res.body.message).not.toMatch(/Too (small|big)/); + // One entry for the member, so the `: ` join names + // it once — the shape a second wording showed up as. + expect(String(res.body.message).match(/selection\.timeDimensions\.0\.dateRange/g)) + .toHaveLength(1); + expect(queryDataset).not.toHaveBeenCalled(); + }); + } + + it('a selection wrong in MORE than the dateRange keeps every other diagnosis', async () => { + // ⛔ The collapse is not a silencer: the generic envelope still carries + // the other member, and the dateRange condition appears exactly once. + const { res } = await post({ + dataset: inlineDataset, + selection: { + measures: ['revenue'], + dimensions: 'region', + timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-01'] }], + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + const fields: Array<{ field: string; message: string }> = res.body.details.fields; + expect(fields.map((f) => f.field)).toContain('selection.dimensions'); + expect(fields.filter((f) => f.field === 'selection.timeDimensions.0.dateRange')) + .toHaveLength(1); + expect(fields.some((f) => /Too (small|big)/.test(f.message))).toBe(false); + }); + + it('a non-string bound still names WHICH bound — that is a location, not a restatement', async () => { + const { res } = await post({ + dataset: inlineDataset, + selection: { + measures: ['revenue'], + timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-01', 3] }], + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toContain('selection.timeDimensions.0.dateRange.1'); + }); +}); diff --git a/packages/rest/src/analytics-selection-door.ts b/packages/rest/src/analytics-selection-door.ts index 987fdea03b2..b9dd1734ba8 100644 --- a/packages/rest/src/analytics-selection-door.ts +++ b/packages/rest/src/analytics-selection-door.ts @@ -76,9 +76,18 @@ * own TSDoc names this route as the caller it was waiting for. * * The `message` is built the way the sibling builds it — `: ` - * joined — over `zodIssuesToFields`, the one ADR-0114 D3 mapper. Field paths - * are prefixed `selection.` because they are reported against the REQUEST - * body, where the parsed object sits one level down. + * joined — over `fieldsFromZodIssues` (`@objectstack/types`), which is + * `zodIssuesToFields`, the one ADR-0114 D3 mapper, plus the two things every + * HTTP boundary owes on top of it: the root-path rename, and [#17598] the drop + * of the date-range union's own arm RESTATEMENT, so an arity refusal reaches + * the wire with ONE wording rather than the prescription followed by zod's + * `Too small: expected array to have >=2 items`. That collapse lives in the one + * mapper both analytics doors share, ⛔ never as a second copy here — which is + * why this door reads the wrapper rather than the raw D3 function. (The rename + * is inert here: the projection is always an object built from declared members + * only, so no issue of this parse lands at the root.) Field paths are prefixed + * `selection.` because they are reported against the REQUEST body, where the + * parsed object sits one level down. * * Validation-only: the caller's `selection` is forwarded to the service * untouched, never the parse output — the rule `assertAnalyticsQueryBody` @@ -86,7 +95,7 @@ * not silently override the engine's own resolution chain). */ -import { zodIssuesToFields } from '@objectstack/spec/api'; +import { fieldsFromZodIssues } from '@objectstack/types'; import { analyticsDateRangeUnrecognizedError } from '@objectstack/core'; /** @@ -166,9 +175,13 @@ export async function datasetSelectionRefusal( const parsed = schema.safeParse(projection); if (parsed.success) return undefined; - const issues: Array<{ code: string; path: ReadonlyArray; input?: unknown }> = - parsed.error.issues; - const fields = zodIssuesToFields(issues, projection).map((entry) => ({ + const issues: Array<{ + code: string; + path: Array; + message: string; + input?: unknown; + }> = parsed.error.issues; + const fields = fieldsFromZodIssues(issues, projection).map((entry) => ({ ...entry, field: `selection.${entry.field}`, })); diff --git a/packages/runtime/src/analytics-daterange-refusal-envelope.test.ts b/packages/runtime/src/analytics-daterange-refusal-envelope.test.ts index 1538f0974c2..a077ff5efee 100644 --- a/packages/runtime/src/analytics-daterange-refusal-envelope.test.ts +++ b/packages/runtime/src/analytics-daterange-refusal-envelope.test.ts @@ -143,3 +143,47 @@ describe('#16041 — /analytics/query refuses an unrecognised dateRange string a expect(calls.query).toEqual([]); }); }); + +/** + * [#17598] The same door, the arity half. The array arm is exactly two string + * bounds now, so `['2026-01-01']` is refused here; the union's own issue is the + * prescription and names the arity, and the tuple arm's `Too small: expected + * array to have >=2 items` is that same condition in zod's words. One condition + * keeps one wording (#5240), ON THE WIRE — the served envelope, not + * `error.issues`, where the union has always been a single issue. + */ +describe('#17598 — an arity refusal carries one wording on the wire', () => { + const arities: Array<[string, unknown]> = [ + ['one bound', ['2026-01-01']], + ['no bounds', []], + ['three bounds', ['2026-01-01', '2026-01-15', '2026-01-31']], + ]; + + for (const [name, dateRange] of arities) { + it(`${name}: the registered code, the prescription, and no second wording`, async () => { + const { res, calls } = await post('/analytics/query', body(dateRange)); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(res.body.error.message).toContain('not the two bounds [start, end]'); + expect(res.body.error.message).not.toMatch(/Too (small|big)/); + expect(String(res.body.error.message).match(/timeDimensions\.0\.dateRange/g)) + .toHaveLength(1); + expect(calls.query).toEqual([]); + }); + } + + it('a body wrong in MORE than the dateRange keeps the other diagnosis and still says the arity once', async () => { + const { res, calls } = await post('/analytics/query', body(['2026-01-01'], { granuarity: 'day' })); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + const fields: Array<{ field: string; code: string; message: string }> = res.body.error.details.fields; + // The typo'd key still speaks… + expect(fields.map((f) => f.code)).toContain('unknown_field'); + // …and the dateRange condition is named exactly once, prescription only. + expect(fields.filter((f) => f.field === 'timeDimensions.0.dateRange')).toHaveLength(1); + expect(fields.some((f) => /Too (small|big)/.test(f.message))).toBe(false); + expect(calls.query).toEqual([]); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts index 7de91509326..81bda02fd53 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts @@ -20,6 +20,7 @@ import { describe, it, expect } from 'vitest'; import { DatasetSchema } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsQuery } from '@objectstack/spec/data'; import { AnalyticsService } from '../analytics-service.js'; import { compileDataset } from '../dataset-compiler.js'; import { DatasetExecutor } from '../dataset-executor.js'; @@ -215,8 +216,15 @@ describe('ObjectQLStrategy — timeDimensions[].dateRange (#3650)', () => { cube: 'sales', dimensions: ['stage'], measures: ['revenue'], - // The schema types `dateRange` as a plain `string[]`, so this parses - // and reaches the face past the door. + // [#17598] The array arm now types `dateRange` as EXACTLY two string + // bounds, so this window no longer COMPILES — that is the schema door + // doing its half of the same rule. The half THIS test pins is the + // face's own refusal for a caller past that door — an IN-PROCESS + // `AnalyticsService.query` / `queryDataset` call, never a REST route: + // every analytics route parses `timeDimensions` at its own door and + // answers "Refused at the schema". So the value is still handed over + // and the type error is expected by name rather than the case deleted. + // @ts-expect-error — one bound is not a window; write the day twice timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-20'] }], }, ctx, @@ -488,7 +496,10 @@ describe('ObjectQLStrategy — cross-object FK-expand carries the window (#3650 executeAggregate: async () => [], }); - const query = { + // [#17598] Annotated, because the arm is a TUPLE now: an un-annotated + // `const` widens `['2026-01-01', '2026-01-31']` to `string[]`, which the + // two-bound window no longer accepts. The window itself is unchanged. + const query: AnalyticsQuery = { cube: 'sales_by_acct_date', dimensions: ['region'], measures: ['revenue'], diff --git a/packages/spec/scripts/format-type.test.ts b/packages/spec/scripts/format-type.test.ts index 2f6d7edeefd..6cb2d1ae678 100644 --- a/packages/spec/scripts/format-type.test.ts +++ b/packages/spec/scripts/format-type.test.ts @@ -82,6 +82,31 @@ describe('formatType — open objects keep their declared shape (#4912)', () => .toBe('Record[]'); }); + it('[#17598] prints a TUPLE by its positions, never as `any[]`', () => { + // Draft-2020-12 spells a `z.tuple([…])` as `prefixItems` and leaves `items` + // ABSENT, so the array branch used to render the element type of nothing — + // `any[]`, a cell strictly weaker than the schema beside it. Measured on + // `origin/main` before the fix: `ListView.map.center` + // (`z.tuple([z.number(), z.number()])`) printed `any[]` on + // `references/ui/view.mdx`, and #17598's two-bound `dateRange` window would + // have regressed `string[]` -> `any[]` on `references/data/analytics.mdx`. + expect(formatType({ type: 'array', prefixItems: [{ type: 'string' }, { type: 'string' }] }, ctx())) + .toBe('[string, string]'); + expect(formatType({ type: 'array', prefixItems: [{ type: 'number' }, { type: 'number' }] }, ctx())) + .toBe('[number, number]'); + // A REST element is spelled rather than dropped, so the cell never claims a + // fixed length the schema does not have. + expect(formatType( + { type: 'array', prefixItems: [{ type: 'string' }], items: { type: 'number' } }, + ctx(), + )).toBe('[string, ...number[]]'); + // Draft-7 spells the same tuple as an ARRAY in `items`; both are read. + expect(formatType({ type: 'array', items: [{ type: 'string' }, { enum: ['a', 'b'] }] }, ctx())) + .toBe("[string, Enum<'a' | 'b'>]"); + // Control: a plain array is untouched by the tuple branch. + expect(formatType({ type: 'array', items: { type: 'string' } }, ctx())).toBe('string[]'); + }); + it('ignores `&`-free nesting when deciding to parenthesize (no stray brackets)', () => { // `Enum<'a' | 'b'>` and markdown links carry `<>`/`[]`/`()` that must not // confuse the depth scan into either adding or skipping parens. diff --git a/packages/spec/scripts/lib/format-type.ts b/packages/spec/scripts/lib/format-type.ts index c1cfad06cfe..5d73339a6a2 100644 --- a/packages/spec/scripts/lib/format-type.ts +++ b/packages/spec/scripts/lib/format-type.ts @@ -919,6 +919,26 @@ function renderType(prop: any, ctx: TypeContext | undefined, depth: number): str } if (prop.type === 'array') { + // A TUPLE first. Draft-2020-12 spells the fixed positions as `prefixItems` + // and leaves `items` absent (draft-7 spelled the same thing as an ARRAY in + // `items`), so the element branch below would render the type of `undefined` + // and print `any[]` — a cell strictly WEAKER than the schema it describes, + // on every tuple in the spec. Measured before this branch existed: the + // `timeDimensions[].dateRange` window and `ListView.map.center` both read + // `any[]`, while the JSON Schema beside them carried both element types. + const positions = Array.isArray(prop.prefixItems) ? prop.prefixItems + : Array.isArray(prop.items) ? prop.items + : null; + if (positions) { + const rendered = positions.map((position: any) => renderType(position, ctx, depth)); + // A tuple may also declare a REST element (`z.tuple([…]).rest(x)`), which + // 2020-12 puts in `items` beside `prefixItems`. Spelling it keeps the cell + // from claiming a fixed length the schema does not have. + const rest = Array.isArray(prop.prefixItems) && prop.items && !Array.isArray(prop.items) + ? `, ...${renderType(prop.items, ctx, depth)}[]` + : ''; + return `[${rendered.join(', ')}${rest}]`; + } const element = renderType(prop.items, ctx, depth); // An open object element renders as an intersection and a multi-variant // element as a union — `[]` would re-associate either — so parenthesize diff --git a/packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts b/packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts index 9a67642c80b..233c53eb8f8 100644 --- a/packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts +++ b/packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts @@ -76,7 +76,7 @@ describe('AnalyticsQuerySchema.timeDimensions[].dateRange — closed vocabulary } }); - it('keeps the array arm exactly as it was — ISO dates or date-macro tokens', () => { + it('still accepts every two-bound array window — ISO dates or date-macro tokens', () => { for (const window of [ ['2023-01-01', '2023-01-31'], ['{7_days_ago}', '{today}'], @@ -115,7 +115,7 @@ describe('AnalyticsQuerySchema.timeDimensions[].dateRange — closed vocabulary if (parsed.success) continue; expect(parsed.error.issues, JSON.stringify(spelling)).toHaveLength(1); expect(parsed.error.issues[0].path).toEqual(RANGE_PATH); - expect(parsed.error.issues[0].message).toBe(analyticsDateRangeRefusalMessage(spelling)); + expect(parsed.error.issues[0].message).toBe(analyticsDateRangeRefusalMessage(spelling, 'schema')); expect(isAnalyticsDateRangeRefusalIssue(parsed.error.issues[0])).toBe(true); } }); @@ -147,7 +147,7 @@ describe('AnalyticsQuerySchema.timeDimensions[].dateRange — closed vocabulary }); it('spells the vocabulary in the refusal from the module, so the prescription cannot drift from the enum', () => { - const message = analyticsDateRangeRefusalMessage('Last 7 days'); + const message = analyticsDateRangeRefusalMessage('Last 7 days', 'schema'); for (const preset of DATE_RANGE_PRESETS) expect(message).toContain(preset); // And the standalone union reports the same wording as the nested field. const bare = AnalyticsDateRangeSchema.safeParse('Last 7 days'); diff --git a/packages/spec/src/data/analytics-date-range-two-bound-window.test.ts b/packages/spec/src/data/analytics-date-range-two-bound-window.test.ts new file mode 100644 index 00000000000..ac29b143aaf --- /dev/null +++ b/packages/spec/src/data/analytics-date-range-two-bound-window.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17598] `timeDimensions[].dateRange`'s ARRAY arm is EXACTLY two string + * bounds, and the one shared refusal sentence names its ORIGIN — maintainer + * ruling A, decision batch #117 item 3 (2026-09-12), re-affirmed 2026-09-13. + * + * ## The negative control is the refusal itself + * + * On `origin/main` the arm is `z.array(z.string())` with no length constraint, + * so every `refuses …` case in the first describe below PARSES CLEAN there and + * goes red — measured, not assumed: `['2026-01-01']`, `[]` and + * `['a', 'b', 'c']` were schema-valid and were then refused by all four of + * `service-analytics`' analytics faces (#17593). The door was looser than + * everything it guarded. An accept-assertion alone would prove nothing here, + * exactly as the sibling `analytics-date-range-closed-vocabulary.test.ts` + * records for the string arm. + * + * ## What the second describe is for + * + * Item ② of the same card: the shared sentence used to end + * "Refused at the schema" and to describe EVERY refused array as + * "an array with a non-string bound". For `['2026-01-01']` refused by a face, + * BOTH clauses were false — every bound present is a string, and it was + * refused past the schema, not at it. The ruling's acceptance criterion is + * that the wording be true for each origin BOTH BEFORE AND AFTER this arm + * narrows, so the assertions below judge the sentence against the input and + * the origin rather than pinning prose for its own sake. + */ + +import { describe, it, expect } from 'vitest'; +import { + AnalyticsDateRangeSchema, + AnalyticsQuerySchema, + analyticsDateRangeRefusalMessage, + isAnalyticsDateRangeRefusalIssue, + type AnalyticsQuery, +} from './analytics.zod'; +import { AnalyticsQueryRequestSchema } from '../api/analytics.zod'; + +const QUERY = { measures: ['orders.count'] }; +const withRange = (dateRange: unknown) => ({ + ...QUERY, + timeDimensions: [{ dimension: 'orders.created_at', granularity: 'day', dateRange }], +}); +const RANGE_PATH = ['timeDimensions', 0, 'dateRange']; + +/** Arities the contract's own prose and #16322's shipped table already excluded. */ +const REFUSED_ARITIES: ReadonlyArray = [ + [], // no window at all + ['2026-01-01'], // the shape #17124 measured three ways + ['2026-01-01', '2026-01-31', '2026-02-28'], // three bounds + ['{7_days_ago}'], // one macro token is a bound, not a window +]; + +describe('AnalyticsDateRangeSchema — the array arm is exactly two string bounds (#17598 ①)', () => { + it('accepts the two-bound windows the contract has always prescribed', () => { + // The control. Without it a narrowing that refused EVERY array would pass + // every refusal assertion below. + for (const window of [ + ['2026-01-01', '2026-01-31'], + ['{7_days_ago}', '{today}'], + ['2026-01-20', '2026-01-20'], // #16322's shipped single-day prescription + ]) { + expect(AnalyticsQuerySchema.safeParse(withRange(window)).success, JSON.stringify(window)).toBe(true); + expect(AnalyticsDateRangeSchema.safeParse(window).success, JSON.stringify(window)).toBe(true); + } + }); + + it.each(REFUSED_ARITIES.map((r) => [JSON.stringify(r), r] as const))( + 'refuses %s with ONE prescriptive issue at the field\'s own path', + (_label, range) => { + // Negative control: on the base commit's `z.array(z.string())` this parse + // SUCCEEDS and every assertion below is red. See the file header. + const parsed = AnalyticsQuerySchema.safeParse(withRange(range)); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues).toHaveLength(1); + const [issue] = parsed.error.issues; + expect(issue.path).toEqual(RANGE_PATH); + expect(issue.code).toBe('invalid_union'); + expect(issue.message).toBe(analyticsDateRangeRefusalMessage(range, 'schema')); + // The structural handle the runtime door lifts into the registered code, + // so an arity refusal answers the SAME ADR-0112 envelope as every other + // dateRange refusal rather than a bare 400. + expect(isAnalyticsDateRangeRefusalIssue(issue)).toBe(true); + }, + ); + + it('refuses the same arities identically through the /analytics/query body schema', () => { + for (const range of REFUSED_ARITIES) { + const parsed = AnalyticsQueryRequestSchema.safeParse({ cube: 'orders', ...withRange(range) }); + expect(parsed.success, JSON.stringify(range)).toBe(false); + if (parsed.success) continue; + expect(parsed.error.issues).toHaveLength(1); + expect(parsed.error.issues[0].path).toEqual(RANGE_PATH); + expect(isAnalyticsDateRangeRefusalIssue(parsed.error.issues[0])).toBe(true); + } + }); + + it('carries the migration prescription in the refusal itself — a single day is both bounds', () => { + // The ADR-0087 semantic entry tells an upgrading author the same thing; + // this is the sentence the author gets without reading it. + const message = analyticsDateRangeRefusalMessage(['2026-01-20'], 'schema'); + expect(message).toContain('["2026-01-20", "2026-01-20"]'); + expect(message).toContain('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + }); + + it('narrows the authored TYPE too — a one-element window no longer compiles', () => { + const accepted: NonNullable = [ + { dimension: 'created_at', dateRange: ['2026-01-20', '2026-01-20'] }, + { dimension: 'created_at', dateRange: 'last_7_days' }, + ]; + const refused: NonNullable = [ + // @ts-expect-error — one bound is not a window; write the day twice + { dimension: 'created_at', dateRange: ['2026-01-20'] }, + ]; + expect(accepted).toHaveLength(2); + expect(refused).toHaveLength(1); + }); +}); + +describe('analyticsDateRangeRefusalMessage — each ORIGIN gets a true sentence (#17598 ②)', () => { + it('states the schema origin only when the schema is where it was refused', () => { + for (const input of [...REFUSED_ARITIES, 'Last 7 days', 42, null]) { + const atSchema = analyticsDateRangeRefusalMessage(input, 'schema'); + const atRuntime = analyticsDateRangeRefusalMessage(input, 'runtime'); + expect(atSchema).toContain('Refused at the schema'); + // The clause that was false for every face-side refusal since #17593. + expect(atRuntime).not.toContain('Refused at the schema'); + expect(atRuntime).toContain('past the schema door'); + // One condition, one wording (#5240): only the origin clause differs. + expect(atRuntime.replace('Refused past the schema door, by the analytics reader that received it', 'Refused at the schema')) + .toBe(atSchema); + } + }); + + it('⭐ does not claim a non-string bound when every bound IS a string', () => { + // The exact sentence #17598 ② was filed about: `['2026-01-01']` refused by + // a face was told "received an array with a non-string bound". + for (const origin of ['schema', 'runtime'] as const) { + const message = analyticsDateRangeRefusalMessage(['2026-01-01'], origin); + expect(message).toContain('received a 1-element array, not the two bounds [start, end]'); + expect(message).not.toContain('non-string bound'); + } + expect(analyticsDateRangeRefusalMessage([], 'runtime')) + .toContain('received an empty array, not the two bounds [start, end]'); + expect(analyticsDateRangeRefusalMessage(['a', 'b', 'c'], 'runtime')) + .toContain('received a 3-element array, not the two bounds [start, end]'); + }); + + it('still names a bad bound when there is one, and names both faults together', () => { + // ⛔ Not a replacement of the old clause — a two-bound array with a + // non-string bound keeps exactly the description it had, because it was + // true. The arity clause is added for the shapes it was false for. + expect(analyticsDateRangeRefusalMessage(['2026-01-01', 3], 'schema')) + .toContain('received an array with a non-string bound'); + expect(analyticsDateRangeRefusalMessage(['2026-01-01', 3, null], 'schema')) + .toContain('received a 3-element array with a non-string bound, not the two bounds [start, end]'); + // And the non-array descriptions are untouched. + expect(analyticsDateRangeRefusalMessage(null, 'schema')).toContain('received null'); + expect(analyticsDateRangeRefusalMessage(42, 'schema')).toContain('received number'); + expect(analyticsDateRangeRefusalMessage({ start: '2026-01-01' }, 'schema')).toContain('received object'); + }); + + it('is true both BEFORE and AFTER the arm narrows — the ruling\'s acceptance criterion', () => { + // Before ① lands, `['2026-01-01']` is refused only at RUNTIME (the faces); + // after ① it is refused at the SCHEMA too. Both sentences describe the + // same input correctly, so answering ① did not falsify ②'s wording — which + // is the hazard the retriage named when it asked for the two to be split. + const runtime = analyticsDateRangeRefusalMessage(['2026-01-01'], 'runtime'); + const schema = analyticsDateRangeRefusalMessage(['2026-01-01'], 'schema'); + for (const message of [runtime, schema]) { + expect(message).toContain('1-element array'); + expect(message).not.toContain('non-string bound'); + expect(message).toContain('ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400'); + } + expect(runtime).not.toBe(schema); + }); +}); diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 8c2cce906a6..38ff996e882 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -365,37 +365,119 @@ export const AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS); /** The same names as {@link DateRangePreset} — declared through the schema so the alias cannot drift from it. */ export type AnalyticsDateRangePreset = z.input; +/** + * The `received …` clause of {@link analyticsDateRangeRefusalMessage} — what is + * actually wrong with a value that is not a string, stated so an author can act + * on it. + * + * ⚠️ Since the array arm narrowed to exactly two bounds (#17598) an array is + * refused for its ARITY with every bound a perfectly good string, so + * "an array with a non-string bound" — the one description this clause used to + * carry for every array — is FALSE for `['2026-01-01']`, `[]` and + * `[a, b, c]`. The arity and the bad bound are named separately, and neither is + * claimed when it is not true. + */ +function describeRefusedDateRange(input: unknown): string { + if (input === null) return 'null'; + if (!Array.isArray(input)) return typeof input; + const hasNonStringBound = input.some((bound) => typeof bound !== 'string'); + if (input.length === 2) { + // Two bounds is the arity the contract asks for, so the only way such an + // array reaches a refusal is a bound that is not a string. + return hasNonStringBound ? 'an array with a non-string bound' : 'a two-element array'; + } + const arity = input.length === 0 ? 'an empty array' : `a ${input.length}-element array`; + return hasNonStringBound + ? `${arity} with a non-string bound, not the two bounds [start, end]` + : `${arity}, not the two bounds [start, end]`; +} + /** * The one refusal wording for a `timeDimensions[].dateRange` value outside * the closed contract — shared by the schema door (this file) and, through * the `ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope, by the runtime door and * the drivers (#16322), so one condition keeps one wording (the #5240 * convention). A bare string is judged against {@link DATE_RANGE_PRESETS}; - * anything that is neither a preset name nor an array is described by type. + * anything that is neither a preset name nor a two-bound window is described + * by {@link describeRefusedDateRange}, i.e. by what is wrong with it. + * + * ## ⭐ The ORIGIN is a PARAMETER, not a sentence each caller rewrites + * + * Where the refusal happened is the one clause no INPUT can supply: the same + * value is refused at parse time by {@link AnalyticsDateRangeSchema} and, for a + * caller past that door, in-process by `analyticsDateRangeUnrecognizedError` + * (`@objectstack/core`) — `AnalyticsService.query`, `queryDataset` and the + * dataset executor behind it reached IN PROCESS, and a driver's cube face + * called directly. ⚠️ Every REST analytics route is a SCHEMA-origin door, + * `POST /analytics/dataset/query` included: since PR #17548, the PR that + * landed that door for card #17058, the route parses its selection's shared + * members — `timeDimensions` among them — against + * `AnalyticsQuerySchema.pick(…)` ahead of the executor, so that route's + * refusal is THIS schema's and says so. Until this parameter existed the + * shared sentence asserted the SCHEMA origin for both, so an author refused past + * the door was sent to inspect a parse call that never ran; the one package that + * noticed (`service-analytics`, #17593) had to OVERWRITE the message instead of + * reusing it, which is one condition with two wordings — exactly what the #5240 + * convention exists to prevent. + * + * ⛔ There is no default. A defaulted origin makes the same false assertion, + * silently, for every caller who does not think about it. + * + * @param input - the refused value, exactly as it arrived. + * @param origin - `'schema'` when {@link AnalyticsDateRangeSchema} itself refused + * the value at parse time, `'runtime'` when a reader past that door did. */ -export function analyticsDateRangeRefusalMessage(input: unknown): string { +export function analyticsDateRangeRefusalMessage( + input: unknown, + origin: 'schema' | 'runtime', +): string { const window = 'an explicit window is the two-element array [start, end] of ISO dates or ' - + '{date-macro} tokens — e.g. ["2026-01-01", "2026-01-31"] or ["{7_days_ago}", "{today}"]'; + + '{date-macro} tokens — e.g. ["2026-01-01", "2026-01-31"] or ["{7_days_ago}", "{today}"], ' + + 'and a single day is that day written as BOTH bounds — ["2026-01-20", "2026-01-20"]'; + const refusedAt = origin === 'schema' + ? 'Refused at the schema' + : 'Refused past the schema door, by the analytics reader that received it'; if (typeof input === 'string') { return ( `${JSON.stringify(input)} is not a dateRange the platform can resolve. A bare string must ` + `be one of the declared date-range PRESET names (${DATE_RANGE_PRESETS.join(', ')}) — the ` + `same closed vocabulary the dashboard date filter uses, case-sensitive, snake_case; ` - + `${window}. Refused at the schema (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400): an ` + + `${window}. ${refusedAt} (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400): an ` + 'unrecognised spelling used to reach the driver as written and silently widen the window ' + 'to every row instead of the one you named.' ); } - const received = input === null ? 'null' : Array.isArray(input) ? 'an array with a non-string bound' : typeof input; return ( `dateRange must be a date-range preset name (${DATE_RANGE_PRESETS.join(', ')}) or ` - + `${window}; received ${received}. Refused at the schema (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400).` + + `${window}; received ${describeRefusedDateRange(input)}. ` + + `${refusedAt} (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400).` ); } /** * `timeDimensions[].dateRange` — a preset name from the closed vocabulary, or - * an explicit `[start, end]` window. + * an explicit `[start, end]` window: EXACTLY two string bounds. + * + * ## ⭐ Why the array arm is a PAIR and not a `string[]` (#17598) + * + * It was `z.array(z.string())`, with no length constraint, while the refusal + * sentence three functions up said "the two-element array [start, end]" and + * #16322's shipped migration table told an author to write a single day as + * `['2026-01-20', '2026-01-20']`. So `['2026-01-01']`, `[]` and `[a, b, c]` + * passed the CONTRACT DOOR and were then refused by every reader behind it + * (#17593 aligned all four analytics faces on "not exactly two bounds is a + * refusal"): the door was looser than everything it guards, and only the TYPE + * was weaker than the sentence beside it. Maintainer ruling A on #17598 + * (decision batch #117 item 3, 2026-09-12) closed that gap in the direction + * the file already documented — an accept-set NARROWING back onto declared + * prose, not a new rule. + * + * A tuple rather than `z.array(z.string()).length(2)` because the ruling is + * that "the type says what the prose says": `[string, string]` states the + * arity to the AUTHOR's compiler, before any parse runs. The ADR-0087 semantic + * migration entry `analytics-date-range-array-two-bounds-required` carries the + * rewrite (a one-element window becomes the same day twice; an empty array and + * 3+ bounds have no conversion and get the structured TODO). * * @example * @@ -406,6 +488,7 @@ export function analyticsDateRangeRefusalMessage(input: unknown): string { * { dimension: 'created_at', granularity: 'day', dateRange: 'last_7_days' }, * { dimension: 'created_at', granularity: 'month', dateRange: ['2023-01-01', '2023-01-31'] }, * { dimension: 'created_at', dateRange: ['{30_days_ago}', '{today}'] }, + * { dimension: 'created_at', dateRange: ['2026-01-20', '2026-01-20'] }, // one day: both bounds * ]; * ``` * @@ -416,12 +499,22 @@ export function analyticsDateRangeRefusalMessage(input: unknown): string { * (registered in `api/error-code-ledger.zod.ts`). */ export const AnalyticsDateRangeSchema = z.union( - [AnalyticsDateRangePresetSchema, z.array(z.string())], + [AnalyticsDateRangePresetSchema, z.tuple([z.string(), z.string()])], { // Zod 4 reports a union with no matching arm as ONE `invalid_union` issue // at the union's own path, so the prescription lands on // `timeDimensions.N.dateRange` instead of on the two arms' generic texts. - error: (issue) => (issue.code === 'invalid_union' ? analyticsDateRangeRefusalMessage(issue.input) : undefined), + // That is also what keeps the ARITY refusal a single prescriptive issue + // rather than the tuple arm's own `too_big` / `too_small` text. + // ⚠️ At `error.issues` only. The ADR-0114 wire mapper EXPANDS a failed + // union into its branches (#5014), so the arm's arity text reaches a + // `fields[]` entry unless something drops it; `fieldsFromZodIssues` + // (`@objectstack/types`) does, for the branches at this union's own path, + // keyed on {@link isAnalyticsDateRangeRefusalIssue}. ⛔ Do not read this + // comment as covering the wire — that half is pinned at both REST doors. + error: (issue) => ( + issue.code === 'invalid_union' ? analyticsDateRangeRefusalMessage(issue.input, 'schema') : undefined + ), }, ); export type AnalyticsDateRange = z.input; diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts new file mode 100644 index 00000000000..9b9c51dd1d2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'analytics-date-range-array-two-bounds-required', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'the ARRAY arm of timeDimensions[].dateRange on an analytics query — ' + + 'AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset ' + + 'selection\'s timeDimensions, and any AnalyticsQuery a host passes to ' + + 'AnalyticsService.query in-process — authored with anything other than EXACTLY two ' + + 'string bounds: a one-element window such as ["2026-01-01"], the empty array [], and ' + + 'three or more bounds such as ["2026-01-01", "2026-01-31", "2026-02-28"]', + replacement: + 'exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as ' + + 'BOTH bounds: `[\'2026-01-01\']` becomes `[\'2026-01-01\', \'2026-01-01\']`, the shape ' + + 'the shipped #16322 migration table already prescribes for a single day, and the shape ' + + 'all four analytics faces have selected that one day with since PR #17593. ⛔ The EMPTY ' + + 'array and THREE-OR-MORE bounds have NO replacement that can be derived from what was ' + + 'written: an empty array names no window at all, and a 3+ array names no pair — decide ' + + 'the window the widget was meant to show and write its two bounds, or drop the ' + + 'dateRange entirely (the field is optional, and absent means the query is not ' + + 'time-bounded). A relative window is a preset name from the closed vocabulary ' + + '(`\'last_7_days\'`) or a date-macro pair (`[\'{7_days_ago}\', \'{today}\']`).', + reason: + 'Maintainer ruling A on #17598 (decision batch #117 item 3, 2026-09-12, re-affirmed ' + + '2026-09-13): the array arm was a bare `z.array(z.string())` with NO length constraint, ' + + 'while the refusal sentence in the same source file said verbatim that "an explicit ' + + 'window is the two-element array [start, end]" and #16322\'s shipped migration table ' + + 'told an author to write a single day as `[\'2026-01-20\', \'2026-01-20\']`. So only the ' + + 'TYPE was weaker than the prose beside it, and #17124 measured what that bought: one ' + + 'authored `[\'2026-01-01\']` meant a point window on ObjectQLStrategy, NO time clause at ' + + 'all on NativeSQLStrategy (the whole of history), an unbounded-above window in the ' + + 'draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the ' + + 'same document, four backends, four different numbers, no error on any of them. PR ' + + '#17593 made all four faces refuse it with the ADR-0112 envelope `400 ' + + 'ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every ' + + 'reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and ' + + 'no stored-metadata rewrite, deliberately: rewriting `[\'2026-01-01\']` to the same day ' + + 'twice at load would be the platform deciding, silently, that the author meant one day ' + + 'rather than a window whose end they forgot — and for the empty array and 3+ bounds ' + + 'there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored ' + + 'dashboard carrying a now-refused range loses that widget with the accurate refusal ' + + 'shown, and the dashboard still loads. Since PR #17593 every such stored range already ' + + 'failed at QUERY time with the same code and status, so this adds no new class of ' + + 'breakage — it moves the refusal to authoring time and states it accurately. ' + + 'ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep every authored `timeDimensions[].dateRange` ARRAY — dashboard widget datasets, saved ' + + 'analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and ' + + 'count its bounds. Two string bounds parse byte-identically to before, as do every preset ' + + 'name and an absent `dateRange`; anything else now answers one prescriptive issue at ' + + '`timeDimensions.N.dateRange` naming the arity it received, so `AnalyticsQuerySchema.' + + 'safeParse` and `POST /analytics/query` both make the sweep mechanical. ⚠️ Do not trust ' + + 'the numbers a one-element window used to produce: the four analytics faces disagreed ' + + 'about what it meant, so a widget that showed a plausible figure may have been reading ' + + 'all of history on one backend and a single day on another. Re-check what each converted ' + + 'widget was supposed to show against its two explicit bounds.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f14e9d37639..17989df36da 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5778,6 +5778,63 @@ const step18: MigrationStep = { + 'every `/analytics/query` body\'s `timeDimensions[]` items carry only ' + '`dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before.', }, + { + id: 'analytics-date-range-array-two-bounds-required', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'the ARRAY arm of timeDimensions[].dateRange on an analytics query — ' + + 'AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset ' + + 'selection\'s timeDimensions, and any AnalyticsQuery a host passes to ' + + 'AnalyticsService.query in-process — authored with anything other than EXACTLY two ' + + 'string bounds: a one-element window such as ["2026-01-01"], the empty array [], and ' + + 'three or more bounds such as ["2026-01-01", "2026-01-31", "2026-02-28"]', + replacement: + 'exactly two string bounds — `[start, end]`. A ONE-ELEMENT window is that day written as ' + + 'BOTH bounds: `[\'2026-01-01\']` becomes `[\'2026-01-01\', \'2026-01-01\']`, the shape ' + + 'the shipped #16322 migration table already prescribes for a single day, and the shape ' + + 'all four analytics faces have selected that one day with since PR #17593. ⛔ The EMPTY ' + + 'array and THREE-OR-MORE bounds have NO replacement that can be derived from what was ' + + 'written: an empty array names no window at all, and a 3+ array names no pair — decide ' + + 'the window the widget was meant to show and write its two bounds, or drop the ' + + 'dateRange entirely (the field is optional, and absent means the query is not ' + + 'time-bounded). A relative window is a preset name from the closed vocabulary ' + + '(`\'last_7_days\'`) or a date-macro pair (`[\'{7_days_ago}\', \'{today}\']`).', + reason: + 'Maintainer ruling A on #17598 (decision batch #117 item 3, 2026-09-12, re-affirmed ' + + '2026-09-13): the array arm was a bare `z.array(z.string())` with NO length constraint, ' + + 'while the refusal sentence in the same source file said verbatim that "an explicit ' + + 'window is the two-element array [start, end]" and #16322\'s shipped migration table ' + + 'told an author to write a single day as `[\'2026-01-20\', \'2026-01-20\']`. So only the ' + + 'TYPE was weaker than the prose beside it, and #17124 measured what that bought: one ' + + 'authored `[\'2026-01-01\']` meant a point window on ObjectQLStrategy, NO time clause at ' + + 'all on NativeSQLStrategy (the whole of history), an unbounded-above window in the ' + + 'draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the ' + + 'same document, four backends, four different numbers, no error on any of them. PR ' + + '#17593 made all four faces refuse it with the ADR-0112 envelope `400 ' + + 'ANALYTICS_DATE_RANGE_UNRECOGNIZED`, which left the contract door LOOSER than every ' + + 'reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and ' + + 'no stored-metadata rewrite, deliberately: rewriting `[\'2026-01-01\']` to the same day ' + + 'twice at load would be the platform deciding, silently, that the author meant one day ' + + 'rather than a window whose end they forgot — and for the empty array and 3+ bounds ' + + 'there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored ' + + 'dashboard carrying a now-refused range loses that widget with the accurate refusal ' + + 'shown, and the dashboard still loads. Since PR #17593 every such stored range already ' + + 'failed at QUERY time with the same code and status, so this adds no new class of ' + + 'breakage — it moves the refusal to authoring time and states it accurately. ' + + 'ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep every authored `timeDimensions[].dateRange` ARRAY — dashboard widget datasets, saved ' + + 'analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and ' + + 'count its bounds. Two string bounds parse byte-identically to before, as do every preset ' + + 'name and an absent `dateRange`; anything else now answers one prescriptive issue at ' + + '`timeDimensions.N.dateRange` naming the arity it received, so `AnalyticsQuerySchema.' + + 'safeParse` and `POST /analytics/query` both make the sweep mechanical. ⚠️ Do not trust ' + + 'the numbers a one-element window used to produce: the four analytics faces disagreed ' + + 'about what it meant, so a widget that showed a plausible figure may have been reading ' + + 'all of history on one backend and a single day on another. Re-check what each converted ' + + 'widget was supposed to show against its two explicit bounds.', + }, { id: 'analytics-time-dimension-date-range-vocabulary-closed', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a diff --git a/packages/types/src/validation-failure.test.ts b/packages/types/src/validation-failure.test.ts index 626a3430208..16cd3d5254a 100644 --- a/packages/types/src/validation-failure.test.ts +++ b/packages/types/src/validation-failure.test.ts @@ -17,7 +17,11 @@ */ import { describe, it, expect } from 'vitest'; -import { FieldErrorCode, MarkNotificationsReadRequestSchema } from '@objectstack/spec/api'; +import { + AnalyticsQueryRequestSchema, + FieldErrorCode, + MarkNotificationsReadRequestSchema, +} from '@objectstack/spec/api'; import { FlowSchema } from '@objectstack/spec/automation'; import { fieldsFromZodIssues } from './validation-failure'; @@ -101,3 +105,74 @@ describe('fieldsFromZodIssues — ADR-0114 D3 catalog codes, not Zod codes (#812 expect(informed.find((f) => f.field.endsWith('label'))?.code).toBe('required'); }); }); + +/** + * [#17598] ONE condition, ONE wording — for the `timeDimensions[].dateRange` + * refusal, on the wire and not merely at `error.issues`. + * + * `AnalyticsDateRangeSchema` is a `z.union` carrying its own error map, so the + * single issue zod raises is already a prescription AND already names the + * arity ("received a 1-element array, not the two bounds [start, end]"). Its + * tuple arm complains about the same value at the same path in zod's own words + * ("Too small: expected array to have >=2 items"), and the #5014 union + * expansion put both on the wire — one condition, two wordings, which is what + * the #5240 convention exists to prevent and what `analytics.zod.ts` claims for + * this refusal. + * + * Before #17598 the arm was `z.array(z.string())` with no length constraint, so + * a 1-element window was not refused by the schema at all and there was no + * second wording to have; the narrowing is what introduced it, and this is + * where it is collapsed. The two edge cases below are as load-bearing as the + * collapse itself: this narrows a RESTATEMENT, never a diagnosis. + */ +describe('fieldsFromZodIssues — the dateRange refusal keeps one wording (#17598)', () => { + const analyticsBody = (dateRange: unknown) => ({ + cube: 'orders', + measures: ['count'], + timeDimensions: [{ dimension: 'created_at', granularity: 'day', dateRange }], + }); + + const arities: Array<[string, unknown]> = [ + ['a 1-element window', ['2026-01-01']], + ['an empty array', []], + ['three bounds', ['2026-01-01', '2026-01-15', '2026-01-31']], + ]; + + for (const [name, dateRange] of arities) { + it(`${name} maps to exactly one entry, and it is the prescription`, () => { + const fields = fieldsFromZodIssues( + issuesOf(AnalyticsQueryRequestSchema, analyticsBody(dateRange)), + ); + expect(fields).toHaveLength(1); + expect(fields[0].field).toBe('timeDimensions.0.dateRange'); + expect(fields[0].message).toContain('not the two bounds [start, end]'); + // ⛔ The arm's own arity text is the second wording, and it is gone. + expect(fields[0].message).not.toMatch(/Too (small|big)/); + }); + } + + it('a NON-string bound keeps the branch entry naming WHICH bound is wrong', () => { + // The prescription says "an array with a non-string bound"; it does not + // say WHICH one. `dateRange.1` names a position the prescription has + // not, so it is a diagnosis rather than a restatement and it stays. + const fields = fieldsFromZodIssues( + issuesOf(AnalyticsQueryRequestSchema, analyticsBody(['2026-01-01', 3])), + ); + expect(fields.map((f) => f.field)).toContain('timeDimensions.0.dateRange'); + expect(fields.map((f) => f.field)).toContain('timeDimensions.0.dateRange.1'); + }); + + it('CONTROL — a branch issue at its own branch ROOT still reaches the wire for every other key', () => { + // `unrecognized_keys` is raised at the BRANCH root — structurally the + // same position as the tuple arm's arity text — and it carries the + // #4001 campaign's curated prose. If the collapse above were written as + // "drop branch issues at the union's own path" rather than keyed on the + // date-range recogniser, this is the family it would have silenced. + const fields = fieldsFromZodIssues(issuesOf(FlowSchema, { + ...WELL_FORMED_FLOW, + nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }], + })); + expect(fields.map((f) => f.code)).toContain('unknown_field'); + expect(fields.some((f) => f.message.includes('next'))).toBe(true); + }); +}); diff --git a/packages/types/src/validation-failure.ts b/packages/types/src/validation-failure.ts index 4bc03e259e2..1377a89f285 100644 --- a/packages/types/src/validation-failure.ts +++ b/packages/types/src/validation-failure.ts @@ -39,6 +39,7 @@ import { zodIssuesToFields } from '@objectstack/spec/api'; import type { FieldErrorCode } from '@objectstack/spec/api'; +import { isAnalyticsDateRangeRefusalIssue } from '@objectstack/spec/data'; /** The HTTP status a validation failure maps to when the error names none. */ export const VALIDATION_FAILED_STATUS = 400; @@ -111,7 +112,57 @@ export function fieldsFromZodIssues( issues: Array<{ path: Array; code: string; message: string }>, ...input: [] | [unknown] ): Array<{ field: string; code: FieldErrorCode; message: string }> { - return zodIssuesToFields(issues, ...input).map((entry) => + return zodIssuesToFields(issues.map(withoutDateRangeArityRestatement), ...input).map((entry) => entry.field === '' ? { ...entry, field: '(body)' } : entry, ); } + +/** + * [#17598] Drop the tuple arm's RESTATEMENT of a `timeDimensions[].dateRange` + * refusal before the union expansion above puts it on the wire. + * + * `AnalyticsDateRangeSchema` is a `z.union` carrying its own error map, so the + * one issue zod raises already reads as a prescription and already names the + * arity — `… received a 1-element array, not the two bounds [start, end]. + * Refused at the schema (…)`. Its tuple arm complains about the SAME value at + * the SAME path in zod's own words (`Too small: expected array to have >=2 + * items`), and the #5014 expansion faithfully emits both: one condition, two + * wordings on the wire, which is the #5240 convention's whole subject and the + * property `analytics.zod.ts` claims for this refusal. So the branch issues + * that land at the UNION'S OWN PATH are dropped and the prescription stands + * alone. + * + * ⚠️ **Only those.** A branch issue naming a DEEPER position — `dateRange.1` + * for `['2026-01-01', 3]` — says WHICH bound is not a string, which the + * prescription does not, so it is kept: this narrows a restatement, never a + * diagnosis. And the filter is keyed on + * {@link isAnalyticsDateRangeRefusalIssue}, the structural recogniser both + * analytics doors already lift this condition with (never message prose), so + * every other union on every other key expands exactly as it did before — + * ⛔ union-branch expansion is not suppressed wholesale, here or anywhere. + * + * A non-array `path` is left alone rather than read as the root — on the issue + * itself (which also keeps the recogniser, whose `path.length` read assumes + * zod's array, off a shape zod never produces) and on a branch issue. Zod + * always produces an array; the conservative reading keeps a junk issue visible + * instead of silently dropping it, which is the posture `zodIssuesToFields` + * takes one package over. + */ +function withoutDateRangeArityRestatement< + T extends { path: Array; code: string; message: string }, +>(issue: T): T { + const errors = (issue as { errors?: unknown }).errors; + if (!Array.isArray(errors) || !Array.isArray(issue?.path)) return issue; + if (!isAnalyticsDateRangeRefusalIssue(issue)) return issue; + return { + ...issue, + errors: errors.map((branch: unknown) => + Array.isArray(branch) + ? branch.filter((nested: unknown) => { + const path = (nested as { path?: unknown } | null | undefined)?.path; + return !Array.isArray(path) || path.length > 0; + }) + : branch, + ), + }; +}