Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/17124-daterange-array-arm-arity.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<readonly [string, readonly unknown[]]> = [
['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<unknown> {
const calls: Array<Record<string, unknown>> = [];
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: async (_o: string, opts: unknown) => { calls.push(opts as Record<string, unknown>); 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<string, unknown> | 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<unknown[]> {
const seen: AnalyticsQuery[] = [];
const svc: IAnalyticsService = {
query: vi.fn(async (q: AnalyticsQuery): Promise<AnalyticsResult> => { 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<readonly [string, (r: readonly unknown[]) => Promise<unknown>]> = [
['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<unknown>): Promise<Refusal | undefined> {
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<string>();
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']],
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/services/service-analytics/src/dataset-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) =>
Expand Down
Loading
Loading