From 7f5d3de09034851378f86ab95f3191229daf62a2 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 11 Sep 2026 01:05:30 +0000 Subject: [PATCH 1/4] fix(service-analytics): refuse a dateRange array arm that is not a two-bound window The array arm of `AnalyticsDateRangeSchema` is a bare `z.array(z.string())`, so `['2026-01-01']` is schema-valid and reaches the faces past the `/analytics/dataset/query` door, which does not Zod-parse its selection. The four faces in this package that read the arm answered it three different ways: the ObjectQL strategy degenerated it to a point window, the native-SQL strategy emitted no time clause at all and read all of history, the draft-preview evaluator wrote `String(undefined)` as the upper bound (which every ISO date sorts below), and the dataset executor's compare pass filled the missing bound in from the lower one. One rule, `explicitDateRangeWindow`, is now the single reading of the arm and all four faces call it; the three divergent fallbacks are deleted. An array that is not exactly two string bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope, the same answer the contract already gives for a `dateRange` that does not denote a window. The two-element window is untouched on every face, bound for bound. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude --- .../service-analytics/src/dataset-executor.ts | 8 +- .../src/date-range-array-arm.ts | 113 ++++++++++++++++++ .../src/preview-evaluator.ts | 15 ++- .../src/strategies/native-sql-strategy.ts | 73 ++++++----- .../src/strategies/objectql-strategy.ts | 16 +-- 5 files changed, 178 insertions(+), 47 deletions(-) create mode 100644 packages/services/service-analytics/src/date-range-array-arm.ts diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index 6fd5b3c64b..a80b28900d 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -11,6 +11,7 @@ import { emptyGroupValueFor, type FilterCondition } from '@objectstack/spec/data import type { ExecutionContext } from '@objectstack/spec/kernel'; import { bucketKeyToCalendarRange, filterTokenContextFrom, resolveFilterTokens } from '@objectstack/core'; import type { CompiledDataset, DerivedMeasureSpec } from './dataset-compiler.js'; +import { explicitDateRangeWindow } from './date-range-array-arm.js'; import { datasetInvalidError } from './dataset-refusal.js'; import type { OrderLabelResolver } from './dimension-labels.js'; @@ -1307,8 +1308,13 @@ export class DatasetExecutor { // questions are answered in one place — see `resolveCompareDimension`. const dimension = resolveCompareDimension(selection); const td = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)!; + // [#17124] The ARRAY arm goes through the one `explicitDateRangeWindow` every + // face in this package calls. ⛔ What this replaced filled a missing upper + // bound in from the lower one, so a one-element array silently became a + // point window HERE while the primary pass it is compared against may have + // read the same document as all of history. const range: [string, string] = Array.isArray(td.dateRange) - ? [td.dateRange[0], td.dateRange[1] ?? td.dateRange[0]] + ? explicitDateRangeWindow(td.dateRange as readonly unknown[]) : [td.dateRange as string, td.dateRange as string]; const shifted = shiftRange(range, cmp.kind); const shiftedTd = (selection.timeDimensions ?? []).map((t) => diff --git a/packages/services/service-analytics/src/date-range-array-arm.ts b/packages/services/service-analytics/src/date-range-array-arm.ts new file mode 100644 index 0000000000..6930191ce3 --- /dev/null +++ b/packages/services/service-analytics/src/date-range-array-arm.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17124] THE one reading of `dateRange`'s ARRAY arm, for every face in this + * package. + * + * ## What was wrong + * + * `AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no + * length constraint, so `['2026-01-01']` is schema-valid, and the four faces in + * this package that read the arm answered it three different ways — MEASURED on + * `abc4b83ce`, one authored document over the same four rows: + * + * | face | `['2026-01-01']` meant | + * |---|---| + * | `ObjectQLStrategy.dateRangeBounds` | the POINT window `{$gte, $lte}` on that day | + * | `NativeSQLStrategy` | nothing at all — `range.length === 2` was false, so NO time clause was emitted and the query read ALL of history | + * | `lowerPreviewDateRange` | an upper bound of the string `"undefined"`, which every ISO date sorts below (`0x30`-`0x39` before `0x75`) ⇒ unbounded above | + * | `DatasetExecutor.runCompare` | the point window, shifted — a comparison pass against a window the primary pass may not have used | + * + * ⇒ For a dashboard that is one day's number, the whole dataset, and everything + * from that day onward, from one document, decided by which backend answered. + * The same three-way split covers `[]` and `[a, b, c]`: the arity, not the one + * element, is what the faces disagreed about. + * + * ## Why a REFUSAL and not an alignment + * + * ⛔ Teaching all four faces the same guess would be "align them independently", + * which is what the standing criterion forbids — and all three readings are + * ungoverned. What IS governed is the CONTRACT, stated by the spec's own refusal + * wording (`analyticsDateRangeRefusalMessage`, the one sentence the schema door + * answers with) and quoted verbatim: + * + * > an explicit window is the two-element array [start, end] of ISO dates or + * > {date-macro} tokens + * + * and by the shipped #16322 migration table, which tells an author to write a + * single day as `['2026-01-20', '2026-01-20']` — TWO bounds. So the arm's arity + * is declared; only the Zod type is weaker than the contract the same file + * states. A one-element array is therefore not an under-specified shape needing + * a meaning invented for it: it is a shape the contract already excludes, and + * the kit's rule for a `dateRange` that does not denote a window is + * *"an unresolvable window is a refusal, never a window"*. + * + * ⭐ And the author loses nothing: `['2026-01-01', '2026-01-01']` selects exactly + * that one day on all four faces today (measured as this change's control), so + * the refusal costs a second bound and buys a document that means one thing. + * + * ## Reachability — why a face-side refusal exists at all + * + * `POST /analytics/dataset/query` types its selection from `AnalyticsQuery` and + * never Zod-parses it, so the schema door is BEHIND these faces. ⛔ Tightening + * `AnalyticsDateRangeSchema` itself is `packages/spec`'s call and is deliberately + * NOT done here; this is the in-process door past that one, the same seam an + * unrecognised `compareTo.kind` is refused at. + */ + +import { analyticsDateRangeUnrecognizedError } from '@objectstack/core'; + +/** + * Build the ADR-0112 refusal for an array arm that does not denote a window. + * + * ⭐ The ENVELOPE comes from the ONE shared constructor — the `code` + `status` + * pair is what the cross-package conformance kit reads, and it must have a + * single origin. Only the SENTENCE is this condition's own: the shared wording + * judges a bare STRING against the preset vocabulary and ends with + * "Refused at the schema", and neither is true of an array refused past the + * schema door by a face. ⛔ A message stating two falsehoods is not reuse. + */ +function arrayArmRefusal(dateRange: readonly unknown[], received: string): Error { + const err = analyticsDateRangeUnrecognizedError(dateRange); + err.message = + `[service-analytics] dateRange ${JSON.stringify(dateRange)} ${received}. 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}"]; for a single day ' + + 'write both bounds, ["2026-01-01", "2026-01-01"]. Refused ' + + '(ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400) rather than guessed: this package\'s four ' + + 'analytics faces read an odd-sized array three different ways — a point window, a ' + + 'window dropped to all of history, and an upper bound left unwritten — so any number ' + + 'computed from one would depend on which backend answered.'; + return err; +} + +/** + * The CALLER's explicit window, as the two bounds every face in this package + * lowers — or the refusal. + * + * ⛔ Bound VALUES are not judged here: a bare `YYYY-MM-DD` versus a full + * timestamp is a per-face calendar translation (#3777 / #4042) and a + * `{date-macro}` token is expanded upstream, neither of which this arity rule + * touches. An unparseable bound keeps its own `DATASET_INVALID` refusal + * (#5716) — a different condition, and so a different envelope. + * + * @param dateRange - the array arm as it reached the face, unparsed. + * @returns the two bounds, in the order the author wrote them. + * @throws the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope when + * the array is not exactly two string bounds. + */ +export function explicitDateRangeWindow(dateRange: readonly unknown[]): [string, string] { + if (dateRange.length !== 2) { + throw arrayArmRefusal(dateRange, `is a ${dateRange.length}-element array, not a window`); + } + const [start, end] = dateRange; + for (const bound of [start, end]) { + if (typeof bound !== 'string' || bound.length === 0) { + throw arrayArmRefusal( + dateRange, + `has a bound that is not a date string (${bound === null ? 'null' : typeof bound})`, + ); + } + } + return [start as string, end as string]; +} diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index e79c0e5430..227496af7e 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -23,6 +23,7 @@ import { resolveAnalyticsDateRangeString, utcInstantMs, } from '@objectstack/core'; +import { explicitDateRangeWindow } from './date-range-array-arm.js'; import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; import { emptyGroupValueFor, type Cube } from '@objectstack/spec/data'; @@ -404,12 +405,14 @@ export function lowerPreviewDateRange( const window = resolveAnalyticsDateRangeString(dateRange as string, { timezone }); return { start: window.start, end: window.end, endExclusive: window.endExclusive }; } - // ⛔ An oddly-sized array keeps the reading this face has always published - // (a one-entry array leaves the upper bound unwritten); the sibling faces - // degenerate it to a point instead, and reconciling the two is a divergence - // of its own, not this card's. - const [start, end] = dateRange as readonly string[]; - return { start: String(start), end: String(end), endExclusive: false }; + // [#17124] An oddly-sized array is REFUSED, by the one + // `explicitDateRangeWindow` every face in this package calls. ⛔ What this + // replaced left the upper bound UNWRITTEN — `String(undefined)` is + // `"undefined"`, and every ISO date sorts below it, so a one-entry array + // admitted every row from `start` onward while the sibling faces read the + // same document as one day and as all of history. + const [start, end] = explicitDateRangeWindow(dateRange as readonly unknown[]); + return { start, end, endExclusive: false }; } /** diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index 3b36b06d34..adafe9e5fe 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -18,6 +18,7 @@ import { datasetInvalidError, invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; import { nextUtcCalendarDay, resolveAnalyticsDateRangeString } from '@objectstack/core'; +import { explicitDateRangeWindow } from '../date-range-array-arm.js'; /** * The SQL wrapper for each aggregate a measure's `type` can name. @@ -509,39 +510,45 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const resolved = Array.isArray(td.dateRange) ? null : resolveAnalyticsDateRangeString(td.dateRange, { timezone: query.timezone }); - const range = resolved ? [resolved.start, resolved.end] : (td.dateRange as string[]); - if (range.length === 2) { - // Same epoch-vs-text root cause as buildFilterClause: a dateRange on a - // SQLite `Field.datetime` column compares ISO TEXT against an INTEGER - // epoch and matches nothing. Coerce both bounds to the storage form — - // and normalise the column to that form too, because the column holds - // BOTH forms at once and coercing only the bounds still empties the - // half the writer stored the other way (#3912). - const td2 = this.resolveStorageTarget(cube, td.dimension, tableName); - const column = this.temporalColumn(ctx, td2, colExpr); - // A bare-day window end means "through that whole day" (#3777). A - // BETWEEN's inclusive upper bound anchors a bare `YYYY-MM-DD` to - // midnight on a datetime column, dropping the final day's rows, so - // the window compiles half-open — `>= start AND < end+1day` — the - // same `[gte, lt)` the drill ranges emit. Equivalent to the old - // BETWEEN for a `date` column (plain `YYYY-MM-DD` ordering), which - // is what lets this path stay column-type-blind. - // - // [#16322] A RESOLVED window already states its own upper reading - // and is never a bare day, so it never takes the widening branch: - // the ten calendar presets stop BEFORE their end instant (`<`), the - // three rolling ones end at NOW and reach it (`<=`). ⛔ An explicit - // `[a, b]` a CALLER wrote keeps the inclusive reading it has always - // had — the #16179 separation, on this side too. - const nextDay = resolved ? null : nextUtcCalendarDay(range[1]); - const upperExclusive = resolved ? resolved.endExclusive : nextDay != null; - params.push(this.coerceTemporal(ctx, td2, range[0])); - const lower = `${column} >= $${params.length}`; - params.push(this.coerceTemporal(ctx, td2, nextDay ?? range[1])); - whereClauses.push( - `(${lower} AND ${column} ${upperExclusive ? '<' : '<='} $${params.length})`, - ); - } + const range = resolved + ? ([resolved.start, resolved.end] as [string, string]) + // [#17124] An oddly-sized array is REFUSED, by the one + // `explicitDateRangeWindow` every face in this package calls. ⛔ What + // this replaced was a silent `if (range.length === 2)` DROP: a + // one-element array emitted no time clause at all, so the query read + // ALL of history — "plot all of history" is the very failure #16322 + // repaired for the string arm, and it was still live on this arm. + : explicitDateRangeWindow(td.dateRange as readonly unknown[]); + // Same epoch-vs-text root cause as buildFilterClause: a dateRange on a + // SQLite `Field.datetime` column compares ISO TEXT against an INTEGER + // epoch and matches nothing. Coerce both bounds to the storage form — + // and normalise the column to that form too, because the column holds + // BOTH forms at once and coercing only the bounds still empties the + // half the writer stored the other way (#3912). + const td2 = this.resolveStorageTarget(cube, td.dimension, tableName); + const column = this.temporalColumn(ctx, td2, colExpr); + // A bare-day window end means "through that whole day" (#3777). A + // BETWEEN's inclusive upper bound anchors a bare `YYYY-MM-DD` to + // midnight on a datetime column, dropping the final day's rows, so + // the window compiles half-open — `>= start AND < end+1day` — the + // same `[gte, lt)` the drill ranges emit. Equivalent to the old + // BETWEEN for a `date` column (plain `YYYY-MM-DD` ordering), which + // is what lets this path stay column-type-blind. + // + // [#16322] A RESOLVED window already states its own upper reading + // and is never a bare day, so it never takes the widening branch: + // the ten calendar presets stop BEFORE their end instant (`<`), the + // three rolling ones end at NOW and reach it (`<=`). ⛔ An explicit + // `[a, b]` a CALLER wrote keeps the inclusive reading it has always + // had — the #16179 separation, on this side too. + const nextDay = resolved ? null : nextUtcCalendarDay(range[1]); + const upperExclusive = resolved ? resolved.endExclusive : nextDay != null; + params.push(this.coerceTemporal(ctx, td2, range[0])); + const lower = `${column} >= $${params.length}`; + params.push(this.coerceTemporal(ctx, td2, nextDay ?? range[1])); + whereClauses.push( + `(${lower} AND ${column} ${upperExclusive ? '<' : '<='} $${params.length})`, + ); } } } diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index a925b4e1ae..37bb0510f1 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -22,6 +22,7 @@ import { invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; import { nextUtcCalendarDay, resolveAnalyticsDateRangeString } from '@objectstack/core'; +import { explicitDateRangeWindow } from '../date-range-array-arm.js'; import { rebucketCrossObject, RECOMBINABLE_METHODS, @@ -1699,11 +1700,13 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * a vocabulary word against a timestamp — which is precisely how the two * backends came to answer one bad input with opposite wrong answers. * - * An oddly-sized array (the schema types `dateRange` as a plain `string[]`) - * takes its first two entries, a one-entry array degenerating to a point. - * `NativeSQLStrategy` drops such a window entirely — but "drop the window" - * means "plot all of history", which is the very failure this fixes, so the - * fallback here errs toward the narrower query instead. + * [#17124] An oddly-sized array is REFUSED with the same envelope, by the one + * `explicitDateRangeWindow` every face in this package now calls. ⛔ The + * per-face fallback this replaced — take the first two entries, a one-entry + * array degenerating to a point — was one of THREE readings of the same + * document (the native-SQL face dropped the window to all of history, the + * preview face left the upper bound unwritten), and the contract declares the + * arm two-element, so there is nothing here to guess. */ private dateRangeBounds( cube: Cube, @@ -1736,8 +1739,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // on a bound they wrote is the reading this face has published since it // existed (#16179), and the driver's own bare-day widening still owns // the calendar-day → instant translation for it. - const [start, end = start] = td.dateRange; - if (start == null) continue; + const [start, end] = explicitDateRangeWindow(td.dateRange); out.push({ field: this.resolveFieldName(cube, td.dimension, 'dimension'), bounds: { $gte: start, $lte: end }, From 1b4bf90f5d8fbe69716ce724796ea3981dc69628 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 11 Sep 2026 01:07:12 +0000 Subject: [PATCH 2/4] test(service-analytics): pin the array-arm arity refusal on all four faces Drives every face that reads the dateRange array arm with the four shapes that are not a two-bound window and asserts one ADR-0112 envelope, plus the control that a two-element window still answers exactly as it did before on each face. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude --- .../date-range-array-arm-arity.test.ts | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts diff --git a/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts b/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts new file mode 100644 index 0000000000..30e70086e6 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17124] Every face in this package that reads `dateRange`'s ARRAY arm gives + * an odd-sized array ONE answer — the ADR-0112 refusal — and gives a + * two-element window exactly the answer it gave before. + * + * ## What was wrong + * + * MEASURED on `abc4b83ce` before this change, `['2026-01-01']` over four rows + * (2020, 2026-01-01, 2026-06, 2099): + * + * | face | answer | + * |---|---| + * | `ObjectQLStrategy.dateRangeBounds` | `{$gte: '2026-01-01', $lte: '2026-01-01'}` — the point | + * | `NativeSQLStrategy` | NO time clause emitted at all — the whole dataset | + * | `evaluateAnalyticsQueryOverRows` | selected 2026-01-01, 2026-06 AND 2099 — unbounded above | + * | `DatasetExecutor.runCompare` | primary pass kept the one-element array, compare pass got the point `['2025-12-31','2025-12-31']` | + * + * ⇒ one authored document, three readings, and on a dashboard the difference + * between one day's number and the whole dataset's. `[]` and `[a, b, c]` split + * the same three ways, and `[null, null]` reached `parseUTC(null)` as a bare + * `TypeError` — a 500 for a malformed request. + * + * ## What is pinned + * + * - the refusal on ALL FOUR faces, on the ENVELOPE (`code` + `status`) the + * route classifies on — ⛔ not on `toThrow()`, which an unfixed face + * throwing a bare `Error` would satisfy, and which `[null, null]`'s + * `TypeError` did satisfy; + * - ONE envelope across the four, because one condition gets one envelope; + * - the message discipline: what arrived, the two-element contract, the + * single-day spelling to write instead; + * - ⭐ the CONTROL that the refusal did not widen — the two-element window + * answers byte-for-byte as it did before on each face, including the #3777 + * half-open bare-day widening on the SQL side and the inclusive upper + * reading (#16179) on the others. Without it a green here would be + * satisfiable by a face that refused everything. + * + * ⚠️ The arity rule is this PACKAGE's, asserted here, because the shared + * cross-package kit (`analyticsDateRangeConformanceFindings`) has no arity case + * — its only array case is a two-element window. ⇒ A face in another package + * can still grow a fourth reading; that is reported, not fixed here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { Cube } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsQuery, AnalyticsResult, IAnalyticsService } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; +import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js'; +import { compileDataset } from '../dataset-compiler.js'; +import { DatasetExecutor } from '../dataset-executor.js'; + +/** The ADR-0112 fields `rest-server.ts`'s catch classifies a thrown error on. */ +interface Refusal extends Error { + code?: unknown; + status?: unknown; +} + +const CTX = { tenantId: 'org_A' } as ExecutionContext; + +/** + * Every array shape that is not a two-bound window, each a shape an author or a + * generator really writes — ⛔ not fuzz. + */ +const NOT_A_WINDOW: ReadonlyArray = [ + ['one element — the card\'s shape', ['2026-01-01']], + ['empty', []], + ['three elements', ['2026-01-01', '2026-01-31', '2026-02-01']], + ['two null bounds', [null, null]], +]; + +/** The window an author writes when they mean that single day — all faces agree on it. */ +const ONE_DAY: readonly string[] = ['2026-01-01', '2026-01-01']; + +const DATASET = DatasetSchema.parse({ + name: 'events', label: 'Events', object: 'events', include: [], + dimensions: [ + { name: 'created_at', field: 'created_at', type: 'date' }, + { name: 'probe', field: 'probe', type: 'string' }, + ], + measures: [{ name: 'count', aggregate: 'count' }], +}); + +// ── face 1: the ObjectQL aggregate strategy ────────────────────────────────── + +/** The emitted ObjectQL filter for `created_at`, or `undefined` if none was emitted. */ +async function objectqlBounds(range: readonly unknown[]): Promise { + const calls: Array> = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_o: string, opts: unknown) => { calls.push(opts as Record); return []; }, + }); + await svc.queryDataset(DATASET, { + dimensions: ['created_at'], measures: ['count'], + timeDimensions: [{ dimension: 'created_at', dateRange: range }], + } as never, CTX); + return (calls[0]?.filter as Record | undefined)?.created_at; +} + +// ── face 2: the native-SQL strategy ────────────────────────────────────────── + +/** The bound statement's WHERE clause and parameters. */ +async function nativeSql(range: readonly unknown[]): Promise<{ where: string; params: string[] }> { + const stmts: string[] = []; const bound: unknown[][] = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_o: string, sql: string, params: unknown[]) => { stmts.push(sql); bound.push(params); return []; }, + }); + await svc.queryDataset(DATASET, { + dimensions: ['probe'], measures: ['count'], + timeDimensions: [{ dimension: 'created_at', dateRange: range }], + } as never, CTX); + return { + where: /WHERE (.*?)(?: GROUP BY| ORDER BY| LIMIT|$)/s.exec(stmts[0] ?? '')?.[1]?.trim() ?? '', + params: (bound[0] ?? []).map(String), + }; +} + +// ── face 3: the draft-preview evaluator ────────────────────────────────────── + +const PREVIEW_CUBE = { + name: 'events', sql: 'events', + dimensions: { + id: { name: 'id', type: 'string', sql: 'id' }, + created_at: { name: 'created_at', type: 'time', sql: 'created_at' }, + }, + measures: { count: { name: 'count', type: 'count', sql: '*' } }, +} as unknown as Cube; + +/** + * Rows spanning far outside the window on BOTH sides, so "unbounded above" and + * "window dropped" are distinguishable from "one day" rather than all reading + * as the same row set. + */ +const ROWS = [ + { id: 'a_2020', created_at: '2020-01-01T00:00:00.000Z' }, + { id: 'b_target', created_at: '2026-01-01T12:00:00.000Z' }, + { id: 'c_2026_06', created_at: '2026-06-15T00:00:00.000Z' }, + { id: 'd_2099', created_at: '2099-12-31T00:00:00.000Z' }, +]; + +/** Which row ids the preview face keeps — the END-TO-END reading, not the lowering's report. */ +function previewSelects(range: readonly unknown[]): string[] { + const result = evaluateAnalyticsQueryOverRows({ + measures: ['count'], dimensions: ['id'], + timeDimensions: [{ dimension: 'created_at', dateRange: range }], + } as never, PREVIEW_CUBE, ROWS.map((r) => ({ ...r }))); + return result.rows.map((r) => String(r.id)).sort(); +} + +// ── face 4: the dataset executor's compareTo window ────────────────────────── + +const CMP_DATASET = DatasetSchema.parse({ + name: 'trend', label: 'Trend', object: 'events', include: [], + dimensions: [{ name: 'created_at', field: 'created_at', type: 'date', dateGranularity: 'month' }], + measures: [{ name: 'count', aggregate: 'count' }], +}); + +/** The `dateRange` of every pass the executor issues — the primary one and the shifted one. */ +async function compareWindows(range: readonly unknown[]): Promise { + const seen: AnalyticsQuery[] = []; + const svc: IAnalyticsService = { + query: vi.fn(async (q: AnalyticsQuery): Promise => { seen.push(q); return { rows: [], fields: [] }; }), + getMeta: async () => [], + }; + await new DatasetExecutor(svc).execute(compileDataset(CMP_DATASET), { + dimensions: ['created_at'], measures: ['count'], + timeDimensions: [{ dimension: 'created_at', dateRange: range, granularity: 'month' }], + compareTo: { kind: 'previousPeriod' }, + } as never, CTX); + return seen.map((q) => (q.timeDimensions ?? []).map((t) => (t as { dateRange?: unknown }).dateRange)); +} + +/** The four faces, each reduced to "drive me with this dateRange". */ +const FACES: ReadonlyArray Promise]> = [ + ['ObjectQLStrategy.dateRangeBounds', objectqlBounds], + ['NativeSQLStrategy', nativeSql], + ['draft-preview evaluator', async (r) => previewSelects(r)], + ['DatasetExecutor.runCompare', compareWindows], +]; + +/** Run `thunk` and hand back the error it threw, if any. */ +async function refusalFrom(thunk: () => unknown | Promise): Promise { + try { + await thunk(); + return undefined; + } catch (e) { + return e as Refusal; + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe('#17124 — an array arm that is not a two-bound window is REFUSED on every face', () => { + for (const [faceName, drive] of FACES) { + for (const [shapeName, range] of NOT_A_WINDOW) { + it(`${faceName} refuses ${shapeName}`, async () => { + const err = await refusalFrom(() => drive(range)); + expect(err, `${faceName} ANSWERED ${JSON.stringify(range)} — a face grew its own reading again`) + .toBeInstanceOf(Error); + // Read exactly as `rest-server.ts`'s catch reads them: code + 4xx status. + // ⛔ Not `toThrow()`: `[null, null]` used to throw a bare TypeError here. + expect(err?.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(err?.status).toBe(400); + }); + } + } + + it('the four faces raise ONE envelope, not four — one condition, one answer', async () => { + const envelopes = new Set(); + for (const [, drive] of FACES) { + for (const [, range] of NOT_A_WINDOW) { + const err = await refusalFrom(() => drive(range)); + envelopes.add(JSON.stringify({ code: err?.code, status: err?.status })); + } + } + expect(envelopes.size, `raised ${envelopes.size} envelopes: ${[...envelopes].join(' | ')}`).toBe(1); + }); + + it('says what arrived, the two-element contract, and the single-day spelling to write', async () => { + const msg = String((await refusalFrom(() => objectqlBounds(['2026-01-01'])))?.message); + // ① what arrived — so the author can find it in the document they wrote. + expect(msg).toContain('["2026-01-01"]'); + expect(msg).toContain('1-element array'); + // ② the contract, in the spec's own words. + expect(msg).toContain('TWO-element array [start, end]'); + // ③ what to do instead — the spelling every face already agrees on. + expect(msg).toContain('["2026-01-01", "2026-01-01"]'); + }); +}); + +describe('#17124 CONTROL — a two-element window answers exactly as it did before', () => { + // ⭐ Without these four, every assertion above is satisfied by a face that + // refuses EVERY array, which is the opposite defect and just as silent. + it('ObjectQLStrategy keeps the inclusive bounds the caller wrote (#16179)', async () => { + expect(await objectqlBounds(ONE_DAY)).toEqual({ $gte: '2026-01-01', $lte: '2026-01-01' }); + }); + + it('NativeSQLStrategy keeps the half-open bare-day widening (#3777)', async () => { + const { where, params } = await nativeSql(ONE_DAY); + expect(where).toBe('(created_at >= $1 AND created_at < $2)'); + expect(params).toEqual(['2026-01-01', '2026-01-02']); + }); + + it('the draft-preview evaluator selects exactly that day', async () => { + expect(previewSelects(ONE_DAY)).toEqual(['b_target']); + }); + + it('DatasetExecutor still shifts the window it was given', async () => { + expect(await compareWindows(ONE_DAY)).toEqual([ + [['2026-01-01', '2026-01-01']], + [['2025-12-31', '2025-12-31']], + ]); + }); +}); From b000e84f5ee93bfe30f223d53f194835452d6ccb Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 11 Sep 2026 01:20:27 +0000 Subject: [PATCH 3/4] chore(changeset): patch for the dateRange array-arm arity refusal Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude --- .changeset/17124-daterange-array-arm-arity.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .changeset/17124-daterange-array-arm-arity.md diff --git a/.changeset/17124-daterange-array-arm-arity.md b/.changeset/17124-daterange-array-arm-arity.md new file mode 100644 index 0000000000..2732009bb3 --- /dev/null +++ b/.changeset/17124-daterange-array-arm-arity.md @@ -0,0 +1,51 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(analytics): a `dateRange` array that is not a two-bound window is refused, once, instead of meaning three different things (#17124) + +`AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no +length constraint, so `dateRange: ['2026-01-01']` is schema-valid and reaches the +analytics faces through `POST /analytics/dataset/query`, which types its selection +from `AnalyticsQuery` and never Zod-parses it. The four faces in this package that +read the arm answered it three different ways — measured over one authored +document and four rows: + +| face | `['2026-01-01']` meant | +|---|---| +| `ObjectQLStrategy.dateRangeBounds` | the point window `created_at >= '2026-01-01' AND <= '2026-01-01'` | +| `NativeSQLStrategy` | no time clause at all — the whole dataset | +| the draft-preview evaluator | an upper bound of the string `"undefined"`, which every ISO date sorts below — everything from that day onward | +| `DatasetExecutor`'s `compareTo` pass | the point window, shifted — compared against a primary pass that may have read all of history | + +For a dashboard that is one day's number, the whole dataset's, and everything +from that day onward, from the same document, decided by which backend answered. +`[]` and `[a, b, c]` split the same three ways, and `[null, null]` reached +`parseUTC(null)` as a bare `TypeError` — a 500 for a malformed request. + +One rule is now the single reading of the arm and all four faces call it; the +three divergent fallbacks are deleted. An array that is not exactly two string +bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 +envelope — the answer the contract already gives for a `dateRange` that does not +denote a window. A two-element window is untouched on every face, bound for +bound, including the inclusive upper reading a caller's bounds keep (#16179) and +the half-open bare-day widening on the SQL side (#3777). + +### Write both bounds + +| wrote | write instead | +|---|---| +| `dateRange: ['2026-01-01']` | `dateRange: ['2026-01-01', '2026-01-01']` | + +That spelling already selects exactly that one day on every face, and it is the +same instruction #16322 shipped for the single-day string dialect. + +⭐ Shipped as `patch`, not as a breaking narrowing, because nothing DECLARED +moves. The spec's own refusal wording already states that *"an explicit window is +the two-element array [start, end] of ISO dates or {date-macro} tokens"*, and +#16322's shipped migration table already told authors to write a single day as +`['2026-01-20', '2026-01-20']`. A one-element array was therefore never a valid +document; it was an invalid one that four faces answered arbitrarily, and a +behaviour that was never one behaviour is not a behaviour this removes. The Zod +type admitting the shape is weaker than the contract the same file states — +tightening it is a separate, spec-owned question. From c8ec0f631b7522b5612417fa0f214427910d2ca2 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 11 Sep 2026 01:25:26 +0000 Subject: [PATCH 4/4] test(service-analytics): supersede the one-entry point-degeneration pin with the refusal `objectql-daterange.test.ts`'s 'narrows rather than vanishes on a one-entry dateRange array' pinned exactly the branch this card retires. It chose the narrower of two wrong answers because the alternative on the table was the native-SQL face's silent drop to all of history; a refusal satisfies #3650's intent strictly better, and the replacement keeps that card's own invariant by asserting no unfiltered query reaches the engine. The retirement is the one the neighbouring test declares deferred. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude --- .../src/__tests__/objectql-daterange.test.ts | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 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 ba2d720b1c..7de9150932 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts @@ -196,21 +196,41 @@ describe('ObjectQLStrategy — timeDimensions[].dateRange (#3650)', () => { expect(result.rows).toEqual([{ stage: 'lost', revenue: 200 }]); }); - it('narrows rather than vanishes on a one-entry dateRange array', async () => { + // [#17124] SUCCEEDS 'narrows rather than vanishes on a one-entry dateRange + // array', which pinned the point degeneration this card retired. ⛔ Not a + // weakening of #3650: that card's complaint was 「no error, just every row + // ever recorded」, and the old pin chose the narrower of two WRONG answers + // because the alternative on the table was the native-SQL face's silent drop + // to all of history. A refusal satisfies the same intent strictly better — it + // is the error #3650 wanted — and the drop it was defending against is gone + // from the sibling face in the same change. The retirement itself is the one + // the test above declares deferred: 「Retiring this test, together with the + // strategy's degeneration, belongs to #16322」. + it('REFUSES a one-entry dateRange array, and still does not plot all of history', async () => { const seen: AggOpts[] = []; - await makeService(seen).query( - { - cube: 'sales', - dimensions: ['stage'], - measures: ['revenue'], - // The schema types `dateRange` as a plain `string[]`, so this parses. - // `NativeSQLStrategy` drops such a window — but "drop the window" means - // "plot all of history", the very failure #3650 is about. - timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-20'] }], - }, - ctx, - ); - expect(seen[0].filter).toEqual({ close_date: { $gte: '2026-01-20', $lte: '2026-01-20' } }); + let thrown: (Error & { code?: string; status?: number }) | undefined; + try { + await makeService(seen).query( + { + cube: 'sales', + dimensions: ['stage'], + measures: ['revenue'], + // The schema types `dateRange` as a plain `string[]`, so this parses + // and reaches the face past the door. + timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-20'] }], + }, + ctx, + ); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + // ⛔ On the ENVELOPE, not on `toThrow()` — an unfixed face throwing a bare + // `Error` would satisfy that. + expect(thrown?.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(thrown?.status).toBe(400); + // #3650's own invariant, kept: the window did not VANISH into an unfiltered + // query. The refusal lands before the engine is asked anything at all. + expect(seen).toEqual([]); }); it('ANDs the read scope around the window rather than replacing it', async () => {