From bebaaae983491e84c6fdeea3c914defbdcc14454 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 21:36:17 +0000 Subject: [PATCH 1/8] fix(spec): the dateRange array arm is exactly two string bounds, and each refusal origin gets a true sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with no length constraint, while the refusal sentence in the same file 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 refused by every reader behind it (#17593 aligned all four analytics faces on "not exactly two bounds is a refusal"). Only the TYPE was weaker than the prose beside it. The arm is now `z.tuple([z.string(), z.string()])`, so the type states the arity to the author's compiler before any parse runs. `analyticsDateRangeRefusalMessage` takes the refusal ORIGIN as a required parameter. "Refused at the schema" is the one clause no input can supply, and it was asserted unconditionally — false for every refusal raised past the schema door, which is why `service-analytics` had to overwrite the message rather than reuse it. The `received …` clause now names the arity and the bad bound separately instead of calling every refused array "an array with a non-string bound", which is false when every bound is a string. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../src/utils/analytics-date-range.test.ts | 13 +- .../core/src/utils/analytics-date-range.ts | 15 +- ...ytics-date-range-closed-vocabulary.test.ts | 6 +- ...lytics-date-range-two-bound-window.test.ts | 180 ++++++++++++++++++ packages/spec/src/data/analytics.zod.ts | 100 +++++++++- 5 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 packages/spec/src/data/analytics-date-range-two-bound-window.test.ts 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..50509debc78 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -203,9 +203,16 @@ 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 raised + * 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. * * ⚠️ The code is registered under `@objectstack/runtime` (the door that names * the wire vocabulary) and this package carries a recorded provenance waiver @@ -219,7 +226,7 @@ export function resolveAnalyticsDateRangePreset( * `selection.timeDimensions` from `AnalyticsQuery` but does not Zod-parse it. */ 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/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..69e3ff58f4a 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -365,37 +365,114 @@ 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`, a driver's cube face called + * directly, `POST /analytics/dataset/query`, which types its selection from + * `AnalyticsQuery` but never Zod-parses it. 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 +483,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 +494,16 @@ 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. + error: (issue) => ( + issue.code === 'invalid_union' ? analyticsDateRangeRefusalMessage(issue.input, 'schema') : undefined + ), }, ); export type AnalyticsDateRange = z.input; From 6076d400a1c72c7fc126994231817d0c188fcc0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 21:45:21 +0000 Subject: [PATCH 2/8] docs(spec): register the ADR-0087 semantic migration and the breaking changeset for the dateRange arity narrowing The one-element window converts to the same day written twice, the shape the shipped #16322 migration table already prescribes. The empty array and three or more bounds get no conversion and the structured TODO: an empty array names no window at all, and a 3+ array names no pair, so deriving either would be the platform inventing the window the author meant. Graded `minor` under the launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`), with the BREAKING banner and the ADR-0087 disposition carrying the breaking-ness, as the sibling accept-set narrowings on this schema did. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- ...8-analytics-date-range-two-bound-window.md | 67 +++++++++++++++++++ ...cs-date-range-array-two-bounds-required.ts | 61 +++++++++++++++++ packages/spec/src/migrations/registry.ts | 57 ++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 .changeset/17598-analytics-date-range-two-bound-window.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts 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..2223457a3cf --- /dev/null +++ b/.changeset/17598-analytics-date-range-two-bound-window.md @@ -0,0 +1,67 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +--- + +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, the same +grade every other accept-set narrowing on this schema has taken. 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. 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 From e555bf5be268008a92e8b6584d6371b5abe8ed2f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 22:02:37 +0000 Subject: [PATCH 3/8] fix(spec): the reference-docs renderer prints a tuple by its positions, never as `any[]` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft-2020-12 spells `z.tuple([...])` as `prefixItems` and leaves `items` absent, so `format-type.ts`'s array branch rendered the element type of nothing at all. Every tuple in the spec printed `any[]` — a published cell strictly weaker than the schema beside it, which carried both element types all along. Without this, narrowing the dateRange array arm to a two-bound window would have REGRESSED a published line from `string[]` to `any[]`: the card's whole point is a declared surface that says what the contract enforces, so shipping the narrowing with that cell would have been the same defect one layer out. Three published pages gain precision as a side effect and none loses any: `ListView.map.center` becomes `[number, number]`, `$between` becomes `[number | string, number | string]`, and the `FilterArray` variants print their positions. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- content/docs/references/api/analytics.mdx | 4 ++-- content/docs/references/data/analytics.mdx | 6 +++--- content/docs/references/data/filter.mdx | 16 +++++++------- content/docs/references/ui/view.mdx | 6 +++--- packages/spec/scripts/format-type.test.ts | 25 ++++++++++++++++++++++ packages/spec/scripts/lib/format-type.ts | 20 +++++++++++++++++ 6 files changed, 61 insertions(+), 16 deletions(-) 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/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 From 5db9a5d684cf7a8767d49082dfef5947e042d117 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 22:40:00 +0000 Subject: [PATCH 4/8] test(service-analytics): the two-bound window is stated to the compiler where the arity rule now lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array arm of `timeDimensions[].dateRange` is a `z.tuple([z.string(), z.string()])`, so two authored shapes in this suite stopped compiling — and both are consequences of that narrowing, not of anything this suite means: - the deliberate one-element window keeps its value and gains a `@ts-expect-error`, because the refusal it pins is the FACE's, raised past a schema door `POST /analytics/dataset/query` never opens; - the cross-object window is annotated `AnalyticsQuery`, because an un-annotated `const` widens `['2026-01-01', '2026-01-31']` to `string[]`. Measured: `pnpm --filter @objectstack/service-analytics exec tsc --noEmit` reports 3 errors in this file with the narrowed arm and 0 with the arm reverted; after this commit it reports 0, with the package's other error count unchanged. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../src/__tests__/objectql-daterange.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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..5e740a38cd3 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,14 @@ 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 (`POST + // /analytics/dataset/query` types its selection from `AnalyticsQuery` + // and never Zod-parses it), 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 +495,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'], From 1974dd56dc5c3fed27df200189a91fdc8c639902 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 23:41:44 +0000 Subject: [PATCH 5/8] wip(spec): the #17598 changeset justification, rescued from a dead worktree INCOMPLETE AND UNREVIEWED. This edit was uncommitted in the dispatch worktree when the container restarted and killed the fix-up round; the seat committed it so the work is not lost, and has NOT reviewed it. Observed state only: 1 modified path, .changeset/17598-analytics-date- range-two-bound-window.md, on top of 5db9a5d684. It addresses one of the three fix-up items (the inaccurate justification sentence). The other two items are NOT in this commit. The continuing round diffs this rather than trusting it. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .changeset/17598-analytics-date-range-two-bound-window.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/17598-analytics-date-range-two-bound-window.md b/.changeset/17598-analytics-date-range-two-bound-window.md index 2223457a3cf..785248b91a7 100644 --- a/.changeset/17598-analytics-date-range-two-bound-window.md +++ b/.changeset/17598-analytics-date-range-two-bound-window.md @@ -8,8 +8,11 @@ fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bound **BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as -`minor` under this repo's launch-window convention for breaking changes, the same -grade every other accept-set narrowing on this schema has taken. The maintainer +`minor` under this repo's launch-window convention for breaking changes +(`scripts/check-changeset-no-major.mjs`), which is the grade that convention +prescribes rather than the grade a precedent set: the string-arm closing on this +same schema (#16322) declared `"@objectstack/spec": patch`, so `minor` here RAISES +the level above the `fix` floor rather than repeating what came before. 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 From 70a0e63d41b4f82b595373e215919e71edec9032 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 00:09:59 +0000 Subject: [PATCH 6/8] wip(analytics): the shipped origin sentence names the real runtime caller, and the arity refusal keeps one wording on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 — `packages/spec/package.json` ships `src/**/*.zod.ts` literally, so the TSDoc at `analytics.zod.ts` reaches npm verbatim. It listed `POST /analytics/dataset/query` as a runtime-origin caller "which types its selection from `AnalyticsQuery` but never Zod-parses it". Measured on this tree, that route answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with a message ending `Refused at the schema (…)`: since #17058 its door parses the selection's shared members — `timeDimensions` included — against `AnalyticsQuerySchema.pick(…)` ahead of the executor. The sentence now names `queryDataset` / the dataset executor reached IN PROCESS as the runtime origin and states that every REST analytics route is a schema-origin door. Same correction at the core echo (which pre-exists on the base) and at the `service-analytics` test comment. F2 — on both REST doors the tuple arm's own `Too small: expected array to have >=2 items` rode along as a second `fields[]` entry beside the prescription: one condition, two wordings (#5240), introduced by this card's narrowing (on the base a 1-element array was not refused by the schema at all). The branch issues that land at the union's OWN path are now dropped in `fieldsFromZodIssues`, the one mapper both doors share, keyed on `isAnalyticsDateRangeRefusalIssue`. Branch issues naming a DEEPER position (`dateRange.1`, the non-string bound) are kept — this narrows a restatement, never a diagnosis — and no other union is touched. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../core/src/utils/analytics-date-range.ts | 29 ++++--- .../analytics-dataset-selection-door.test.ts | 81 +++++++++++++++++++ packages/rest/src/analytics-selection-door.ts | 21 +++-- ...alytics-daterange-refusal-envelope.test.ts | 44 ++++++++++ .../src/__tests__/objectql-daterange.test.ts | 9 ++- packages/spec/src/data/analytics.zod.ts | 10 ++- packages/types/src/validation-failure.test.ts | 77 +++++++++++++++++- packages/types/src/validation-failure.ts | 49 ++++++++++- 8 files changed, 295 insertions(+), 25 deletions(-) diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 50509debc78..d7d6bc68bde 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -207,23 +207,30 @@ export function resolveAnalyticsDateRangePreset( * 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 raised - * 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. + * ⚠️ 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. + * The one caller that keeps the `.code`/`.status` and DISCARDS the message is + * the REST dataset door, whose own refusal is the schema's — Reachability + * below says why that is not an exception to the sentence. * * ⚠️ 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 the schema door refuses first and + * this never fires. `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 #17058 (`rest/src/analytics-selection-door.ts`, wired ahead of the + * executor), so that route answers "Refused at the schema" and takes only this + * constructor's `.code`/`.status`. This is 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, 'runtime')) as Error & { 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..899567af3bc 100644 --- a/packages/rest/src/analytics-selection-door.ts +++ b/packages/rest/src/analytics-selection-door.ts @@ -76,7 +76,14 @@ * 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 + * 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. Field paths * are prefixed `selection.` because they are reported against the REQUEST * body, where the parsed object sits one level down. * @@ -86,7 +93,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 +173,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 5e740a38cd3..81bda02fd53 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts @@ -219,10 +219,11 @@ describe('ObjectQLStrategy — timeDimensions[].dateRange (#3650)', () => { // [#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 (`POST - // /analytics/dataset/query` types its selection from `AnalyticsQuery` - // and never Zod-parses it), so the value is still handed over and the - // type error is expected by name rather than the case deleted. + // 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'] }], }, diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 69e3ff58f4a..4353ae3aa01 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -406,9 +406,13 @@ function describeRefusedDateRange(input: unknown): string { * 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`, a driver's cube face called - * directly, `POST /analytics/dataset/query`, which types its selection from - * `AnalyticsQuery` but never Zod-parses it. Until this parameter existed the + * (`@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 #17058 that 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 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..77195ac038a 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,53 @@ 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` on a branch issue is left alone rather than read as the + * root: zod always produces an array, and the conservative reading keeps a junk + * issue visible instead of silently dropping it. + */ +function withoutDateRangeArityRestatement< + T extends { path: Array; code: string; message: string }, +>(issue: T): T { + const errors = (issue as { errors?: unknown }).errors; + if (!Array.isArray(errors) || !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, + ), + }; +} From ae337c01a960050f3d1ed6f43ff1240457c4d82e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 01:06:49 +0000 Subject: [PATCH 7/8] docs(changeset): the bump justification names the precedent that IS one, and the wire half is declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4 — the justification claimed `minor` was "the same grade every other accept-set narrowing on this schema has taken", which sourced nothing. The rescue commit replaced it with an attribution to #16322; measured, that is not the string-arm closing either. #16322 is the DRIVER half of #16041, and its own changeset describes its `"@objectstack/spec": patch` entry as "a `PROVENANCE_WAIVERS` row only" — not an accept-set narrowing. The accept-set narrowing on this schema is #16041, released as `@objectstack/spec` minor (`packages/spec/CHANGELOG.md` 17.4.0, under Minor Changes). The sentence now names that precedent, its grade and where to read it, and disposes of the neighbouring entry so the next reader does not re-litigate it from the same mis-reading. ⛔ The LEVEL is untouched — `minor` is ruled. `@objectstack/types` and `@objectstack/rest` join the changeset: both publish a changed wire (one `fields[]` entry for an arity refusal instead of two), and the body now states that client-visible effect and its two edges. `analytics.zod.ts`'s "a single prescriptive issue rather than the tuple arm's own too_big/too_small text" is true at `error.issues` and was being read as a claim about the wire, where the ADR-0114 union expansion had made it false. The comment now says which half it covers and where the other half is enforced. Also hardens the new filter: an issue carrying an `errors` array but no `path` now returns untouched instead of reaching a recogniser that reads `path.length`. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- ...8-analytics-date-range-two-bound-window.md | 28 ++++++++++++++++--- packages/rest/src/analytics-selection-door.ts | 8 ++++-- packages/spec/src/data/analytics.zod.ts | 6 ++++ packages/types/src/validation-failure.ts | 12 +++++--- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/.changeset/17598-analytics-date-range-two-bound-window.md b/.changeset/17598-analytics-date-range-two-bound-window.md index 785248b91a7..bfc2e258f51 100644 --- a/.changeset/17598-analytics-date-range-two-bound-window.md +++ b/.changeset/17598-analytics-date-range-two-bound-window.md @@ -1,6 +1,8 @@ --- "@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) @@ -9,10 +11,14 @@ fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bound **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`), which is the grade that convention -prescribes rather than the grade a precedent set: the string-arm closing on this -same schema (#16322) declared `"@objectstack/spec": patch`, so `minor` here RAISES -the level above the `fix` floor rather than repeating what came before. The maintainer +(`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 @@ -68,3 +74,17 @@ and it was refused past the schema, not at it — which is why 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/packages/rest/src/analytics-selection-door.ts b/packages/rest/src/analytics-selection-door.ts index 899567af3bc..b9dd1734ba8 100644 --- a/packages/rest/src/analytics-selection-door.ts +++ b/packages/rest/src/analytics-selection-door.ts @@ -83,9 +83,11 @@ * 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. Field paths - * are prefixed `selection.` because they are reported against the REQUEST - * body, where the parsed object sits one level down. + * 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` diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 4353ae3aa01..97502a3371f 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -505,6 +505,12 @@ export const AnalyticsDateRangeSchema = z.union( // `timeDimensions.N.dateRange` instead of on the two arms' generic texts. // 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 ), diff --git a/packages/types/src/validation-failure.ts b/packages/types/src/validation-failure.ts index 77195ac038a..1377a89f285 100644 --- a/packages/types/src/validation-failure.ts +++ b/packages/types/src/validation-failure.ts @@ -141,15 +141,19 @@ export function fieldsFromZodIssues( * 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` on a branch issue is left alone rather than read as the - * root: zod always produces an array, and the conservative reading keeps a junk - * issue visible instead of silently dropping it. + * 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) || !isAnalyticsDateRangeRefusalIssue(issue)) return issue; + if (!Array.isArray(errors) || !Array.isArray(issue?.path)) return issue; + if (!isAnalyticsDateRangeRefusalIssue(issue)) return issue; return { ...issue, errors: errors.map((branch: unknown) => From 00ae6c44c75f573ff5f05884873c63c456295f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 02:21:30 +0000 Subject: [PATCH 8/8] docs(core,spec): state the measured caller set and the schema door's real residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rework's own docblock in `core/src/utils/analytics-date-range.ts` carried two falsifiable clauses, and both citations it added pointed at a card number a reader cannot open. Prose only — no behaviour, no assertion, no bump change. - `:216-218` said "the one caller that ... DISCARDS the message is the REST dataset door". Measured on this head: four non-test callers of `analyticsDateRangeUnrecognizedError(`, of which THREE replace the message — the dataset door (`rest/src/analytics-selection-door.ts:197`) and the two face-side array arms (`service-analytics/src/date-range-array-arm.ts:71`, `driver-memory/src/memory-analytics.ts:777`). Only core's own string resolver (`:260`) lets the sentence leave. The paragraph now names the set. - `:225-226` said "on EVERY REST analytics route the schema door refuses first and this never fires". The array arm is `z.tuple([z.string(), z.string()])` with bare bounds — no `.min(1)`, no format refinement (spec `data/analytics.zod.ts:501`) — so `['','']` passes the union and reaches this constructor at each face past every door; only its message is replaced. The paragraph now states what the door does refuse and names the residue it cannot. - Both `#17058` citations this diff added are now `PR #17548, the PR that landed that door for card #17058`. `GET /issues/17058` answers 404 on the credential that answers 200 for `17548`; the `analytics.zod.ts` site ships to npm (`packages/spec` `files[]` carries `src/**/*.zod.ts`), so it was published prose pointing at a dead link. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../core/src/utils/analytics-date-range.ts | 42 +++++++++++++------ packages/spec/src/data/analytics.zod.ts | 5 ++- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index d7d6bc68bde..3da399635e3 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -213,24 +213,42 @@ export function resolveAnalyticsDateRangePreset( * 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. - * The one caller that keeps the `.code`/`.status` and DISCARDS the message is - * the REST dataset door, whose own refusal is the schema's — Reachability - * below says why that is not an exception to the sentence. + * 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 EVERY REST analytics route the schema door refuses first and - * this never fires. `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 #17058 (`rest/src/analytics-selection-door.ts`, wired ahead of the - * executor), so that route answers "Refused at the schema" and takes only this - * constructor's `.code`/`.status`. This is 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. + * 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, 'runtime')) as Error & { diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 97502a3371f..38ff996e882 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -409,8 +409,9 @@ function describeRefusedDateRange(input: unknown): string { * (`@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 #17058 that route parses its - * selection's shared members — `timeDimensions` among them — against + * `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