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
89 changes: 89 additions & 0 deletions .changeset/analytics-daterange-driver-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
"@objectstack/core": minor
"@objectstack/driver-memory": minor
"@objectstack/service-analytics": minor
"@objectstack/spec": patch
---

fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322)

<!-- adr-0087: not-required (already-registered analytics-time-dimension-date-range-vocabulary-closed) the driver half of #16041 implements the migration that card registered; the accept set narrowed at the contract there, and the prescription an author needs is that entry's, unchanged -->

**BREAKING** for an in-process caller that reaches an analytics face PAST the
schema door with a string the closed vocabulary does not contain: it used to be
answered, and is now refused. Shipped as `minor` under the repo's launch-window
convention. The driver half of #16041, whose spec change closed
`AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen
dashboard preset names; every value affected here was already refused at
`POST /analytics/query` and `/analytics/sql` when that landed.

## What was wrong

#16041 closed the contract; the faces behind it never aligned, so the defect it
abolished simply moved onto the newly-blessed vocabulary. Measured on the built
`driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now,
2099):

| input | before | after |
|:--|--:|--:|
| `today` | 1/5 | 1/5 |
| the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each |
| `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` |

`driver-memory` recognised exactly `today`: every snake_case preset missed its
`startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose
two bounds were the preset's own NAME, which matched every `Date`-typed row
under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered
the same names — and unrecognised strings, and `today` — to the point window
`created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is
whatever the dialect decides a vocabulary word compares as. So a dashboard
asking for one month got all of history on one backend and a nonsense
comparison on the other, at HTTP 200 on both.

## What it does now

- **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` /
`resolveAnalyticsDateRangeString` resolve every declared preset to
`{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens
handed to the existing macro resolver, so `dateRange: 'this_month'` and a
`{month_start}` filter token cannot answer differently, and the anchoring on
`AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825)
come from that resolver rather than from each face.
- **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112
envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own
`analyticsDateRangeRefusalMessage` wording — the same sentence the schema door
answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call
it, so "memory and SQL refuse identically" is one function rather than an
agreement.
- **The upper bound keeps #16179's separation.** A window a face RESOLVED is
compared exclusively (`$lt` / `<`) for the ten calendar presets and
inclusively for the three rolling `last_N_days`, whose bound is NOW; an
explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`.
- The fifteen `driver-memory` date-range pins #16041 retired are reinstated in
preset form (DST cells re-measured under calendar semantics, not re-spelled),
and one cross-face conformance fixture holds all FOUR faces to the same
windows and the same refusal.
- **The draft-preview evaluator is the fourth face**, and it is in that fixture
for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 —
the Live Canvas preview over a pending seed draft) carried the identical
`[range, range]` fallback, so a valid `last_30_days` selected NOTHING there,
silently, while the published chart beside it answered a real window — across
a publish boundary the preview exists to make continuous, since publish
materialises the same seed.

## FROM → TO

Unchanged from #16041's — the spelling that is refused here is the spelling that
was already refused at the door.

| you wrote | write instead |
|:--|:--|
| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` |
| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` |
| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` |
| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged |

The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's
code stays registered under `@objectstack/runtime` (the door that names the wire
vocabulary), and the waiver records that the shared constructor spelling it
lives one package over.
14 changes: 14 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ export * from './utils/advisory-aggregation.js';
// Export the runtime filter-placeholder resolver (framework#3582)
export * from './utils/filter-tokens.js';

// [#16322] The ONE lowering of the closed `timeDimensions[].dateRange` preset
// vocabulary into a window, and the ONE refusal for a string outside it. Here
// for the same reason as the resolver above: `driver-memory`'s cube face and
// `@objectstack/service-analytics`' two SQL strategies both lower that field,
// they cannot import each other, and a second implementation is exactly how
// the two backends came to answer one bad input with opposite wrong answers.
export * from './utils/analytics-date-range.js';

// [#16322] The shared conformance kit for that lowering — the cases and rules
// every analytics face is held to, so "memory and SQL agree" is measured in
// each face's own package rather than asserted in prose. It ships beside the
// lowering because the oracle IS the lowering.
export * from './utils/analytics-date-range-conformance.js';

// [#8690] Can a temporal column's storage rule read this comparand? The VALUE
// half of the field-typed judgement behind the engine's temporal-comparand door
// and the analytics raw-SQL decline — one rule, two packages that do not depend
Expand Down
234 changes: 234 additions & 0 deletions packages/core/src/utils/analytics-date-range-conformance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#16322] THE shared `dateRange` conformance kit: the cases and the rules every
* analytics face is held to, written ONCE so "memory and SQL agree" is a
* measurement rather than an agreement.
*
* ## Why a kit and not one test file
*
* The ruling that split #16041 asked for exactly this — *"memory and SQL drivers
* refuse identically and share one conformance fixture"* — because the defect it
* closes was a DISAGREEMENT, not a bug in one backend. Measured on `b834b48e7a`,
* one input, three different wrong answers:
*
* - `driver-memory` matched EVERY `Date`-typed row (a `Date` compares above a
* `String` under BSON cross-type ordering, so both garbage bounds of the
* `[range, range]` fallback were satisfied) — 5/5 probe rows, 2020 and 2099
* included, at HTTP 200;
* - both `service-analytics` SQL strategies compiled the point window
* `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose
* answer is whatever the dialect decides a vocabulary word compares as;
* - and the VALID presets fared no better: `today` was the only one
* driver-memory resolved, and neither SQL strategy resolved even that one.
*
* ⛔ The faces cannot be driven from one file — `packages/runtime` is the only
* package that can import all of them, and a new static consumer of
* `@objectstack/driver-memory` there is a maintainer ruling under the #6664
* census (`RULED_CEILING`), not a test-authoring decision. So the shape is the
* repo's existing cross-driver one (`*-conformance.ts` in a shared package, a
* thin runner per driver): the CASES and the RULES live here, each face's
* runner lives in its own package, and the assertion body is not written twice.
*
* ## The oracle is this package's own lowering, and that is the point
*
* Every face is compared against {@link resolveAnalyticsDateRangeString} — the
* one function all of them now call — so holding each face to it holds the
* faces to each other, transitively, without a process that can see them all.
* A face that grew a second interpretation goes red in its own package.
*
* ⛔ Returns FINDINGS rather than asserting: this file ships in `dist` and must
* not import a test framework. Each runner asserts the list is empty, so one
* rule set produces one failure text on every face.
*/

import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/data';
import {
resolveAnalyticsDateRangeString,
type AnalyticsDateRangeResolutionOptions,
type ResolvedAnalyticsDateRange,
} from './analytics-date-range.js';

/**
* What a face did with one `dateRange`, reduced to the three facts the
* vocabulary decides: the two bounds, and whether the upper one is excluded.
*
* A face reports this in whatever currency it lowers into — a mingo `$match`,
* an ObjectQL filter, a bound SQL statement — which is why the kit takes a
* function rather than reading anything itself.
*/
export interface LoweredDateRangeWindow {
readonly start: string;
readonly end: string;
readonly endExclusive: boolean;
}

/** One analytics face under conformance. */
export interface AnalyticsDateRangeFace {
/** Named in every finding, so a failure says WHICH backend disagreed. */
readonly name: string;
/**
* Lower one `dateRange` and report the window. ⛔ Must let a refusal
* PROPAGATE — the kit reads the thrown envelope's `code` and `status`.
*/
lower(range: string | readonly string[]): Promise<LoweredDateRangeWindow>;
}

/**
* ⛔ Spellings the closed vocabulary does not contain — each a real one, not a
* fuzz string:
*
* - `'Last 7 days'` — the schema's own former example, and #16041's case;
* - `'last 7 days'` — the relative dialect #16322 deleted from the parser;
* - `'not a range at all'` — the retired driver fence's input;
* - `'last_60_days'` — a plausible near-miss the platform never declared;
* - `'2026-01-20'` — the SQL single-day dialect, which is the ARRAY arm's job.
*/
export const ANALYTICS_DATE_RANGE_REFUSED_SPELLINGS: readonly string[] = [
'Last 7 days',
'last 7 days',
'not a range at all',
'last_60_days',
'2026-01-20',
];

/**
* The explicit-window control, as full timestamps.
*
* ⚠️ Deliberately not a bare `YYYY-MM-DD`: a bare day end means "through that
* whole day" and each face widens it to `< nextDay` in its own currency
* (#4042 / #3777) — a per-face calendar translation this vocabulary does not
* touch, and which would make the faces differ here for a reason that has
* nothing to do with presets.
*/
export const ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW: readonly [string, string] = [
'2026-09-01T00:00:00.000Z',
'2026-09-30T00:00:00.000Z',
];

/** The three presets whose upper bound is NOW rather than a calendar boundary. */
const ROLLING: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'last_90_days'];

interface ThrownEnvelope {
code?: string;
status?: number;
message?: string;
}

async function attempt(
face: AnalyticsDateRangeFace,
range: string | readonly string[],
): Promise<{ window: LoweredDateRangeWindow } | { refusal: ThrownEnvelope }> {
try {
return { window: await face.lower(range) };
} catch (e) {
const err = e as ThrownEnvelope;
return { refusal: { code: err.code, status: err.status, message: err.message } };
}
}

/**
* Run the whole rule set against one face and report what it got wrong.
*
* An EMPTY array is conformance. Each finding is one sentence naming the face,
* the input and the disagreement, so the runner needs no message of its own.
*
* @param face - the backend under test.
* @param options - the reference instant and timezone; the caller freezes the
* clock so the rolling presets (whose bound is NOW) are comparable at all.
*/
export async function analyticsDateRangeConformanceFindings(
face: AnalyticsDateRangeFace,
options: AnalyticsDateRangeResolutionOptions = {},
): Promise<string[]> {
const findings: string[] = [];
const say = (msg: string) => findings.push(`${face.name}: ${msg}`);
const seenWindows = new Set<string>();

// ── The thirteen declared names must resolve, and resolve identically ────
for (const preset of DATE_RANGE_PRESETS) {
const expected: ResolvedAnalyticsDateRange = resolveAnalyticsDateRangeString(preset, options);
const got = await attempt(face, preset);
if ('refusal' in got) {
// ⭐ The control that keeps every refusal assertion below honest: a face
// that refused EVERYTHING would satisfy them all, which is the opposite
// defect and just as silent.
say(`refused the DECLARED preset '${preset}' (${got.refusal.code ?? 'no code'})`);
continue;
}
const w = got.window;
if (w.start !== expected.start || w.end !== expected.end) {
say(
`lowered '${preset}' to [${w.start}, ${w.end}] but the shared resolver says `
+ `[${expected.start}, ${expected.end}]`,
);
}
if (w.endExclusive !== expected.endExclusive) {
say(
`compares '${preset}''s upper bound ${w.endExclusive ? 'exclusively' : 'inclusively'}, `
+ `but a ${ROLLING.includes(preset) ? 'rolling window ends at NOW and REACHES its bound' : 'calendar window stops BEFORE its end instant'}`,
);
}
// The fallback's shape, named directly: its two bounds were the preset's
// own NAME, which is how twelve of thirteen windows became one.
if (w.start === preset || w.end === preset) {
say(`used the preset NAME '${preset}' as a window bound — the [range, range] fallback shape`);
}
seenWindows.add(`${w.start}..${w.end}`);
}
if (seenWindows.size > 0 && seenWindows.size !== DATE_RANGE_PRESETS.length) {
say(
`collapsed the ${DATE_RANGE_PRESETS.length} declared presets onto ${seenWindows.size} `
+ 'distinct window(s) — distinct names must select distinct rows',
);
}

// ── Everything else is REFUSED, with one envelope ────────────────────────
const envelopes = new Set<string>();
for (const bad of ANALYTICS_DATE_RANGE_REFUSED_SPELLINGS) {
const got = await attempt(face, bad);
if ('window' in got) {
say(
`ANSWERED ${JSON.stringify(bad)} with [${got.window.start}, ${got.window.end}] instead of `
+ 'refusing — an unresolvable window is a refusal, never a window',
);
continue;
}
if (got.refusal.code !== 'ANALYTICS_DATE_RANGE_UNRECOGNIZED') {
say(`refused ${JSON.stringify(bad)} with code ${String(got.refusal.code)}, not ANALYTICS_DATE_RANGE_UNRECOGNIZED`);
}
if (got.refusal.status !== 400) {
say(`refused ${JSON.stringify(bad)} with status ${String(got.refusal.status)}, not 400`);
}
envelopes.add(JSON.stringify({ code: got.refusal.code, status: got.refusal.status }));
}
if (envelopes.size > 1) {
say(`raised ${envelopes.size} different envelopes for one condition — ADR-0112 asks for one`);
}

// ── The vocabulary is case-sensitive, and snake_case ─────────────────────
for (const wrongCase of ['TODAY', 'Last_7_Days', 'This_Month']) {
const got = await attempt(face, wrongCase);
if ('window' in got) say(`accepted ${JSON.stringify(wrongCase)} — the vocabulary is case-sensitive`);
}

// ── ⛔ The CALLER's explicit window is not this vocabulary's business ─────
const explicit = await attempt(face, ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW);
if ('refusal' in explicit) {
say(`refused the explicit [start, end] window — only the STRING arm is a closed vocabulary`);
} else {
if (explicit.window.start !== ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW[0]
|| explicit.window.end !== ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW[1]) {
say(
`rewrote the caller's explicit window to [${explicit.window.start}, ${explicit.window.end}]`,
);
}
if (explicit.window.endExclusive) {
// #16179: only a window the face RESOLVED is compared exclusively. A
// caller's bound is a bound they wrote meaning "include it".
say("narrowed the caller's explicit window to an exclusive upper bound");
}
}

return findings;
}
Loading
Loading