Skip to content

Commit 0da638c

Browse files
os-muskclaude
andauthored
fix(analytics): lower the closed dateRange preset vocabulary once, and refuse the rest (#16322) (#17015)
* fix(analytics): lower the closed dateRange preset vocabulary once, and refuse the rest Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(analytics): reinstate the 15 retired date-range pins and add the cross-face conformance fixture Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(analytics): move the dateRange conformance fixture to a shared kit with per-package runners The runtime-hosted fixture added a third static consumer of @objectstack/driver-memory, which the #6664 census rules is a maintainer decision (RULED_CEILING = 2), not a test-authoring one. Migrated to the repo's existing cross-driver shape instead: the cases and rules live once in @objectstack/core beside the lowering they grade, and each face runs them in its own package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * fix(service-analytics): lower and refuse `dateRange` on the draft-preview face too, and register it in the conformance kit The fourth analytics face — `preview-evaluator.ts`, the Live Canvas draft preview (ADR-0037 P3) — still carried the `[range, range]` fallback the other three shed. After the rest of this branch a VALID `last_30_days` lowered there to `v >= 'last_30_days' && v <= 'last_30_days~'` — zero rows, silently — while the published chart beside it answered a real window, breaking exactly the publish-boundary continuity a draft preview exists to provide. It now calls the same `resolveAnalyticsDateRangeString` the two strategies and `driver-memory`'s cube face call, and refuses a non-preset string with the same ADR-0112 `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope. The caller's explicit `[start, end]` array keeps its bounds and its inclusive upper reading (#16179), bare-day widening (#3777) included. The face is registered in the shared conformance kit's FACES list, so it is now held to the same rules as the other two — plus four end-to-end cases proving `evaluateAnalyticsQueryOverRows` really applies the window it reports, since this face emits no filter to read the window out of. Also points two driver-memory test headers at the conformance kit's real path (`packages/core/src/utils/analytics-date-range-conformance.ts`); they named `packages/runtime/src/analytics-daterange-driver-conformance.test.ts`, a file that does not exist — the kit went to core under the #6664 census. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * docs(changeset): count the draft-preview evaluator among the faces the fixture holds The body said "both SQL strategies" and "all three faces" while the headline already claimed EVERY analytics face. Wiring the fourth face makes the headline true; these two enumerations were the half still under-counting it. ⛔ No level moved: the same four packages ship at the same levels (core / driver-memory / service-analytics `minor`, spec `patch`) — this diff adds no package and changes no published surface, so the levelling the contract review passed stands untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 71629a1 commit 0da638c

15 files changed

Lines changed: 1856 additions & 343 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/driver-memory": minor
4+
"@objectstack/service-analytics": minor
5+
"@objectstack/spec": patch
6+
---
7+
8+
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)
9+
10+
<!-- 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 -->
11+
12+
**BREAKING** for an in-process caller that reaches an analytics face PAST the
13+
schema door with a string the closed vocabulary does not contain: it used to be
14+
answered, and is now refused. Shipped as `minor` under the repo's launch-window
15+
convention. The driver half of #16041, whose spec change closed
16+
`AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen
17+
dashboard preset names; every value affected here was already refused at
18+
`POST /analytics/query` and `/analytics/sql` when that landed.
19+
20+
## What was wrong
21+
22+
#16041 closed the contract; the faces behind it never aligned, so the defect it
23+
abolished simply moved onto the newly-blessed vocabulary. Measured on the built
24+
`driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now,
25+
2099):
26+
27+
| input | before | after |
28+
|:--|--:|--:|
29+
| `today` | 1/5 | 1/5 |
30+
| the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each |
31+
| `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` |
32+
33+
`driver-memory` recognised exactly `today`: every snake_case preset missed its
34+
`startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose
35+
two bounds were the preset's own NAME, which matched every `Date`-typed row
36+
under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered
37+
the same names — and unrecognised strings, and `today` — to the point window
38+
`created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is
39+
whatever the dialect decides a vocabulary word compares as. So a dashboard
40+
asking for one month got all of history on one backend and a nonsense
41+
comparison on the other, at HTTP 200 on both.
42+
43+
## What it does now
44+
45+
- **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` /
46+
`resolveAnalyticsDateRangeString` resolve every declared preset to
47+
`{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens
48+
handed to the existing macro resolver, so `dateRange: 'this_month'` and a
49+
`{month_start}` filter token cannot answer differently, and the anchoring on
50+
`AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825)
51+
come from that resolver rather than from each face.
52+
- **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112
53+
envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own
54+
`analyticsDateRangeRefusalMessage` wording — the same sentence the schema door
55+
answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call
56+
it, so "memory and SQL refuse identically" is one function rather than an
57+
agreement.
58+
- **The upper bound keeps #16179's separation.** A window a face RESOLVED is
59+
compared exclusively (`$lt` / `<`) for the ten calendar presets and
60+
inclusively for the three rolling `last_N_days`, whose bound is NOW; an
61+
explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`.
62+
- The fifteen `driver-memory` date-range pins #16041 retired are reinstated in
63+
preset form (DST cells re-measured under calendar semantics, not re-spelled),
64+
and one cross-face conformance fixture holds all FOUR faces to the same
65+
windows and the same refusal.
66+
- **The draft-preview evaluator is the fourth face**, and it is in that fixture
67+
for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 —
68+
the Live Canvas preview over a pending seed draft) carried the identical
69+
`[range, range]` fallback, so a valid `last_30_days` selected NOTHING there,
70+
silently, while the published chart beside it answered a real window — across
71+
a publish boundary the preview exists to make continuous, since publish
72+
materialises the same seed.
73+
74+
## FROM → TO
75+
76+
Unchanged from #16041's — the spelling that is refused here is the spelling that
77+
was already refused at the door.
78+
79+
| you wrote | write instead |
80+
|:--|:--|
81+
| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` |
82+
| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` |
83+
| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` |
84+
| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged |
85+
86+
The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's
87+
code stays registered under `@objectstack/runtime` (the door that names the wire
88+
vocabulary), and the waiver records that the shared constructor spelling it
89+
lives one package over.

packages/core/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ export * from './utils/advisory-aggregation.js';
7777
// Export the runtime filter-placeholder resolver (framework#3582)
7878
export * from './utils/filter-tokens.js';
7979

80+
// [#16322] The ONE lowering of the closed `timeDimensions[].dateRange` preset
81+
// vocabulary into a window, and the ONE refusal for a string outside it. Here
82+
// for the same reason as the resolver above: `driver-memory`'s cube face and
83+
// `@objectstack/service-analytics`' two SQL strategies both lower that field,
84+
// they cannot import each other, and a second implementation is exactly how
85+
// the two backends came to answer one bad input with opposite wrong answers.
86+
export * from './utils/analytics-date-range.js';
87+
88+
// [#16322] The shared conformance kit for that lowering — the cases and rules
89+
// every analytics face is held to, so "memory and SQL agree" is measured in
90+
// each face's own package rather than asserted in prose. It ships beside the
91+
// lowering because the oracle IS the lowering.
92+
export * from './utils/analytics-date-range-conformance.js';
93+
8094
// [#8690] Can a temporal column's storage rule read this comparand? The VALUE
8195
// half of the field-typed judgement behind the engine's temporal-comparand door
8296
// and the analytics raw-SQL decline — one rule, two packages that do not depend
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16322] THE shared `dateRange` conformance kit: the cases and the rules every
5+
* analytics face is held to, written ONCE so "memory and SQL agree" is a
6+
* measurement rather than an agreement.
7+
*
8+
* ## Why a kit and not one test file
9+
*
10+
* The ruling that split #16041 asked for exactly this — *"memory and SQL drivers
11+
* refuse identically and share one conformance fixture"* — because the defect it
12+
* closes was a DISAGREEMENT, not a bug in one backend. Measured on `b834b48e7a`,
13+
* one input, three different wrong answers:
14+
*
15+
* - `driver-memory` matched EVERY `Date`-typed row (a `Date` compares above a
16+
* `String` under BSON cross-type ordering, so both garbage bounds of the
17+
* `[range, range]` fallback were satisfied) — 5/5 probe rows, 2020 and 2099
18+
* included, at HTTP 200;
19+
* - both `service-analytics` SQL strategies compiled the point window
20+
* `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose
21+
* answer is whatever the dialect decides a vocabulary word compares as;
22+
* - and the VALID presets fared no better: `today` was the only one
23+
* driver-memory resolved, and neither SQL strategy resolved even that one.
24+
*
25+
* ⛔ The faces cannot be driven from one file — `packages/runtime` is the only
26+
* package that can import all of them, and a new static consumer of
27+
* `@objectstack/driver-memory` there is a maintainer ruling under the #6664
28+
* census (`RULED_CEILING`), not a test-authoring decision. So the shape is the
29+
* repo's existing cross-driver one (`*-conformance.ts` in a shared package, a
30+
* thin runner per driver): the CASES and the RULES live here, each face's
31+
* runner lives in its own package, and the assertion body is not written twice.
32+
*
33+
* ## The oracle is this package's own lowering, and that is the point
34+
*
35+
* Every face is compared against {@link resolveAnalyticsDateRangeString} — the
36+
* one function all of them now call — so holding each face to it holds the
37+
* faces to each other, transitively, without a process that can see them all.
38+
* A face that grew a second interpretation goes red in its own package.
39+
*
40+
* ⛔ Returns FINDINGS rather than asserting: this file ships in `dist` and must
41+
* not import a test framework. Each runner asserts the list is empty, so one
42+
* rule set produces one failure text on every face.
43+
*/
44+
45+
import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/data';
46+
import {
47+
resolveAnalyticsDateRangeString,
48+
type AnalyticsDateRangeResolutionOptions,
49+
type ResolvedAnalyticsDateRange,
50+
} from './analytics-date-range.js';
51+
52+
/**
53+
* What a face did with one `dateRange`, reduced to the three facts the
54+
* vocabulary decides: the two bounds, and whether the upper one is excluded.
55+
*
56+
* A face reports this in whatever currency it lowers into — a mingo `$match`,
57+
* an ObjectQL filter, a bound SQL statement — which is why the kit takes a
58+
* function rather than reading anything itself.
59+
*/
60+
export interface LoweredDateRangeWindow {
61+
readonly start: string;
62+
readonly end: string;
63+
readonly endExclusive: boolean;
64+
}
65+
66+
/** One analytics face under conformance. */
67+
export interface AnalyticsDateRangeFace {
68+
/** Named in every finding, so a failure says WHICH backend disagreed. */
69+
readonly name: string;
70+
/**
71+
* Lower one `dateRange` and report the window. ⛔ Must let a refusal
72+
* PROPAGATE — the kit reads the thrown envelope's `code` and `status`.
73+
*/
74+
lower(range: string | readonly string[]): Promise<LoweredDateRangeWindow>;
75+
}
76+
77+
/**
78+
* ⛔ Spellings the closed vocabulary does not contain — each a real one, not a
79+
* fuzz string:
80+
*
81+
* - `'Last 7 days'` — the schema's own former example, and #16041's case;
82+
* - `'last 7 days'` — the relative dialect #16322 deleted from the parser;
83+
* - `'not a range at all'` — the retired driver fence's input;
84+
* - `'last_60_days'` — a plausible near-miss the platform never declared;
85+
* - `'2026-01-20'` — the SQL single-day dialect, which is the ARRAY arm's job.
86+
*/
87+
export const ANALYTICS_DATE_RANGE_REFUSED_SPELLINGS: readonly string[] = [
88+
'Last 7 days',
89+
'last 7 days',
90+
'not a range at all',
91+
'last_60_days',
92+
'2026-01-20',
93+
];
94+
95+
/**
96+
* The explicit-window control, as full timestamps.
97+
*
98+
* ⚠️ Deliberately not a bare `YYYY-MM-DD`: a bare day end means "through that
99+
* whole day" and each face widens it to `< nextDay` in its own currency
100+
* (#4042 / #3777) — a per-face calendar translation this vocabulary does not
101+
* touch, and which would make the faces differ here for a reason that has
102+
* nothing to do with presets.
103+
*/
104+
export const ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW: readonly [string, string] = [
105+
'2026-09-01T00:00:00.000Z',
106+
'2026-09-30T00:00:00.000Z',
107+
];
108+
109+
/** The three presets whose upper bound is NOW rather than a calendar boundary. */
110+
const ROLLING: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'last_90_days'];
111+
112+
interface ThrownEnvelope {
113+
code?: string;
114+
status?: number;
115+
message?: string;
116+
}
117+
118+
async function attempt(
119+
face: AnalyticsDateRangeFace,
120+
range: string | readonly string[],
121+
): Promise<{ window: LoweredDateRangeWindow } | { refusal: ThrownEnvelope }> {
122+
try {
123+
return { window: await face.lower(range) };
124+
} catch (e) {
125+
const err = e as ThrownEnvelope;
126+
return { refusal: { code: err.code, status: err.status, message: err.message } };
127+
}
128+
}
129+
130+
/**
131+
* Run the whole rule set against one face and report what it got wrong.
132+
*
133+
* An EMPTY array is conformance. Each finding is one sentence naming the face,
134+
* the input and the disagreement, so the runner needs no message of its own.
135+
*
136+
* @param face - the backend under test.
137+
* @param options - the reference instant and timezone; the caller freezes the
138+
* clock so the rolling presets (whose bound is NOW) are comparable at all.
139+
*/
140+
export async function analyticsDateRangeConformanceFindings(
141+
face: AnalyticsDateRangeFace,
142+
options: AnalyticsDateRangeResolutionOptions = {},
143+
): Promise<string[]> {
144+
const findings: string[] = [];
145+
const say = (msg: string) => findings.push(`${face.name}: ${msg}`);
146+
const seenWindows = new Set<string>();
147+
148+
// ── The thirteen declared names must resolve, and resolve identically ────
149+
for (const preset of DATE_RANGE_PRESETS) {
150+
const expected: ResolvedAnalyticsDateRange = resolveAnalyticsDateRangeString(preset, options);
151+
const got = await attempt(face, preset);
152+
if ('refusal' in got) {
153+
// ⭐ The control that keeps every refusal assertion below honest: a face
154+
// that refused EVERYTHING would satisfy them all, which is the opposite
155+
// defect and just as silent.
156+
say(`refused the DECLARED preset '${preset}' (${got.refusal.code ?? 'no code'})`);
157+
continue;
158+
}
159+
const w = got.window;
160+
if (w.start !== expected.start || w.end !== expected.end) {
161+
say(
162+
`lowered '${preset}' to [${w.start}, ${w.end}] but the shared resolver says `
163+
+ `[${expected.start}, ${expected.end}]`,
164+
);
165+
}
166+
if (w.endExclusive !== expected.endExclusive) {
167+
say(
168+
`compares '${preset}''s upper bound ${w.endExclusive ? 'exclusively' : 'inclusively'}, `
169+
+ `but a ${ROLLING.includes(preset) ? 'rolling window ends at NOW and REACHES its bound' : 'calendar window stops BEFORE its end instant'}`,
170+
);
171+
}
172+
// The fallback's shape, named directly: its two bounds were the preset's
173+
// own NAME, which is how twelve of thirteen windows became one.
174+
if (w.start === preset || w.end === preset) {
175+
say(`used the preset NAME '${preset}' as a window bound — the [range, range] fallback shape`);
176+
}
177+
seenWindows.add(`${w.start}..${w.end}`);
178+
}
179+
if (seenWindows.size > 0 && seenWindows.size !== DATE_RANGE_PRESETS.length) {
180+
say(
181+
`collapsed the ${DATE_RANGE_PRESETS.length} declared presets onto ${seenWindows.size} `
182+
+ 'distinct window(s) — distinct names must select distinct rows',
183+
);
184+
}
185+
186+
// ── Everything else is REFUSED, with one envelope ────────────────────────
187+
const envelopes = new Set<string>();
188+
for (const bad of ANALYTICS_DATE_RANGE_REFUSED_SPELLINGS) {
189+
const got = await attempt(face, bad);
190+
if ('window' in got) {
191+
say(
192+
`ANSWERED ${JSON.stringify(bad)} with [${got.window.start}, ${got.window.end}] instead of `
193+
+ 'refusing — an unresolvable window is a refusal, never a window',
194+
);
195+
continue;
196+
}
197+
if (got.refusal.code !== 'ANALYTICS_DATE_RANGE_UNRECOGNIZED') {
198+
say(`refused ${JSON.stringify(bad)} with code ${String(got.refusal.code)}, not ANALYTICS_DATE_RANGE_UNRECOGNIZED`);
199+
}
200+
if (got.refusal.status !== 400) {
201+
say(`refused ${JSON.stringify(bad)} with status ${String(got.refusal.status)}, not 400`);
202+
}
203+
envelopes.add(JSON.stringify({ code: got.refusal.code, status: got.refusal.status }));
204+
}
205+
if (envelopes.size > 1) {
206+
say(`raised ${envelopes.size} different envelopes for one condition — ADR-0112 asks for one`);
207+
}
208+
209+
// ── The vocabulary is case-sensitive, and snake_case ─────────────────────
210+
for (const wrongCase of ['TODAY', 'Last_7_Days', 'This_Month']) {
211+
const got = await attempt(face, wrongCase);
212+
if ('window' in got) say(`accepted ${JSON.stringify(wrongCase)} — the vocabulary is case-sensitive`);
213+
}
214+
215+
// ── ⛔ The CALLER's explicit window is not this vocabulary's business ─────
216+
const explicit = await attempt(face, ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW);
217+
if ('refusal' in explicit) {
218+
say(`refused the explicit [start, end] window — only the STRING arm is a closed vocabulary`);
219+
} else {
220+
if (explicit.window.start !== ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW[0]
221+
|| explicit.window.end !== ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW[1]) {
222+
say(
223+
`rewrote the caller's explicit window to [${explicit.window.start}, ${explicit.window.end}]`,
224+
);
225+
}
226+
if (explicit.window.endExclusive) {
227+
// #16179: only a window the face RESOLVED is compared exclusively. A
228+
// caller's bound is a bound they wrote meaning "include it".
229+
say("narrowed the caller's explicit window to an exclusive upper bound");
230+
}
231+
}
232+
233+
return findings;
234+
}

0 commit comments

Comments
 (0)