From 9459e524b2f1397c6bcb31099a03831157c43cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:08:31 +0000 Subject: [PATCH 1/5] fix(analytics): lower the closed dateRange preset vocabulary once, and refuse the rest Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- packages/core/src/index.ts | 8 + .../core/src/utils/analytics-date-range.ts | 236 ++++++++++++++++++ .../driver-memory/src/memory-analytics.ts | 198 +++++---------- .../src/strategies/native-sql-strategy.ts | 37 ++- .../src/strategies/objectql-strategy.ts | 65 +++-- 5 files changed, 379 insertions(+), 165 deletions(-) create mode 100644 packages/core/src/utils/analytics-date-range.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e8b681f4e1..41522d693f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -77,6 +77,14 @@ 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'; + // [#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 diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts new file mode 100644 index 0000000000..ceec30e7a1 --- /dev/null +++ b/packages/core/src/utils/analytics-date-range.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `timeDimensions[].dateRange` — the ONE lowering of the closed date-range + * preset vocabulary into a concrete window, and the ONE refusal for a string + * outside it (#16322, the driver half of #16041). + * + * ## Why this lives here and not in a driver + * + * #16041 closed the string arm of `AnalyticsQuery.timeDimensions[].dateRange` + * to `DATE_RANGE_PRESETS`. The drivers were then asked to align to that closed + * contract "rather than each guessing" — and there is more than one of them: + * `driver-memory`'s cube face resolves a window itself, and the SQL analytics + * path (`@objectstack/service-analytics`' ObjectQL and native-SQL strategies) + * lowers the same field into filter bounds and into raw SQL. A second + * implementation is how the two backends came to answer the SAME bad input + * with OPPOSITE wrong answers in the first place — memory matched every + * `Date`-typed row, SQL read a bare string as a single ISO day, neither an + * error. So the lowering is written ONCE, in the package all of them already + * depend on, exactly like {@link resolveFilterToken} one file over. + * + * ⛔ The preset LIST is never hand-copied. {@link PRESET_WINDOW_TOKENS} is a + * `Record`, so a name added to or removed from + * `DATE_RANGE_PRESETS` fails this package's own type-check, and + * `analytics-date-range.test.ts` pins the key set against that module at run + * time as well. `date-range-presets.ts`'s header records that the vocabulary + * once existed in three drifting copies; a fourth in a driver would repeat it. + * + * ## Every boundary is a `{date-macro}` token, resolved by the one resolver + * + * The windows below are not calendar arithmetic — they are pairs of tokens + * from the platform's one date-macro vocabulary, handed to + * {@link resolveFilterToken}. Three things follow, and each is a defect this + * file therefore cannot have: + * + * - **One calendar.** The resolver anchors on the reference timezone's + * calendar day and does its arithmetic on a UTC proxy (#15825's two + * defects, both of which were a driver doing this itself). + * - **One answer per question.** `dateRange: 'this_month'` and a + * `{month_start}` filter token select the same boundary, because they ARE + * the same call. + * - **One migration.** A month-length clamp or a Monday-based week start + * fixed there is fixed here. + * + * The START token of every preset is the one `DATE_RANGE_PRESET_MACRO_WINDOWS` + * already prescribes for it (pinned in the test), so the window a driver + * resolves and the window the refusal message tells an author to write open at + * the same instant. + * + * ## ⭐ The END is stated as the day the window STOPS BEFORE + * + * Half-open, and deliberately: the macro vocabulary's own documented shape is + * `>= {current_year_start} AND < {next_year_start}`. Two adjacent windows that + * both claim the boundary instant double-count a row stamped on it — the + * #16179 defect, measured on `'today'`, which counted the first instant of + * tomorrow. + * + * ⚠️ {@link ResolvedAnalyticsDateRange.endExclusive} is the flag that carries + * that reading to the comparison, and it is NOT constant across the + * vocabulary: + * + * - the ten CALENDAR presets end at a day boundary the window must not + * contain ⇒ `endExclusive: true`; + * - the three ROLLING `last_N_days` presets end at NOW, an instant the + * window REACHES ⇒ `endExclusive: false`. + * + * ⛔ And it is never `true` for a window a CALLER wrote out as an explicit + * `[a, b]` array — that arm never reaches this file. `$lte` on a caller's + * bound is the reading published since the analytics face existed (#16179); + * this module resolves only the string arm, so the separation is structural. + * + * ⛔ A bare `YYYY-MM-DD` is never emitted as a bound from here. Both bounds are + * rendered as INSTANTS through {@link zonedDateStartToUtcMs}, i.e. that zone's + * midnight. A bare day would be widened by `nextUtcCalendarDay` and cut at UTC + * midnight, undoing #16042 for every non-UTC caller — measured on + * `Asia/Shanghai`, eight hours late. The failure is silent, which is why the + * road is closed here rather than left to each caller. + */ + +import { + isDateRangePresetName, + analyticsDateRangeRefusalMessage, + type DateRangePreset, +} from '@objectstack/spec/data'; +import { zonedDateStartToUtcMs } from './datetime.js'; +import { resolveFilterToken } from './filter-tokens.js'; + +/** + * A resolved `dateRange` window: two instants, plus what the upper one means. + * + * `start` and `end` are ISO instants (`toISOString()`), never bare calendar + * days — see the module header for why that road is closed on this path. + */ +export interface ResolvedAnalyticsDateRange { + /** The first instant the window contains, inclusive. */ + readonly start: string; + /** The upper bound; {@link endExclusive} says whether the window contains it. */ + readonly end: string; + /** + * Is `end` the first instant AFTER the window rather than its last instant? + * `true` for the ten calendar presets, `false` for the three rolling ones, + * whose upper bound is NOW. + */ + readonly endExclusive: boolean; +} + +/** + * The `{date-macro}` token pair each preset resolves to — `[start, endBefore]` + * in the UNWRAPPED spelling {@link resolveFilterToken} takes. + * + * `endBefore: null` means the window has no calendar upper bound and runs to + * the reference instant (the rolling `last_N_days` family), matching the + * `end: null` of `DATE_RANGE_PRESET_MACRO_WINDOWS`. + * + * ⚠️ The end token names the day the window STOPS BEFORE, not its last day — + * `this_week` ends at `next_week_start`, not at `week_end`. That is why this + * table states the ends itself instead of reading the spec's prescription + * pair: `DATE_RANGE_PRESET_MACRO_WINDOWS` is written for `$between`, whose + * bare-day upper bound is INCLUSIVE of that whole day, so its ends are one day + * earlier for the eight period presets. The STARTS agree exactly, and the test + * pins that they do. + */ +const PRESET_WINDOW_TOKENS: Readonly< + Record +> = { + today: ['today', 'tomorrow'], + yesterday: ['yesterday', 'today'], + this_week: ['week_start', 'next_week_start'], + last_week: ['last_week_start', 'week_start'], + this_month: ['month_start', 'next_month_start'], + last_month: ['last_month_start', 'month_start'], + this_quarter: ['quarter_start', 'next_quarter_start'], + last_quarter: ['last_quarter_start', 'quarter_start'], + this_year: ['year_start', 'next_year_start'], + last_year: ['last_year_start', 'year_start'], + last_7_days: ['7_days_ago', null], + last_30_days: ['30_days_ago', null], + last_90_days: ['90_days_ago', null], +}; + +/** Options every `dateRange` resolution reads. */ +export interface AnalyticsDateRangeResolutionOptions { + /** Reference instant. Defaults to `new Date()` at call time. */ + readonly now?: Date; + /** + * IANA reference timezone the window is anchored on — which calendar day + * "today" is, and where that day BEGINS as an instant. An unset, `'UTC'` or + * unknown zone degrades to UTC rather than throwing, matching + * `calendarPartsInTzOrUtc`. + */ + readonly timezone?: string; +} + +/** That macro token's calendar day, then the instant it BEGINS in `timezone`. */ +function tokenDayStart(token: string, now: Date, timezone?: string): string { + const day = resolveFilterToken(token, { now, timezone }); + // Every token in the table above is a calendar-day macro, so the resolver + // answers `YYYY-MM-DD`. A non-string answer would mean the vocabulary moved + // under this table, which the pin test catches long before a caller does. + return new Date(zonedDateStartToUtcMs(String(day), timezone)).toISOString(); +} + +/** + * Lower one preset name to its window. + * + * @param preset - A member of `DATE_RANGE_PRESETS`. + * @param options - Reference instant and timezone; see + * {@link AnalyticsDateRangeResolutionOptions}. + */ +export function resolveAnalyticsDateRangePreset( + preset: DateRangePreset, + options: AnalyticsDateRangeResolutionOptions = {}, +): ResolvedAnalyticsDateRange { + const now = options.now ?? new Date(); + const [startToken, endToken] = PRESET_WINDOW_TOKENS[preset]; + const start = tokenDayStart(startToken, now, options.timezone); + if (endToken === null) { + // The rolling family. The upper bound is the current INSTANT, which no + // zone moves — and it is a moment the window REACHES, so it stays + // INCLUSIVE (#16179 leaves this leg alone). + return { start, end: now.toISOString(), endExclusive: false }; + } + return { start, end: tokenDayStart(endToken, now, options.timezone), endExclusive: true }; +} + +/** + * The ADR-0112 refusal every analytics face raises for a `dateRange` string + * outside the closed vocabulary — `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. + * + * ONE constructor, called by `driver-memory`'s cube face and by both + * `service-analytics` strategies, because "memory and SQL refuse identically" + * is a property a shared conformance fixture can only hold if there is one + * refusal to hold. The wording is the spec's + * {@link analyticsDateRangeRefusalMessage} — the same sentence the schema door + * answers with (the #5240 convention: one condition, one wording), quoted + * rather than restated. + * + * ⚠️ The code is registered under `@objectstack/runtime` (the door that names + * the wire vocabulary) and this package carries a recorded provenance waiver + * in `error-code-ledger.zod.ts` — the shared-constructor shape, the same one + * `UPDATE_ID_MISMATCH` records. + * + * Reachability: on `POST /analytics/query` and `/analytics/sql` the schema door + * refuses first and this never fires. It is the answer for the in-process + * caller past that door — `AnalyticsService.query`, a driver's cube face + * called directly, and `POST /analytics/dataset/query`, which types + * `selection.timeDimensions` from `AnalyticsQuery` but does not Zod-parse it. + */ +export function analyticsDateRangeUnrecognizedError(input: unknown): Error { + const err = new Error(analyticsDateRangeRefusalMessage(input)) as Error & { + code?: string; + status?: number; + }; + err.code = 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'; + err.status = 400; + return err; +} + +/** + * Resolve the STRING arm of `dateRange` — a preset name to its window, and + * anything else to the refusal. + * + * ⛔ The array arm never comes here: an explicit `[a, b]` is the CALLER's + * window and keeps its published inclusive-upper reading (#16179). + * + * @throws the {@link analyticsDateRangeUnrecognizedError} envelope for a + * string outside `DATE_RANGE_PRESETS` — never a silent widening, which is + * the defect #16041 abolished at the contract and this closes at the faces. + */ +export function resolveAnalyticsDateRangeString( + range: string, + options: AnalyticsDateRangeResolutionOptions = {}, +): ResolvedAnalyticsDateRange { + if (!isDateRangePresetName(range)) throw analyticsDateRangeUnrecognizedError(range); + return resolveAnalyticsDateRangePreset(range, options); +} diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index 0315a768e1..550c431bae 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -11,8 +11,10 @@ import { Logger, createLogger, nextUtcCalendarDay, - calendarPartsInTzOrUtc, - zonedDateStartToUtcMs, + // [#16322] The ONE lowering of the closed `dateRange` preset vocabulary and + // the ONE refusal for a string outside it, shared with the SQL analytics + // path so the two backends cannot answer one input differently again. + resolveAnalyticsDateRangeString, } from '@objectstack/core'; import { assertFilterConditionShape, @@ -1477,146 +1479,60 @@ export class MemoryAnalyticsService implements IAnalyticsService { return sql.trim(); } + /** + * The STRING arm of `timeDimensions[].dateRange`, resolved against the ONE + * closed vocabulary — or refused (#16322, the driver half of #16041). + * + * ## What this method stopped doing, and why each half had to go + * + * It used to be a hand-rolled parser with two branches and a fallback, and + * ALL THREE were defects by the time #16041 closed the contract: + * + * - `range === 'today'` was the only preset it understood. Every other + * member of the declared vocabulary fell past it — MEASURED on the built + * dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, 2099): + * `today` selected 1/5, and the other twelve selected **5/5, 2099 + * included**. So a VALID preset like `last_30_days` was accepted by the + * schema and then silently widened to all of history: the exact defect + * class #16041 abolished at the contract, relocated onto the + * newly-blessed vocabulary. + * - `range.startsWith('last ')` matched a SPACE, a relative dialect + * (`'last 7 days'`) the closed vocabulary does not contain and the + * schema door now refuses. It could never fire for a preset name, which + * spells them `last_7_days`. + * - the `[range, range]` fallback is the silent widening itself, and it is + * what the refusal below replaces. ⛔ It must not come back in any + * spelling: an unresolvable window is a REFUSAL, not a window. + * + * ## ⛔ The calendar arithmetic did not move here — it left + * + * #15825's two defects (a LOCAL-midnight boundary rendered as UTC, and + * `last N …` arithmetic done on the local calendar) and #16042's dropped + * `timezone` were repaired in this method, and are now repaired ONCE for + * every analytics face in `@objectstack/core`'s + * {@link resolveAnalyticsDateRangeString} — the same package, one file over + * from the `{date-macro}` resolver whose tokens it lowers. ⛔ Re-deriving any + * of it here is the three-drifting-copies shape the vocabulary module's own + * header records; the SQL analytics path calls the same function, which is + * what makes "the drivers agree" checkable rather than asserted. + * + * ## ⭐ What survives unchanged + * + * `'today'` still resolves to `[that zone's midnight, tomorrow's midnight)` + * with `endExclusive: true` — #16179's repair, byte for byte, because the + * shared resolver states the same window in the same tokens. The three + * rolling `last_N_days` presets end at NOW and stay INCLUSIVE. And the + * explicit `[a, b]` array arm never arrives here at all: it is discriminated + * at the call site and keeps its published `$lte` reading. + * + * @throws the ADR-0112 `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope for + * a string outside `DATE_RANGE_PRESETS` — the same code, status and + * wording the schema door and the SQL analytics path answer with, since + * all three call one constructor. + */ private parseDateRangeString(range: string, timezone?: string): ResolvedDateRange { - // Simple parser for common date range strings - // In production, this would use a proper date range parser - // - // [#16179] Returns a {@link ResolvedDateRange}, not a bare pair: a window - // this function BUILT knows whether its upper bound is inclusive, and that - // answer cannot be recovered downstream -- a resolved bound and a caller's - // bound are the same `toISOString()` text. Each `return` below states it. - // - // [#15825] ONE calendar, and it is UTC -- the same one `toISOString()` - // renders every bound below on. Two INDEPENDENT defects lived here: - // - // 1. The window BOUNDARY was `new Date(y, m, d)` -- LOCAL midnight -- - // rendered as UTC. Wrong on every day of the year in every non-UTC - // process, with no DST transition needed: measured 2026-09-05, the - // `'today'` bucket ran from the previous 16:00Z at `Asia/Shanghai`, - // from 07:00Z at `America/Los_Angeles` (08:00Z outside its DST). - // 2. The `last N ...` legs did their arithmetic on the LOCAL calendar - // (`setDate` / `setMonth` / `setFullYear`) and rendered on the UTC - // one. `setDate` preserves WALL-CLOCK time, so the instant moves - // n x 24h only while every local day in the window is 24 hours - // long; across a DST transition it moves 23h or 25h and the window - // start slips an hour. - // - // ⛔ They do not fix each other: `setUTCDate` alone leaves the - // local-midnight boundary in place, and `Date.UTC` alone leaves the - // arithmetic mixed. Each is pinned by its own file, and each was ablated - // separately to prove it -- `memory-analytics-date-range-utc-window.test.ts` - // (boundary; red in any non-UTC zone, no transition instant needed) and - // `memory-analytics-date-range-dst.test.ts` (arithmetic; CANNOT go red at - // TZ=UTC, where the two spellings are indistinguishable -- which is - // exactly why nothing in CI ever reddened on this). - // - // UTC is the target calendar, not merely "a consistent one". The rest of - // the platform resolves a bare date to the UTC day: `@objectstack/core`'s - // `{today}` filter-token macro builds its reference day as - // `new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts - // when the context carries no timezone, and `{TODAY()}` in flow templates - // resolves to the UTC day (#14852, same two-calendar shape). So the same - // analytics question asked through this path and through a flow token no - // longer selects different rows in one deployment -- that agreement, not - // the hour count, is what this repair restores. ⚠️ That paragraph scopes - // itself to a query carrying NO timezone; a query that carries one is - // answered on THAT zone's calendar -- see [#16042] below. - // - // [#16042] The reference TIMEZONE, which this path used to accept and drop. - // - // `AnalyticsQuery.timezone` is declared optional with no default precisely - // because an ABSENT value is a meaningful state that the engine resolves - // (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2 — - // `service-analytics`' `buildQuery` resolves that whole chain and writes the - // ANSWER into `query.timezone` before a driver ever sees it). So a driver - // owes the chain's last two links: the value it was handed, else UTC. The - // third state -- accepting the field and ignoring it -- is the one that - // misleads, and it is what a caller asking `'today'` with - // `timezone: 'Asia/Shanghai'` got: the UTC day, silently, with no warning. - // - // TWO halves, and each needs its own primitive: - // - // 1. WHICH calendar day "now" is -- `calendarPartsInTzOrUtc(now, tz)`, - // read from the platform tz database. Arithmetic then runs on a UTC - // "proxy" date built from those parts, the `proxyDay()` pattern in - // `@objectstack/core`'s filter-token macros: working in UTC keeps - // `last N months` free of DST jumps, and the zone only decides which - // calendar day the window is anchored to. - // 2. WHERE that day BEGINS as an instant -- `zonedDateStartToUtcMs(ymd, - // tz)`, that zone's local midnight. This half is required because the - // bounds here are rendered with `toISOString()` and compared against - // DATETIME values, which is exactly the case ADR-0053 settles in - // `service-analytics`' drill ranges: "`datetime` -> the reference tz's - // MIDNIGHT INSTANT (ISO), because the bucket is defined on that tz's - // calendar"; only a `date`-typed, tz-naive column takes the bare - // `YYYY-MM-DD` calendar bound. - // - // ⛔ Half 1 alone is NOT the fix, and the failure is silent: it would - // anchor to Shanghai's calendar day but cut it at UTC midnight, a window - // that is neither the UTC day nor the Shanghai day but an 8-hour-shifted - // hybrid -- worse for that caller than the UTC day they get today. - // ⛔ Nor is `+ 86_400_000` a next-day boundary once a zone is in play: - // measured on `America/New_York`, 2026-03-08 begins at 05:00Z and 2026-03-09 - // at 04:00Z, so that spring-forward day is 23 hours long. - // - // NO-TIMEZONE CASE UNCHANGED, by construction: `zonedDateStartToUtcMs` - // returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so - // every bound below is byte-identical to #15825's for a query carrying no - // timezone -- the common case, and the one this must not disturb. An - // unknown zone degrades to UTC rather than throwing, the same call - // `calendarPartsInTzOrUtc` makes one line above. - const now = new Date(); - const ref = calendarPartsInTzOrUtc(now, timezone); - const today = new Date(Date.UTC(ref.year, ref.month - 1, ref.day)); - /** That proxy day's `YYYY-MM-DD`, then the instant it BEGINS in `timezone`. */ - const boundary = (proxy: Date): string => - new Date(zonedDateStartToUtcMs(proxy.toISOString().slice(0, 10), timezone)).toISOString(); - - if (range === 'today') { - // The next calendar day, via the proxy calendar -- never `+ 86_400_000`. - const tomorrow = new Date(today.getTime()); - tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); - // [#16179] `endExclusive`, and the flag is the whole repair: the upper - // bound is where TOMORROW begins, which is the one instant `'today'` must - // NOT contain. Compared inclusively it made every day window one instant - // too long, so two adjacent days overlapped at midnight and a row stamped - // there was counted twice -- silently, with no error and no warning. - // - // ⛔ Emitting a bare `YYYY-MM-DD` end instead -- the other spelling of - // this repair -- is NOT available on this path and the failure would be - // silent: `boundary()` renders that zone's midnight INSTANT, and a bare - // day would be widened by `nextUtcCalendarDay` and cut at `T00:00:00Z`, - // i.e. at UTC midnight, undoing #16042 for every non-UTC caller. Measured - // on `Asia/Shanghai`: the window ends at 2026-09-06T16:00:00.000Z, and - // the bare-day route would end it eight hours late. - return { bounds: [boundary(today), boundary(tomorrow)], endExclusive: true }; - } else if (range.startsWith('last ')) { - const parts = range.split(' '); - const num = parseInt(parts[1]); - const unit = parts[2]; - const start = new Date(today); - - if (unit.startsWith('day')) { - start.setUTCDate(start.getUTCDate() - num); - } else if (unit.startsWith('week')) { - start.setUTCDate(start.getUTCDate() - num * 7); - } else if (unit.startsWith('month')) { - start.setUTCMonth(start.getUTCMonth() - num); - } else if (unit.startsWith('year')) { - start.setUTCFullYear(start.getUTCFullYear() - num); - } - - // The upper bound is the current INSTANT, which no zone moves -- and it - // is a moment the window REACHES, not one it stops before, so it stays - // INCLUSIVE ([#16179] leaves this leg alone). ⚠️ No preset in the declared - // vocabulary reaches this branch today: `DATE_RANGE_PRESETS` spells them - // `last_7_days`, and `startsWith('last ')` wants a space (#16322). - return { bounds: [boundary(start), now.toISOString()], endExclusive: false }; - } - - // Fallback -- an inclusive pair of the raw string, unchanged and NOT this - // card's question (#16041 / #16322 own what an unresolved range matches). - return { bounds: [range, range], endExclusive: false }; + const window = resolveAnalyticsDateRangeString(range, { timezone }); + return { bounds: [window.start, window.end], endExclusive: window.endExclusive }; } private generateSqlFromPipeline(table: string, pipeline: Record[]): string { 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 1078b75b92..1a9e286f13 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -17,7 +17,7 @@ import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column. import { datasetInvalidError, invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; -import { nextUtcCalendarDay } from '@objectstack/core'; +import { nextUtcCalendarDay, resolveAnalyticsDateRangeString } from '@objectstack/core'; /** * The SQL wrapper for each aggregate a measure's `type` can name. @@ -497,7 +497,19 @@ export class NativeSQLStrategy implements AnalyticsStrategy { for (const td of query.timeDimensions) { const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins); if (td.dateRange) { - const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange]; + // [#16322] The STRING arm is the CLOSED preset vocabulary (#16041), + // lowered by the ONE shared resolver `driver-memory` and the ObjectQL + // strategy also call — so one dashboard's `last_30_days` opens on the + // same instant on every backend — and REFUSED with the ADR-0112 + // `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope when it is not a + // preset name. ⛔ It used to become the point window + // `col >= 'last_30_days' AND col <= 'last_30_days'` (measured), which + // is not a narrower query but a nonsense one whose answer depends on + // how the dialect compares a vocabulary word against a timestamp. + 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 @@ -514,16 +526,21 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // 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. - const nextDay = nextUtcCalendarDay(range[1]); + // + // [#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}`; - if (nextDay != null) { - params.push(this.coerceTemporal(ctx, td2, nextDay)); - whereClauses.push(`(${lower} AND ${column} < $${params.length})`); - } else { - params.push(this.coerceTemporal(ctx, td2, range[1])); - whereClauses.push(`(${lower} AND ${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 b3773cff25..dc07307311 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -21,7 +21,7 @@ import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column. import { invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; -import { nextUtcCalendarDay } from '@objectstack/core'; +import { nextUtcCalendarDay, resolveAnalyticsDateRangeString } from '@objectstack/core'; import { rebucketCrossObject, RECOMBINABLE_METHODS, @@ -1647,13 +1647,16 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * HERE on every driver — and "bucketed trend" is precisely the shape that also * carries a range ("last 12 months", "this quarter"). * - * Bounds are inclusive on both ends — logically "from day X through day Y". - * The `$lte` end is left as the bare calendar day on purpose: the driver's - * filter compiler owns the calendar-day → instant translation, compiling a - * bare-day `$lte` on a `datetime` column into the half-open `< nextDay` - * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy` - * performs the same half-open translation itself because it binds into raw - * SQL, so one dashboard reads the same on every driver. + * An EXPLICIT `[a, b]` window is inclusive on both ends — logically "from day + * X through day Y". The `$lte` end is left as the bare calendar day on + * purpose: the driver's filter compiler owns the calendar-day → instant + * translation, compiling a bare-day `$lte` on a `datetime` column into the + * half-open `< nextDay` (#3777) while a `date` column keeps the plain `<=`. + * `NativeSQLStrategy` performs the same half-open translation itself because + * it binds into raw SQL, so one dashboard reads the same on every driver. + * + * [#16322] A window this face RESOLVED is a different question and carries + * its own upper reading — see the string arm below. * * [#5526] Bounds are forwarded at the type `dateRange` is DECLARED with — * `string` (`AnalyticsQuerySchema`'s `timeDimensions[].dateRange: string[]`) — @@ -1671,10 +1674,21 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * the very coercion that already makes a `where` bound on that same column * work today. * - * A bare-string `dateRange` degenerates to the single point `[s, s]`, matching - * `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here; - * neither SQL path resolves them, and inventing a second interpretation on the - * driver-independent path is how the two would drift apart again. + * [#16322] A bare string is a member of the CLOSED date-range preset + * vocabulary (#16041) and is lowered to a real window by + * `@objectstack/core`'s `resolveAnalyticsDateRangeString` — the same call + * `driver-memory`'s cube face makes, so this is not a second interpretation + * but the only one. Anything else is REFUSED with the ADR-0112 + * `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope. + * + * ⛔ It used to degenerate to the single point `[s, s]` — MEASURED on the + * dataset door, which does not Zod-parse its selection: `last_30_days` + * compiled to `created_at >= 'last_30_days' AND created_at <= 'last_30_days'` + * on both SQL strategies, and so did `'not a range at all'`, and so did + * `'today'`. A nonsense point window is not a narrower query, it is a + * DIFFERENT query whose answer depends on how the backend happens to compare + * 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. @@ -1689,8 +1703,31 @@ export class ObjectQLStrategy implements AnalyticsStrategy { const out: Array<{ field: string; bounds: Record }> = []; for (const td of query.timeDimensions ?? []) { if (!td.dateRange) continue; - const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange]; - const [start, end = start] = range; + // [#16322] The STRING arm is the CLOSED preset vocabulary, resolved by + // the one shared lowering `driver-memory`'s cube face also calls — so a + // dashboard's `last_30_days` opens on the same instant on every backend, + // and a string outside the vocabulary is REFUSED here rather than + // degenerating to a point window bound with the literal name. + if (!Array.isArray(td.dateRange)) { + const window = resolveAnalyticsDateRangeString(td.dateRange, { timezone: query.timezone }); + out.push({ + field: this.resolveFieldName(cube, td.dimension, 'dimension'), + // A window this path RESOLVED states its own upper reading: the ten + // calendar presets stop BEFORE their end instant (`$lt`, so two + // adjacent windows cannot both count a row stamped on the boundary — + // #16179's memory-side repair, same rule on this side), while the + // three rolling ones end at NOW, a moment they reach. + bounds: window.endExclusive + ? { $gte: window.start, $lt: window.end } + : { $gte: window.start, $lte: window.end }, + }); + continue; + } + // ⛔ The CALLER's explicit window is untouched, bound for bound: `$lte` + // 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; out.push({ field: this.resolveFieldName(cube, td.dimension, 'dimension'), From 9301d1b913b2e91af4e4a1f93bbbb446d4f2e178 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:27:17 +0000 Subject: [PATCH 2/5] test(analytics): reinstate the 15 retired date-range pins and add the cross-face conformance fixture Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../src/utils/analytics-date-range.test.ts | 242 +++++++++++ .../memory-analytics-date-range-dst.test.ts | 406 ++++++++++++------ ...mory-analytics-date-range-timezone.test.ts | 127 +++++- ...ry-analytics-date-range-utc-window.test.ts | 78 +++- ...ytics-daterange-driver-conformance.test.ts | 308 +++++++++++++ 5 files changed, 993 insertions(+), 168 deletions(-) create mode 100644 packages/core/src/utils/analytics-date-range.test.ts create mode 100644 packages/runtime/src/analytics-daterange-driver-conformance.test.ts diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts new file mode 100644 index 0000000000..abeb7affe5 --- /dev/null +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16322 — the ONE lowering of `timeDimensions[].dateRange`'s closed preset + * vocabulary, and the ONE refusal for a string outside it. + * + * ## What this file is guarding against, stated as the measurement + * + * Before this module existed, `driver-memory` was the only face that resolved + * a `dateRange` string at all, and it understood exactly one preset. Driving + * its BUILT dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + * 2099) on `b834b48e7a`: `today` selected 1/5 and **the other twelve declared + * presets selected 5/5 — 2020 and 2099 included** — because each fell to a + * `[range, range]` fallback whose two bounds were the preset's own NAME. The + * two SQL strategies lowered the same names to the point window + * `col >= 'last_30_days' AND col <= 'last_30_days'` (measured on the dataset + * door in the same run). So a VALID preset was accepted by the schema and then + * answered with all of history on one backend and a nonsense comparison on the + * other. + * + * ⇒ every assertion below is about a property that measurement violated: + * every declared name resolves, the windows are distinct and tile, they move + * with the reference timezone, and a name outside the vocabulary is a REFUSAL + * rather than a window. + * + * ## ⛔ The list is never restated here either + * + * The cases iterate `DATE_RANGE_PRESETS` itself, so a name added to that module + * without a window here fails at this file rather than at a dashboard. + */ + +import { describe, it, expect } from 'vitest'; +import { + DATE_RANGE_PRESETS, + DATE_RANGE_PRESET_MACRO_WINDOWS, + analyticsDateRangeRefusalMessage, + type DateRangePreset, +} from '@objectstack/spec/data'; +import { resolveFilterToken } from './filter-tokens.js'; +import { zonedDateStartToUtcMs } from './datetime.js'; +import { + resolveAnalyticsDateRangePreset, + resolveAnalyticsDateRangeString, + analyticsDateRangeUnrecognizedError, +} from './analytics-date-range.js'; + +/** A frozen reference instant, deliberately mid-week, mid-month, mid-quarter. */ +const NOW = new Date('2026-09-09T12:34:56.789Z'); + +/** The rolling family — the only presets whose upper bound is NOW. */ +const ROLLING: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'last_90_days']; + +const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +describe('#16322 — every declared preset resolves to a real window', () => { + it('resolves all thirteen, and none of them is the name of the preset', () => { + for (const preset of DATE_RANGE_PRESETS) { + const w = resolveAnalyticsDateRangePreset(preset, { now: NOW }); + expect(w.start, preset).toMatch(ISO_INSTANT); + expect(w.end, preset).toMatch(ISO_INSTANT); + // The defect this replaces, stated directly: the fallback returned + // the preset's own name as BOTH bounds. + expect(w.start, preset).not.toBe(preset); + expect(w.end, preset).not.toBe(preset); + expect(Date.parse(w.start), preset).toBeLessThan(Date.parse(w.end)); + } + }); + + it('⛔ never emits a bare `YYYY-MM-DD` — the road that would re-cut the window at UTC midnight', () => { + // Measured on #16042/#16179: `boundary()` renders a zone's midnight + // INSTANT, and a bare day handed downstream is widened by + // `nextUtcCalendarDay` and cut at `T00:00:00Z`, i.e. UTC midnight — + // eight hours late for `Asia/Shanghai`, silently. + for (const preset of DATE_RANGE_PRESETS) { + for (const tz of [undefined, 'Asia/Shanghai', 'America/New_York']) { + const w = resolveAnalyticsDateRangePreset(preset, { now: NOW, timezone: tz }); + expect(w.start, `${preset} @ ${tz}`).toMatch(ISO_INSTANT); + expect(w.end, `${preset} @ ${tz}`).toMatch(ISO_INSTANT); + } + } + }); + + it('the thirteen windows are DISTINCT — the fallback made twelve of them identical', () => { + const seen = new Set( + DATE_RANGE_PRESETS.map((p) => { + const w = resolveAnalyticsDateRangePreset(p, { now: NOW }); + return `${w.start}..${w.end}`; + }), + ); + expect(seen.size).toBe(DATE_RANGE_PRESETS.length); + }); +}); + +describe('#16322 — the START of every window is the token the spec already prescribes', () => { + // The single-sourcing that matters: `DATE_RANGE_PRESET_MACRO_WINDOWS` is + // what the REFUSAL message tells an author to write instead of a preset + // name, so a resolver that opened its window somewhere else would hand out + // a prescription that does not reproduce the answer. + it.each([...DATE_RANGE_PRESETS])('%s opens where the prescription says', (preset) => { + for (const tz of [undefined, 'Asia/Shanghai']) { + const prescribedStartToken = DATE_RANGE_PRESET_MACRO_WINDOWS[preset][0] + .replace(/^\{|\}$/g, ''); + const day = String(resolveFilterToken(prescribedStartToken, { now: NOW, timezone: tz })); + const expected = new Date(zonedDateStartToUtcMs(day, tz)).toISOString(); + expect(resolveAnalyticsDateRangePreset(preset, { now: NOW, timezone: tz }).start) + .toBe(expected); + } + }); +}); + +describe('#16322 — the upper bound is half-open for a calendar window, and NOW for a rolling one', () => { + it('the ten calendar presets are endExclusive, the three rolling ones are not', () => { + for (const preset of DATE_RANGE_PRESETS) { + const w = resolveAnalyticsDateRangePreset(preset, { now: NOW }); + expect(w.endExclusive, preset).toBe(!ROLLING.includes(preset)); + } + }); + + it('a rolling window ends at the reference instant exactly', () => { + for (const preset of ROLLING) { + expect(resolveAnalyticsDateRangePreset(preset, { now: NOW }).end).toBe(NOW.toISOString()); + } + }); + + it('adjacent calendar windows TILE — no instant belongs to both, none falls between', () => { + // The #16179 defect as a property: an inclusive upper bound made two + // adjacent day windows overlap at midnight and counted a row stamped + // there TWICE. Tiling is the shape that cannot do that. + const pairs: Array<[DateRangePreset, DateRangePreset]> = [ + ['yesterday', 'today'], + ['last_week', 'this_week'], + ['last_month', 'this_month'], + ['last_quarter', 'this_quarter'], + ['last_year', 'this_year'], + ]; + for (const [earlier, later] of pairs) { + const a = resolveAnalyticsDateRangePreset(earlier, { now: NOW }); + const b = resolveAnalyticsDateRangePreset(later, { now: NOW }); + expect(a.end, `${earlier} → ${later}`).toBe(b.start); + expect(a.endExclusive, earlier).toBe(true); + } + }); + + it("`today`'s window is the one #16179 pinned — it stops before tomorrow begins", () => { + const w = resolveAnalyticsDateRangePreset('today', { now: NOW }); + expect(w).toEqual({ + start: '2026-09-09T00:00:00.000Z', + end: '2026-09-10T00:00:00.000Z', + endExclusive: true, + }); + }); +}); + +describe('#16322 — the window is anchored on the SUPPLIED timezone, both halves', () => { + // The two halves fail independently and each failure is silent (#16042): + // WHICH calendar day the window is on, and WHERE that day begins. + it("`today` in Asia/Shanghai is the Shanghai day, opening at Shanghai's midnight", () => { + // Instant taken from `memory-analytics-date-range-timezone.test.ts`'s + // measured cell: 20:00Z is already the NEXT calendar day in Shanghai. + const at = new Date('2026-09-06T20:00:00Z'); + expect(resolveAnalyticsDateRangePreset('today', { now: at, timezone: 'Asia/Shanghai' })) + .toEqual({ + start: '2026-09-06T16:00:00.000Z', + end: '2026-09-07T16:00:00.000Z', + endExclusive: true, + }); + expect(resolveAnalyticsDateRangePreset('today', { now: at })).toEqual({ + start: '2026-09-06T00:00:00.000Z', + end: '2026-09-07T00:00:00.000Z', + endExclusive: true, + }); + }); + + it("`last_7_days` opens seven days before the ZONE's day, at that zone's midnight", () => { + // The reinstated #16042 reading, in preset spelling: the retired pin + // measured Asia/Shanghai opening at 2026-08-30T16:00:00.000Z against + // UTC's 2026-08-30T00:00:00.000Z. + const at = new Date('2026-09-06T20:00:00Z'); + expect(resolveAnalyticsDateRangePreset('last_7_days', { now: at, timezone: 'Asia/Shanghai' }).start) + .toBe('2026-08-30T16:00:00.000Z'); + expect(resolveAnalyticsDateRangePreset('last_7_days', { now: at }).start) + .toBe('2026-08-30T00:00:00.000Z'); + }); + + it('an unknown zone degrades to UTC rather than throwing', () => { + expect(resolveAnalyticsDateRangePreset('today', { now: NOW, timezone: 'Mars/Olympus' })) + .toEqual(resolveAnalyticsDateRangePreset('today', { now: NOW })); + }); + + it("a zone whose offset is not a whole hour still opens at that zone's midnight", () => { + // Pacific/Chatham is +12:45 — a whole-hour assumption in the lowering + // would land 15 minutes off and nothing else would notice. + const w = resolveAnalyticsDateRangePreset('today', { + now: new Date('2026-06-15T12:00:00Z'), + timezone: 'Pacific/Chatham', + }); + expect(w.start).toBe('2026-06-15T11:15:00.000Z'); + }); +}); + +describe('#16322 — a string outside the vocabulary is REFUSED, not widened', () => { + const OUTSIDE = [ + 'not a range at all', + 'Last 7 Days', // case — the vocabulary is case-sensitive + 'last 7 days', // the relative dialect #16041 closed + 'last_60_days', // a plausible near-miss that was never declared + '', + '2026-09-09', // a single ISO day is an explicit window's JOB, not a preset + ]; + + it.each(OUTSIDE)('refuses %j with the ADR-0112 envelope', (bad) => { + let thrown: (Error & { code?: string; status?: number }) | null = null; + try { + resolveAnalyticsDateRangeString(bad); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + expect(thrown, 'an unresolvable window must be a refusal, never a window').not.toBeNull(); + expect(thrown!.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(thrown!.status).toBe(400); + }); + + it('speaks the SPEC\'s wording — one condition, one sentence (#5240)', () => { + // ⛔ Not a second convention: the schema door answers this same text, + // so an author correcting the value reads the same prescription + // wherever the refusal reached them. + for (const bad of OUTSIDE) { + expect(analyticsDateRangeUnrecognizedError(bad).message) + .toBe(analyticsDateRangeRefusalMessage(bad)); + } + }); + + it('every DECLARED name is accepted by the same door that refuses those', () => { + // The control. Without it a refusal that rejected EVERYTHING would pass + // every assertion above. + for (const preset of DATE_RANGE_PRESETS) { + expect(() => resolveAnalyticsDateRangeString(preset, { now: NOW })).not.toThrow(); + } + expect(resolveAnalyticsDateRangeString('today', { now: NOW })) + .toEqual(resolveAnalyticsDateRangePreset('today', { now: NOW })); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts index 2bd736a983..027a7ea7d2 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts @@ -1,91 +1,87 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #15825 — DEFECT 2 of 2: the `last N ...` legs of `parseDateRangeString()` - * did their arithmetic on the LOCAL calendar and rendered it on the UTC one. + * #15825 — DEFECT 2 of 2: the `dateRange` window legs of the analytics cube + * face used to do their calendar arithmetic on the PROCESS's local calendar + * and render it on the UTC one — reinstated under #16322 in the closed PRESET + * vocabulary, which is the only spelling the contract still admits. * * ## ⛔ Why this file cannot be written to run only at `TZ=UTC` * - * The two spellings — local `setDate(getDate() - n)` and UTC - * `setUTCDate(getUTCDate() - n)` — are behaviourally INDISTINGUISHABLE at - * `TZ=UTC`, which is precisely why nothing in CI ever went red on this and why - * it shipped. Every case below therefore fakes BOTH halves of the environment: - * a DST-observing zone (`process.env.TZ`, re-read by V8 on the next `Date` - * operation) AND an instant whose day/month/year shift crosses that zone's own - * transition. + * A UTC-calendar spelling (`setUTCDate`, `getUTCDay`, `Date.UTC`) and its local + * twin (`setDate`, `getDay`, `new Date(y, m, d)`) are behaviourally + * INDISTINGUISHABLE at `TZ=UTC`, which is precisely why nothing in CI ever went + * red on this and why it shipped. Every case below therefore fakes BOTH halves + * of the environment: a DST-observing zone (`process.env.TZ`, re-read by V8 on + * the next `Date` operation) AND an instant at which the two spellings actually + * part company. A fence at the bottom re-asserts the indistinguishability, so + * the reason this file is shaped this way cannot quietly stop being true. * - * ## The mechanism, stated once + * ## What each cell measures, and what its control proves * - * `setDate` preserves WALL-CLOCK time, so shifting the local calendar by n - * days moves the INSTANT by exactly n x 24h only while every local day in the - * window is 24 hours long. Across a spring-forward that window is 23 hours; - * across a fall-back, 25. The rendering is `toISOString()` — UTC. So the - * window start slips an hour, and rows in that hour are wrongly gained or - * wrongly lost. `setMonth` / `setFullYear` are the same class, and the - * `last 1 month` cell below shows the local calendar can move the answer by a - * whole DAY, not merely an hour. + * The cell drives the real public entry — `MemoryAnalyticsService.query()`, + * through `AnalyticsQuerySchema.parse` — and asserts the ROW SET it selects in + * a DST zone equals the row set the same query selects at `TZ=UTC`. The oracle + * is that INVARIANCE, not a second implementation: the query carries no + * `timezone`, so the answer is a property of the data and the clock and must + * not move with a host setting. (A query that DOES carry one is the sibling + * file's subject — `memory-analytics-date-range-timezone.test.ts`.) * - * ## ⭐ This is a SEPARATE defect from the window boundary + * The inline control is load-bearing: each cell first asserts that the + * PROCESS-calendar spelling of that preset's window start DISAGREES with the + * one-calendar spelling at that instant. Without it a green run would be + * ambiguous between "the resolution is host-independent" and "this instant + * does not discriminate" — the second being the failure mode that hid this bug + * for years. * - * Defect 1 — `new Date(y, m, d)` building LOCAL midnight — is pinned in - * `memory-analytics-date-range-utc-window.test.ts`. ⛔ Neither repair fixes the - * other, and each was ablated on its own to prove it. Every cell here is built - * on a boundary that is ALREADY `Date.UTC`, so what it measures is the - * arithmetic leg alone. - * - * ## The oracle is timezone-INVARIANCE, not a second implementation + * ⚠️ How SHARP the control is differs by leg, measured, and the difference is + * stated rather than papered over: * - * For `month` and `year` there is no offset-free definition of the answer to - * compare against, so re-deriving one would just be the fix written twice. The - * oracle used instead is the property the card is actually about: at `TZ=UTC` - * the two spellings coincide, so the `TZ=UTC` run IS the reference answer, and - * a correct implementation must return exactly that answer in every other - * zone. Where a definition does exist (`day` / `week`, since a UTC day is - * always 24h) it is asserted as well. + * - on the three ROLLING day legs (`last_7_days` / `last_30_days` / + * `last_90_days`) the two spellings agree except across a transition — + * `setDate` preserves WALL-CLOCK time, so shifting the local calendar by n + * days moves the INSTANT by exactly n x 24h only while every local day in + * the window is 24 hours long. Measured over a 9-zone x 366-day x 7-preset + * sweep of 2026: 14/366 live days for `last_7_days` in every zone below; + * - on the CALENDAR legs (`last_week` / `last_month` / `last_quarter` / + * `last_year`) the local spelling reads the period off a UTC-midnight + * anchor's LOCAL fields, so in a zone AHEAD of UTC it too parts company only + * across a transition (Berlin `last_week`: 14/366; Auckland: 16/366), while + * in a zone BEHIND UTC it disagrees every day of the year (New York + * `last_month`: 366/366 — and by a whole MONTH, not an hour: at + * 2026-11-01T12:00:00Z the window starts 2026-10-01T00:00:00.000Z one way + * and 2026-09-02T00:00:00.000Z the other). * - * ## The inline control is load-bearing - * - * Each cell also evaluates the OLD spelling directly and asserts it DISAGREES - * with the one-calendar answer. Without it a green run would be ambiguous - * between "the fix works" and "these instants are not actually in a transition - * window" — the second being the failure mode that hid this bug for so long. + * ⭐ This is a SEPARATE defect from the window boundary. Defect 1 — + * `new Date(y, m, d)` building LOCAL midnight — is pinned in + * `memory-analytics-date-range-utc-window.test.ts`. ⛔ Neither repair fixes the + * other, and each was ablated on its own to prove it. * - * ## ⛔ The driver cells are RETIRED — #16041 closed the dialect, #16322 reinstates + * ## ⭐ What #16322 changed underneath these cells * - * Every driver-facing cell below fed the relative dialect (`'last 3 days'`, - * `'last 7 days'`, `'last 1 week'`, `'last 2 weeks'`, `'last 1 month'`, - * `'last 3 months'`, `'last 1 year'`) through `AnalyticsQuerySchema.parse`. - * #16041 (maintainer ruling, decision batch #57) closed the string arm of - * `timeDimensions[].dateRange` to the `date-range-presets.ts` vocabulary, so - * that input is refused at the schema door and no longer reaches - * `parseDateRangeString` through any door production has. Re-spelling was - * MEASURED, not assumed: the parser matches `range.startsWith('last ')`, so - * every snake_case preset (`last_7_days`, `last_week`, …) takes the - * `[range, range]` fallback and matches EVERY row — not one of the 13 cells - * can be expressed in the closed vocabulary until #16322 aligns the parser. - * The cells are `it.todo` (retirement was chosen over routing the fixture - * around the schema door, which would have kept a live pin on the - * silent-widening fallback #16041 exists to abolish). + * The driver no longer does this arithmetic at all. `parseDateRangeString` + * delegates to `@objectstack/core`'s `resolveAnalyticsDateRangeString`, which + * lowers each preset to a pair of `{date-macro}` tokens and hands them to the + * one macro resolver — anchored on `calendarPartsInTzOrUtc(now, query.timezone)` + * and stepped with `setUTC*` throughout, so the PROCESS calendar is read on no + * path. These cells are what makes that structural claim a measured one, and + * what would go red if a future edit reached for a local accessor again. * - * ⚠️ COVERAGE LOST until #16322 reinstates it in preset form: the driver is - * no longer measured on the `last N …` ARITHMETIC leg across a DST - * transition — spring-forward and fall-back, all four legs (day / week / - * month / year), both hemispheres, the two non-whole-hour zones. (The - * `'today'` leg across the 23-hour spring-forward day stays covered by - * `memory-analytics-date-range-timezone.test.ts`.) What survives here is the - * CELL TABLE and its controls — every cell still flips, both directions, all - * four legs, the TZ=UTC indistinguishability fence — so the reinstatement - * starts from a verified table. Note for #16322: `last_7_days` / - * `last_30_days` / `last_90_days` are the rolling day-leg presets, while - * `last_week` / `last_month` / `last_quarter` / `last_year` are CALENDAR - * windows, not `n` units back, so the week / month / year cells need - * re-measured instants under the preset semantics. The retired harness - * (`probesSelected` over `MemoryAnalyticsService.query`) is in history at - * 5f4f1f6e22 / 1cf7392728. + * ⚠️ The cells were RE-MEASURED for this reinstatement, ⛔ not re-spelled: the + * old table fed the relative dialect (`'last 3 days'`, `'last 1 month'`) that + * #16041 closed at the schema, and `last_week` / `last_month` / `last_quarter` + * / `last_year` are CALENDAR windows — the PREVIOUS week/month/quarter/year, + * not n units back — so their instants answer a different question from the old + * `last N units` ones. The retired harness is in history at 5f4f1f6e22 / + * 1cf7392728; the cell table below is a fresh sweep (2026-09-09). */ import { describe, it, expect } from 'vitest'; import { vi } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; +import { AnalyticsQuerySchema } from '@objectstack/spec/data'; +import type { AnalyticsQuery, Cube, DateRangePreset } from '@objectstack/spec/data'; const REAL_TZ = process.env.TZ; @@ -103,101 +99,233 @@ async function at(zone: string, instant: string, fn: () => Promise): Promi } } -type Unit = 'day' | 'week' | 'month' | 'year'; +const CUBE: Cube = { + name: 'events', + title: 'Events', + sql: 'events', + measures: { + count: { name: 'count', label: 'Count', type: 'count', sql: 'id' }, + }, + dimensions: { + probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' }, + createdAt: { + name: 'created_at', + label: 'Created At', + type: 'time', + sql: 'created_at', + granularities: ['day'], + }, + }, + public: true, +}; + +// ⭐ Parsed by the CONTRACT, deliberately: every `range` below is a member of +// the closed vocabulary, so a cell that drifted out of it would be refused +// here rather than silently measuring a dialect no door accepts. +const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input); + +/** Ask `range` over rows planted at `instants`; answer which probes came back. */ +async function probesSelected(instants: string[], range: DateRangePreset): Promise { + const driver = new InMemoryDriver({ + initialData: { + events: instants.map((iso, i) => ({ + id: i + 1, + probe: iso, + created_at: new Date(iso), + })), + }, + }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const result = await service.query(asQuery({ + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }], + })); + return result.rows.map((row) => String(row['events.probe'])).sort(); +} + +/** Which arithmetic leg a preset exercises — the axis the table must cover. */ +type Leg = 'rolling-day' | 'week' | 'month' | 'quarter' | 'year'; /** The window boundary, already repaired — defect 1 is not what this file measures. */ -function utcBoundary(): Date { +function utcAnchor(): Date { const n = new Date(); return new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate())); } -/** The DEFECT, spelled out: UTC boundary, arithmetic on the LOCAL calendar. */ -function localArithmeticStart(unit: Unit, num: number): string { - const s = utcBoundary(); - if (unit === 'day') s.setDate(s.getDate() - num); - else if (unit === 'week') s.setDate(s.getDate() - num * 7); - else if (unit === 'month') s.setMonth(s.getMonth() - num); - else s.setFullYear(s.getFullYear() - num); - return s.toISOString(); +/** The ONE-CALENDAR spelling of a preset's window start: UTC fields throughout. */ +function utcCalendarStart(preset: DateRangePreset): string { + const s = utcAnchor(); + switch (preset) { + case 'last_7_days': s.setUTCDate(s.getUTCDate() - 7); return s.toISOString(); + case 'last_30_days': s.setUTCDate(s.getUTCDate() - 30); return s.toISOString(); + case 'last_90_days': s.setUTCDate(s.getUTCDate() - 90); return s.toISOString(); + case 'last_week': { + const dow = (s.getUTCDay() + 6) % 7; // 0 = Monday + s.setUTCDate(s.getUTCDate() - dow - 7); + return s.toISOString(); + } + case 'last_month': + return new Date(Date.UTC(s.getUTCFullYear(), s.getUTCMonth() - 1, 1)).toISOString(); + case 'last_quarter': + return new Date(Date.UTC(s.getUTCFullYear(), Math.floor(s.getUTCMonth() / 3) * 3 - 3, 1)).toISOString(); + case 'last_year': + return new Date(Date.UTC(s.getUTCFullYear() - 1, 0, 1)).toISOString(); + default: + throw new Error(`no control spelling for ${preset}`); + } } -/** - * ⚠️ Used ONLY to place probe rows on either side of the two candidate - * boundaries — never as the oracle. The oracle is the `TZ=UTC` run below. - */ -function utcArithmeticStart(unit: Unit, num: number): string { - const s = utcBoundary(); - if (unit === 'day') s.setUTCDate(s.getUTCDate() - num); - else if (unit === 'week') s.setUTCDate(s.getUTCDate() - num * 7); - else if (unit === 'month') s.setUTCMonth(s.getUTCMonth() - num); - else s.setUTCFullYear(s.getUTCFullYear() - num); - return s.toISOString(); +/** The DEFECT, spelled out: the same window start read off the PROCESS calendar. */ +function localCalendarStart(preset: DateRangePreset): string { + const s = utcAnchor(); + switch (preset) { + case 'last_7_days': s.setDate(s.getDate() - 7); return s.toISOString(); + case 'last_30_days': s.setDate(s.getDate() - 30); return s.toISOString(); + case 'last_90_days': s.setDate(s.getDate() - 90); return s.toISOString(); + case 'last_week': { + const dow = (s.getDay() + 6) % 7; + s.setDate(s.getDate() - dow - 7); + return s.toISOString(); + } + case 'last_month': { + const r = new Date(s); + r.setDate(1); + r.setMonth(r.getMonth() - 1); + return r.toISOString(); + } + case 'last_quarter': { + const r = new Date(s); + r.setDate(1); + r.setMonth(Math.floor(r.getMonth() / 3) * 3 - 3); + return r.toISOString(); + } + case 'last_year': { + const r = new Date(s); + r.setDate(1); + r.setMonth(0); + r.setFullYear(r.getFullYear() - 1); + return r.toISOString(); + } + default: + throw new Error(`no control spelling for ${preset}`); + } } -// The driver harness (`CUBE`, `asQuery`, `probesSelected`) left with the -// retired cells — see the header; #16322 brings it back with the preset form. - interface Cell { zone: string; /** Frozen clock, always written in UTC. */ instant: string; - range: string; - unit: Unit; - num: number; + range: DateRangePreset; + leg: Leg; kind: 'spring-forward' | 'fall-back'; } /** * Red cells — every one MEASURED, ⛔ not guessed: each is an instant at which - * the local-arithmetic spelling actually disagrees with the one-calendar - * answer in that zone, taken from a 9-zone x 366-day sweep of 2026 across all - * four units (2026-09-05). Both hemispheres, both transition directions, all - * four legs (`day` / `week` / `month` / `year`), and two zones whose standard - * offset is not a whole hour (St_Johns -03:30, Chatham +12:45) so a - * whole-hour assumption cannot hide in the repair. + * the process-calendar spelling actually disagrees with the one-calendar + * answer in that zone, from a 9-zone x 366-day x 7-preset sweep of 2026 + * (2026-09-09). Both hemispheres, both transition directions, all five legs + * the closed vocabulary has (the rolling day family plus the four calendar + * periods), and two zones whose standard offset is not a whole hour + * (St_Johns -03:30, Chatham +12:45) so a whole-hour assumption cannot hide in + * the repair. + * + * ⚠️ The zone/direction coverage is the RETIRED table's, carried over + * deliberately so nothing this file used to watch stopped being watched: the + * spring-forward zones are New_York, Los_Angeles, St_Johns, London and Berlin + * (week + year), the fall-back ones Sydney, Auckland, Chatham, Santiago, plus + * New_York and London on the month leg. */ const DST_CELLS: Cell[] = [ - { zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'spring-forward' }, - { zone: 'America/Los_Angeles', instant: '2026-03-09T12:00:00Z', range: 'last 7 days', unit: 'day', num: 7, kind: 'spring-forward' }, - { zone: 'America/St_Johns', instant: '2026-03-09T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'spring-forward' }, - { zone: 'Europe/London', instant: '2026-03-30T12:00:00Z', range: 'last 7 days', unit: 'day', num: 7, kind: 'spring-forward' }, - { zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last 1 week', unit: 'week', num: 1, kind: 'spring-forward' }, - { zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last 2 weeks', unit: 'week', num: 2, kind: 'spring-forward' }, - { zone: 'Australia/Sydney', instant: '2026-04-05T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'fall-back' }, - { zone: 'Pacific/Auckland', instant: '2026-04-05T12:00:00Z', range: 'last 2 weeks', unit: 'week', num: 2, kind: 'fall-back' }, - { zone: 'Pacific/Chatham', instant: '2026-04-05T12:00:00Z', range: 'last 1 month', unit: 'month', num: 1, kind: 'fall-back' }, - { zone: 'America/New_York', instant: '2026-01-01T12:00:00Z', range: 'last 1 month', unit: 'month', num: 1, kind: 'fall-back' }, - { zone: 'Europe/London', instant: '2026-01-01T12:00:00Z', range: 'last 3 months', unit: 'month', num: 3, kind: 'fall-back' }, - { zone: 'America/Santiago', instant: '2026-04-06T12:00:00Z', range: 'last 1 year', unit: 'year', num: 1, kind: 'fall-back' }, - { zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last 1 year', unit: 'year', num: 1, kind: 'spring-forward' }, + { zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last_7_days', leg: 'rolling-day', kind: 'spring-forward' }, + { zone: 'America/Los_Angeles', instant: '2026-03-09T12:00:00Z', range: 'last_30_days', leg: 'rolling-day', kind: 'spring-forward' }, + { zone: 'America/St_Johns', instant: '2026-03-09T12:00:00Z', range: 'last_7_days', leg: 'rolling-day', kind: 'spring-forward' }, + { zone: 'Europe/London', instant: '2026-03-30T12:00:00Z', range: 'last_90_days', leg: 'rolling-day', kind: 'spring-forward' }, + { zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last_week', leg: 'week', kind: 'spring-forward' }, + { zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last_week', leg: 'week', kind: 'spring-forward' }, + { zone: 'Australia/Sydney', instant: '2026-04-05T12:00:00Z', range: 'last_7_days', leg: 'rolling-day', kind: 'fall-back' }, + { zone: 'Pacific/Auckland', instant: '2026-04-05T12:00:00Z', range: 'last_week', leg: 'week', kind: 'fall-back' }, + { zone: 'Pacific/Chatham', instant: '2026-04-05T12:00:00Z', range: 'last_month', leg: 'month', kind: 'fall-back' }, + { zone: 'America/New_York', instant: '2026-11-01T12:00:00Z', range: 'last_month', leg: 'month', kind: 'fall-back' }, + { zone: 'Europe/London', instant: '2026-11-01T12:00:00Z', range: 'last_month', leg: 'month', kind: 'fall-back' }, + { zone: 'Pacific/Auckland', instant: '2026-10-04T12:00:00Z', range: 'last_quarter', leg: 'quarter', kind: 'spring-forward' }, + { zone: 'America/Santiago', instant: '2026-04-06T12:00:00Z', range: 'last_year', leg: 'year', kind: 'fall-back' }, + { zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last_year', leg: 'year', kind: 'spring-forward' }, ]; const label = (c: Cell) => `${c.zone} @ ${c.instant} '${c.range}'`; -describe('#15825 defect 2 — the `last N ...` legs resolve on one calendar, across DST transitions', () => { +/** + * Probes that straddle BOTH candidate window starts, so the row set can tell + * the two spellings apart, plus one comfortably inside the window either way. + */ +function probesFor(c: Cell): string[] { + const truth = Date.parse(utcCalendarStart(c.range)); + const mixed = Date.parse(localCalendarStart(c.range)); + return [...new Set([ + new Date(truth).toISOString(), + new Date(truth - 1).toISOString(), + new Date(mixed).toISOString(), + new Date(mixed - 1).toISOString(), + new Date(truth + 43_200_000).toISOString(), // comfortably inside, both ways + ])].sort(); +} + +describe('#15825 defect 2 — a preset window resolves on one calendar, across DST transitions', () => { for (const c of DST_CELLS) { - // ⛔ RETIRED (#16041 → #16322): `c.range` is the relative dialect the - // closed vocabulary refuses at the schema door; no preset expresses it - // until #16322 aligns the parser (measured — see the header, which also - // states exactly what is uncovered until then). - it.todo(`${c.kind}: ${label(c)} — retired by #16041 (dialect closed at the schema), reinstate under #16322 in preset form`); + it(`${c.kind}: ${label(c)}`, async () => { + const { truth, mixed, probes } = await at(c.zone, c.instant, async () => ({ + truth: utcCalendarStart(c.range), + mixed: localCalendarStart(c.range), + probes: probesFor(c), + })); + + // CONTROL FIRST — if these agree, the cell is not discriminating + // and every assertion below would be vacuous. (It is the whole + // reason a TZ=UTC-only test is worthless here.) + expect( + mixed, + `${label(c)}: the process-calendar spelling must DISAGREE here, otherwise this cell pins nothing`, + ).not.toBe(truth); + + const inZone = await at(c.zone, c.instant, () => probesSelected(probes, c.range)); + const atUtc = await at('UTC', c.instant, () => probesSelected(probes, c.range)); + + // THE ORACLE: at TZ=UTC the two spellings coincide, so this run is + // the reference answer. The process timezone must not move it. + expect(inZone, `${label(c)}: the process timezone changed which rows were counted`).toEqual(atUtc); + + // And the answer must actually be non-trivial — a window that + // selected everything or nothing would compare equal for free. + // ⭐ This half is also what the pre-#16322 driver failed outright: + // every preset but `today` fell to the `[range, range]` fallback + // and selected EVERY row, so `toBeLessThan` was unreachable. + expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeGreaterThan(0); + expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeLessThan(probes.length); + }); } - it('the day and week legs also match the offset-free definition — n x 24h before the UTC day', async () => { - for (const c of DST_CELLS.filter((x) => x.unit === 'day' || x.unit === 'week')) { + it('the rolling day legs match the offset-free definition — n x 24h before the UTC day', async () => { + const days: Partial> = { + last_7_days: 7, last_30_days: 30, last_90_days: 90, + }; + for (const c of DST_CELLS.filter((x) => x.leg === 'rolling-day')) { await at(c.zone, c.instant, async () => { - const days = c.unit === 'week' ? c.num * 7 : c.num; - expect(utcArithmeticStart(c.unit, c.num), label(c)) - .toBe(new Date(utcBoundary().getTime() - days * 86_400_000).toISOString()); + const n = days[c.range]!; + expect(utcCalendarStart(c.range), label(c)) + .toBe(new Date(utcAnchor().getTime() - n * 86_400_000).toISOString()); }); } }); - it('every red cell is live — the local-arithmetic spelling disagrees in all of them', async () => { + it('every red cell is live — the process-calendar spelling disagrees in all of them', async () => { const live: string[] = []; for (const c of DST_CELLS) { await at(c.zone, c.instant, async () => { - if (localArithmeticStart(c.unit, c.num) !== utcArithmeticStart(c.unit, c.num)) live.push(label(c)); + if (localCalendarStart(c.range) !== utcCalendarStart(c.range)) live.push(label(c)); }); } expect(live.length, 'a cell that no longer flips has stopped guarding the fix').toBe(DST_CELLS.length); @@ -207,14 +335,20 @@ describe('#15825 defect 2 — the `last N ...` legs resolve on one calendar, acr const dirs = new Set(); for (const c of DST_CELLS) { await at(c.zone, c.instant, async () => { - dirs.add(localArithmeticStart(c.unit, c.num) < utcArithmeticStart(c.unit, c.num) ? 'early' : 'late'); + dirs.add(localCalendarStart(c.range) < utcCalendarStart(c.range) ? 'early' : 'late'); }); } expect([...dirs].sort()).toEqual(['early', 'late']); }); - it('all four arithmetic legs are covered — day, week, month and year', () => { - expect([...new Set(DST_CELLS.map((c) => c.unit))].sort()).toEqual(['day', 'month', 'week', 'year']); + it('all five legs of the closed vocabulary are covered — rolling days, week, month, quarter, year', () => { + expect([...new Set(DST_CELLS.map((c) => c.leg))].sort()) + .toEqual(['month', 'quarter', 'rolling-day', 'week', 'year']); + }); + + it('all three rolling presets are exercised, not just one of them', () => { + const rolling = DST_CELLS.filter((c) => c.leg === 'rolling-day').map((c) => c.range); + expect([...new Set(rolling)].sort()).toEqual(['last_30_days', 'last_7_days', 'last_90_days']); }); }); @@ -224,7 +358,7 @@ describe('#15825 defect 2 fences', () => { it('⛔ at TZ=UTC the two spellings are INDISTINGUISHABLE — a UTC-only test proves nothing', async () => { for (const c of DST_CELLS) { await at('UTC', c.instant, async () => { - expect(localArithmeticStart(c.unit, c.num), label(c)).toBe(utcArithmeticStart(c.unit, c.num)); + expect(localCalendarStart(c.range), label(c)).toBe(utcCalendarStart(c.range)); }); } }); @@ -233,17 +367,17 @@ describe('#15825 defect 2 fences', () => { for (const zone of ['UTC', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Perth']) { for (const instant of ['2026-03-09T12:00:00Z', '2026-11-02T12:00:00Z', '2026-06-15T12:00:00Z']) { await at(zone, instant, async () => { - expect(localArithmeticStart('day', 7), `${zone} @ ${instant}`) - .toBe(utcArithmeticStart('day', 7)); + expect(localCalendarStart('last_7_days'), `${zone} @ ${instant}`) + .toBe(utcCalendarStart('last_7_days')); }); } } }); - it('an ordinary instant in a DST zone is unaffected — the local day is 24h there', async () => { + it('an ordinary instant in a DST zone is unaffected on the day legs — the local day is 24h there', async () => { for (const zone of [...new Set(DST_CELLS.map((c) => c.zone))]) { await at(zone, '2026-06-15T12:00:00Z', async () => { - expect(localArithmeticStart('day', 3), zone).toBe(utcArithmeticStart('day', 3)); + expect(localCalendarStart('last_7_days'), zone).toBe(utcCalendarStart('last_7_days')); }); } }); diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts index 3c11d14129..d291a7b6ef 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts @@ -304,21 +304,116 @@ describe('#16042 — the resolution is host-independent and degrades to UTC', () }); }); -describe("#16042 — `last N …` anchors on the zone's calendar too", () => { - // ⛔ RETIRED (#16041 → #16322). This case fed `dateRange: 'last 7 days'` - // through `AnalyticsQuerySchema.parse`; #16041 (maintainer ruling, decision - // batch #57) closed the string arm to the `date-range-presets.ts` - // vocabulary, so that dialect is refused at the schema door. Re-spelling - // was MEASURED, not assumed: `parseDateRangeString` matches - // `startsWith('last ')`, so the preset `'last_7_days'` takes the - // `[range, range]` fallback and matches every row — the case cannot be - // expressed until #16322 aligns the parser. +describe("#16042 — the rolling `last_N_days` window anchors on the zone's calendar too", () => { + // ⭐ REINSTATED under #16322, in the spelling the contract now admits. // - // COVERAGE LOST until #16322 reinstates it as `'last_7_days'`: the driver - // is no longer measured on the `last N …` leg anchoring to the SUPPLIED - // zone's calendar day and midnight — Asia/Shanghai's window opening at - // 2026-08-30T16:00:00.000Z rather than UTC's 2026-08-30T00:00:00.000Z, with - // the no-timezone control taking the extra 16 hours of rows. The `'today'` - // leg's zone anchoring — both halves — stays covered by the cells above. - it.todo("'last 7 days' starts 7 days before the ZONE's day, at the zone's midnight — retired by #16041 (dialect closed at the schema), reinstate under #16322 as 'last_7_days'"); + // This case was retired by #16041: it fed `dateRange: 'last 7 days'`, a + // relative dialect the closed vocabulary does not contain, and re-spelling + // it was not available at the time — MEASURED, not assumed: + // `parseDateRangeString` matched `startsWith('last ')`, so `'last_7_days'` + // fell to the `[range, range]` fallback and matched EVERY row. #16322 + // aligned the parser to `DATE_RANGE_PRESETS`, so the case is expressible + // again and the coverage it lost — the `last_N_days` leg anchoring to the + // SUPPLIED zone's calendar day AND to that zone's midnight — is back. + // + // ⚠️ The window it pins is unchanged from the retired cell, because the + // preset means the same thing the dialect did on this leg: Asia/Shanghai + // opening at 2026-08-30T16:00:00.000Z against UTC's + // 2026-08-30T00:00:00.000Z, with the no-timezone control taking the extra + // sixteen hours of rows. + const ZONE = 'Asia/Shanghai'; + const INSTANT = '2026-09-06T20:00:00Z'; // local 2026-09-07 04:00 + const TZ_START = '2026-08-30T16:00:00.000Z'; + const UTC_START = '2026-08-30T00:00:00.000Z'; + + /** + * Probes chosen to make the sixteen-hour difference VISIBLE as rows: two + * sit inside the UTC window and outside the zone's, one sits outside both, + * and three sit inside both. ⛔ None sits on the upper bound — that leg + * ends at NOW and is a separate question (`endExclusive` is false for the + * rolling family, `memory-analytics-date-range-token-end-exclusive.test.ts` + * owns the boundary reading). + */ + const PROBES = [ + '2026-08-29T23:59:59.999Z', // before both windows + UTC_START, // the extra sixteen hours: UTC only … + '2026-08-30T08:00:00.000Z', // … and its midpoint + TZ_START, // the zone's window opens here + '2026-09-01T00:00:00.000Z', // comfortably inside both + '2026-09-06T19:00:00.000Z', // an hour before the frozen clock + ]; + + it("'last_7_days' starts 7 days before the ZONE's day, at the zone's midnight", async () => { + const inZone = await at(ZONE, INSTANT, () => + probesSelected(PROBES, { range: 'last_7_days', timezone: ZONE })); + const noZone = await at(ZONE, INSTANT, () => + probesSelected(PROBES, { range: 'last_7_days' })); + + // The zone's window: opens at Shanghai's midnight on 2026-08-31 — seven + // days before Shanghai's calendar day, which is already 2026-09-07. + expect(inZone).toEqual([ + TZ_START, + '2026-09-01T00:00:00.000Z', + '2026-09-06T19:00:00.000Z', + ].sort()); + + // The CONTROL, and the "before" in the same test: with no timezone the + // window is the UTC one, and it takes the extra sixteen hours of rows. + expect(noZone).toEqual([ + UTC_START, + '2026-08-30T08:00:00.000Z', + TZ_START, + '2026-09-01T00:00:00.000Z', + '2026-09-06T19:00:00.000Z', + ].sort()); + + // Stated as the difference, so a repair that merely made both answers + // equal cannot pass: the zone answer is a STRICT subset, short by + // exactly the sixteen hours between the two midnights. + expect(noZone.filter((p) => !inZone.includes(p))) + .toEqual([UTC_START, '2026-08-30T08:00:00.000Z'].sort()); + expect(ms(TZ_START) - ms(UTC_START)).toBe(16 * 3_600_000); + }); + + it('the answer does not depend on the PROCESS timezone', async () => { + // Half 1 of #16042 read from the QUERY, never from the host — the same + // property the DST file measures for the process-calendar arithmetic. + const expected = await at('UTC', INSTANT, () => + probesSelected(PROBES, { range: 'last_7_days', timezone: ZONE })); + for (const hostZone of ['America/Los_Angeles', 'Asia/Tokyo', 'Pacific/Chatham']) { + await expect( + at(hostZone, INSTANT, () => probesSelected(PROBES, { range: 'last_7_days', timezone: ZONE })), + `host TZ=${hostZone} changed the answer`, + ).resolves.toEqual(expected); + } + }); + + it('⛔ an unrecognised range is REFUSED here, not widened — the fallback is gone', async () => { + // The third retirement this card owed back, in its new form: the + // `[range, range]` fallback that made this whole family match every row + // is a refusal now. The shared conformance fixture + // (`packages/runtime/src/analytics-daterange-driver-conformance.test.ts`) + // holds memory and the SQL analytics path to the SAME envelope; this + // cell is the driver-local half, next to the window it protects. + await at(ZONE, INSTANT, async () => { + const driver = new InMemoryDriver({ initialData: { events: [] } }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const query = { + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + // ⛔ Not through `asQuery`: the schema door refuses this first + // (#16041), and what this pins is the DRIVER's own answer for + // an in-process caller that reached it past that door. + timeDimensions: [{ dimension: 'events.createdAt', dateRange: 'last 7 days' }], + } as unknown as AnalyticsQuery; + const err = await service.query(query).then(() => null, (e: unknown) => e as Error & { + code?: string; status?: number; + }); + expect(err, 'an unresolvable window must be a refusal, never a window').not.toBeNull(); + expect(err!.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(err!.status).toBe(400); + }); + }); }); diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts index 50ab926875..865d87fd9e 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts @@ -257,23 +257,69 @@ describe('#15825 defect 1 fences', () => { }); }); - // ⛔ RETIRED (#16041 → #16322). This fence fed `dateRange: 'not a range at - // all'` through `AnalyticsQuerySchema.parse` to pin that the `[range, range]` - // fallback's answer did not depend on the process zone. #16041 (maintainer - // ruling, decision batch #57) closed the string arm to the - // `date-range-presets.ts` vocabulary, so an unrecognised string is refused - // at the schema door (`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, pinned in - // `packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts` - // and `packages/runtime/src/analytics-daterange-refusal-envelope.test.ts`) - // and never reaches the parser through any door. Retired rather than routed - // around the door: that would have kept a live pin on the silent-widening - // fallback this card exists to abolish. + // ⭐ REINSTATED under #16322, in the form the requirement named. // - // COVERAGE LOST until #16322: nothing a caller can reach — the fallback's - // zone-independence was a property of an answer that matched EVERY row. - // #16322 deletes the fallback and owes the DRIVER-side refusal pin in its - // place (memory and SQL refusing identically, one conformance fixture). - it.todo('the unrecognised-range fallback carries no calendar — same answer in every zone — retired by #16041 (input refused at the schema); #16322 replaces it with the driver-side refusal pin'); + // The retired fence fed `dateRange: 'not a range at all'` through + // `AnalyticsQuerySchema.parse` to pin that the `[range, range]` fallback's + // answer did not depend on the process zone. #16041 closed the string arm + // to the `date-range-presets.ts` vocabulary, so that input is refused at + // the schema door (`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`) and the + // fallback it protected is GONE — this card deleted it rather than + // preserving a window that matched every row. + // + // ⛔ The old assertion was NOT re-spelled: "the fallback carries no + // calendar" is a claim about an answer that no longer exists. What replaces + // it is the same question asked of the REFUSAL — an unrecognised range is + // refused identically in every zone, with the ADR-0112 code and status, + // and it is refused rather than answered. + // + // The cross-driver half (memory and the SQL analytics path refusing with + // one envelope) is the shared conformance fixture this card owed: + // `packages/runtime/src/analytics-daterange-driver-conformance.test.ts`. + it('the unrecognised-range REFUSAL carries no calendar — same envelope in every zone', async () => { + const seen = new Set(); + for (const zone of ['UTC', 'Asia/Shanghai', 'America/Los_Angeles', 'Pacific/Chatham']) { + await at(zone, '2026-09-05T12:00:00Z', async () => { + const driver = new InMemoryDriver({ + initialData: { + events: [ + { id: 1, probe: 'a', created_at: new Date('2020-01-01T00:00:00.000Z') }, + { id: 2, probe: 'b', created_at: new Date('2099-01-01T00:00:00.000Z') }, + ], + }, + }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + // ⛔ Deliberately NOT through `asQuery`: the schema door refuses + // this first, and what this pins is the DRIVER's own answer for + // an in-process caller past that door — `/analytics/dataset/query` + // being the live example, since it types its selection from + // `AnalyticsQuery` and never Zod-parses it. + const err = await service.query({ + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: 'not a range at all' }], + } as unknown as AnalyticsQuery).then( + (r) => ({ kind: 'answered' as const, rows: r.rows.length }), + (e: Error & { code?: string; status?: number }) => ({ + kind: 'refused' as const, code: e.code, status: e.status, message: e.message, + }), + ); + // ⛔ The defect this replaces, named as the thing that must not + // happen: an unresolvable window answered, and answered with + // EVERY row — 2020 and 2099 both. + expect(err.kind, `${zone}: an unresolvable window must not be answered`).toBe('refused'); + seen.add(JSON.stringify(err)); + }); + } + // One envelope, byte for byte, in all four zones — no calendar reaches + // a refusal, which is the durable half of what the retired fence said. + expect([...seen]).toHaveLength(1); + const only = JSON.parse([...seen][0]) as { code: string; status: number }; + expect(only.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(only.status).toBe(400); + }); it('the process timezone is restored after every case', () => { expect(process.env.TZ).toBe(REAL_TZ); diff --git a/packages/runtime/src/analytics-daterange-driver-conformance.test.ts b/packages/runtime/src/analytics-daterange-driver-conformance.test.ts new file mode 100644 index 0000000000..53dd0a498e --- /dev/null +++ b/packages/runtime/src/analytics-daterange-driver-conformance.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16322] THE shared conformance fixture the driver half of #16041 owes: every + * analytics-capable face lowers the closed `timeDimensions[].dateRange` preset + * vocabulary to the SAME window, and refuses everything else with the SAME + * ADR-0112 envelope. + * + * ## Why one fixture, and why it lives here + * + * 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 bad 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 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 assertions below are cross-face by construction: each face is measured + * in its own currency (a mingo pipeline, an ObjectQL filter, bound SQL) and the + * three answers are compared to ONE expectation — `@objectstack/core`'s + * `resolveAnalyticsDateRangeString`, the single lowering all three now call. + * A face that grew its own interpretation goes red here even if its own + * package's tests stay green, which is the whole point of the fixture. + * + * `packages/runtime` hosts it because it is the only package that can see all + * three: `@objectstack/driver-memory` is a dependency, `@objectstack/service-analytics` + * a devDependency. Its sibling `analytics-daterange-refusal-envelope.test.ts` + * pins the same envelope at the HTTP door, so the two files together state the + * whole rule — refused at the door, and refused again by every face behind it. + * + * ## ⚠️ The SQL faces are driven through `queryDataset`, deliberately + * + * That is the `/analytics/dataset/query` path, which types its selection from + * `AnalyticsQuery` and NEVER Zod-parses it (recorded on the card). So it is the + * live in-process caller that reaches a face past the schema door — exactly the + * moment a driver-side refusal exists for. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { resolveAnalyticsDateRangeString } from '@objectstack/core'; +import { InMemoryDriver, MemoryAnalyticsService } from '@objectstack/driver-memory'; +import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; +import { AnalyticsService } from '@objectstack/service-analytics'; + +/** Frozen so all three faces resolve against one instant. */ +const NOW = new Date('2026-09-09T12:34:56.789Z'); +const CTX = { tenantId: 'org_A' } as ExecutionContext; + +/** What a face did with one `dateRange` — its window, in one shape. */ +interface Lowered { + start: string; + end: string; + /** `true` when the face compares the upper bound EXCLUSIVELY. */ + endExclusive: boolean; +} + +/** The refusal a face raised, reduced to the envelope facts ADR-0112 fixes. */ +interface Refusal { + code?: string; + status?: number; + message: string; +} + +const CUBE: Cube = { + name: 'events', + title: 'Events', + sql: 'events', + measures: { count: { name: 'count', label: 'Count', type: 'count', sql: 'id' } }, + dimensions: { + probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' }, + createdAt: { + name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', + granularities: ['day'], + }, + }, + public: true, +}; + +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' }], +}); + +// ── The three faces ─────────────────────────────────────────────────────── + +/** + * driver-memory's cube face. Its window is read out of the pipeline dump the + * service already returns as `result.sql` — the `$match` stage this path + * builds, which is where the bounds and the upper-bound OPERATOR both live. + */ +async function memoryFace(range: string): Promise { + const driver = new InMemoryDriver({ initialData: { events: [] } }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const result = await service.query({ + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }], + } as unknown as AnalyticsQuery); + const m = /"\$gte":"([^"]+)","(\$lte|\$lt)":"([^"]+)"/.exec(String(result.sql)); + if (!m) throw new Error(`no window in the memory pipeline dump: ${String(result.sql)}`); + return { start: m[1], end: m[3], endExclusive: m[2] === '$lt' }; +} + +/** The ObjectQL aggregate strategy — the path every date-bucketed query takes. */ +async function objectqlFace(range: string): Promise { + const calls: Array> = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: 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, + ); + const filter = (calls[0].filter as Record>).created_at; + const exclusive = Object.prototype.hasOwnProperty.call(filter, '$lt'); + return { start: filter.$gte, end: exclusive ? filter.$lt : filter.$lte, endExclusive: exclusive }; +} + +/** The native-SQL strategy — the pushdown path, measured on the bound statement. */ +async function nativeSqlFace(range: string): Promise { + const statements: string[] = []; + const bound: unknown[][] = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_o: string, sql: string, params: unknown[]) => { + statements.push(sql); + bound.push(params); + return []; + }, + }); + await svc.queryDataset( + DATASET, + { + dimensions: ['probe'], measures: ['count'], + timeDimensions: [{ dimension: 'created_at', dateRange: range }], + } as never, + CTX, + ); + const exclusive = / < \$\d+\)/.test(statements[0]); + const params = bound[0].map((p) => (p instanceof Date ? p.toISOString() : String(p))); + return { start: params[0], end: params[1], endExclusive: exclusive }; +} + +const FACES: Array<{ name: string; lower: (range: string) => Promise }> = [ + { name: 'driver-memory (cube face)', lower: memoryFace }, + { name: 'service-analytics (ObjectQL strategy)', lower: objectqlFace }, + { name: 'service-analytics (native SQL strategy)', lower: nativeSqlFace }, +]; + +async function refusalFrom( + face: { name: string; lower: (range: string) => Promise }, + range: string, +): Promise { + try { + await face.lower(range); + return null; + } catch (e) { + const err = e as Error & { code?: string; status?: number }; + return { code: err.code, status: err.status, message: err.message }; + } +} + +/** ⛔ Strings the closed vocabulary does not contain — every one a real spelling. */ +const OUTSIDE_THE_VOCABULARY = [ + 'Last 7 days', // the schema's own former example, and the #16041 case + 'last 7 days', // the relative dialect this repair deleted + 'not a range at all', // the retired driver fence's input + 'last_60_days', // a plausible near-miss that was never declared +]; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('#16322 — every analytics face lowers the preset vocabulary to ONE window', () => { + it.each([...DATE_RANGE_PRESETS])('%s resolves identically on all three faces', async (preset) => { + const expected = resolveAnalyticsDateRangeString(preset, { now: NOW }); + for (const face of FACES) { + const got = await face.lower(preset); + expect(got, `${face.name} disagreed on '${preset}'`).toEqual({ + start: expected.start, + end: expected.end, + endExclusive: expected.endExclusive, + }); + } + }); + + it('⛔ no face answers with the preset NAME as a bound — the fallback shape', async () => { + // The defect stated as a shape rather than a row count, so it is + // checkable on the two faces that never see a row. + for (const preset of DATE_RANGE_PRESETS) { + for (const face of FACES) { + const got = await face.lower(preset); + expect([got.start, got.end], `${face.name} / ${preset}`).not.toContain(preset); + } + } + }); + + it('the thirteen windows are DISTINCT on every face', async () => { + for (const face of FACES) { + const seen = new Set(); + for (const preset of DATE_RANGE_PRESETS) { + const w = await face.lower(preset); + seen.add(`${w.start}..${w.end}`); + } + expect(seen.size, `${face.name} collapsed windows together`).toBe(DATE_RANGE_PRESETS.length); + } + }); + + it('the ten calendar presets compare the upper bound EXCLUSIVELY, the three rolling ones do not', async () => { + // #16179's reading, held across faces: a resolved calendar window stops + // BEFORE its end instant, so two adjacent windows cannot both count a + // row stamped on the boundary. A rolling window ends at NOW, a moment + // it reaches. + const rolling: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'last_90_days']; + for (const preset of DATE_RANGE_PRESETS) { + for (const face of FACES) { + expect((await face.lower(preset)).endExclusive, `${face.name} / ${preset}`) + .toBe(!rolling.includes(preset)); + } + } + }); +}); + +describe('#16322 — every analytics face refuses the SAME strings with the SAME envelope', () => { + it.each(OUTSIDE_THE_VOCABULARY)('refuses %j on all three faces, identically', async (bad) => { + const refusals: Refusal[] = []; + for (const face of FACES) { + const r = await refusalFrom(face, bad); + expect(r, `${face.name} ANSWERED ${JSON.stringify(bad)} instead of refusing`).not.toBeNull(); + expect(r!.code, face.name).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(r!.status, face.name).toBe(400); + refusals.push(r!); + } + // Identical, not merely equivalent: one condition, one wording (#5240), + // so an author correcting the value reads the same prescription + // whichever backend the deployment runs. + expect(new Set(refusals.map((r) => JSON.stringify(r))).size).toBe(1); + }); + + it('the vocabulary is CASE-SENSITIVE and snake_case on every face', async () => { + for (const face of FACES) { + expect(await refusalFrom(face, 'TODAY'), face.name).not.toBeNull(); + expect(await refusalFrom(face, 'Last_7_Days'), face.name).not.toBeNull(); + expect(await refusalFrom(face, 'today'), face.name).toBeNull(); + } + }); + + it('⛔ CONTROL — every DECLARED name is accepted by all three faces', async () => { + // Without this a face that refused EVERY string would satisfy every + // assertion above, which is the opposite defect and just as silent. + for (const face of FACES) { + for (const preset of DATE_RANGE_PRESETS) { + expect(await refusalFrom(face, preset), `${face.name} refused the declared '${preset}'`) + .toBeNull(); + } + } + }); + + it("⛔ CONTROL — an explicit [start, end] window is NOT refused, and keeps its inclusive upper bound", async () => { + // The #16179 separation, stated across faces: only a window a face + // RESOLVED is compared with `<`. A caller's own bound is theirs, and + // `$lte` is the reading these faces have published since they existed. + // + // ⚠️ Written as full timestamps on purpose. A BARE `YYYY-MM-DD` 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 card does not touch and which would make the three + // spellings differ here for a reason that has nothing to do with the + // preset vocabulary. + const explicit = ['2026-09-01T00:00:00.000Z', '2026-09-30T00:00:00.000Z']; + for (const face of FACES) { + const got = await face.lower(explicit as unknown as string); + expect(got.endExclusive, `${face.name} narrowed a caller's explicit window`).toBe(false); + expect(got.start, face.name).toBe(explicit[0]); + expect(got.end, face.name).toBe(explicit[1]); + } + }); +}); From 67e46cc06d0080dad57b530615aa4aa319b5c39f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:59:25 +0000 Subject: [PATCH 3/5] 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 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../analytics-daterange-driver-alignment.md | 81 +++++ packages/core/src/index.ts | 6 + .../utils/analytics-date-range-conformance.ts | 234 +++++++++++++ ...y-analytics-date-range-conformance.test.ts | 103 ++++++ ...ytics-daterange-driver-conformance.test.ts | 308 ------------------ .../analytics-date-range-conformance.test.ts | 130 ++++++++ .../spec/src/api/error-code-ledger.zod.ts | 20 +- 7 files changed, 572 insertions(+), 310 deletions(-) create mode 100644 .changeset/analytics-daterange-driver-alignment.md create mode 100644 packages/core/src/utils/analytics-date-range-conformance.ts create mode 100644 packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts delete mode 100644 packages/runtime/src/analytics-daterange-driver-conformance.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts diff --git a/.changeset/analytics-daterange-driver-alignment.md b/.changeset/analytics-daterange-driver-alignment.md new file mode 100644 index 0000000000..18b40bb847 --- /dev/null +++ b/.changeset/analytics-daterange-driver-alignment.md @@ -0,0 +1,81 @@ +--- +"@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) + + + +**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` and both SQL strategies 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 three faces to the same + windows and the same refusal. + +## 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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41522d693f..9860d2ffe0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,12 @@ export * from './utils/filter-tokens.js'; // 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 diff --git a/packages/core/src/utils/analytics-date-range-conformance.ts b/packages/core/src/utils/analytics-date-range-conformance.ts new file mode 100644 index 0000000000..e0b8502a05 --- /dev/null +++ b/packages/core/src/utils/analytics-date-range-conformance.ts @@ -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; +} + +/** + * ⛔ 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 { + const findings: string[] = []; + const say = (msg: string) => findings.push(`${face.name}: ${msg}`); + const seenWindows = new Set(); + + // ── 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(); + 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; +} diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts new file mode 100644 index 0000000000..a488b85f30 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16322] `driver-memory`'s arm of the shared `dateRange` conformance fixture. + * + * The cases and the rules are `@objectstack/core`'s + * `analyticsDateRangeConformanceFindings` — the SQL analytics path runs the + * identical set in `@objectstack/service-analytics` + * (`analytics-date-range-conformance.test.ts`), which is what makes "memory and + * SQL refuse identically and lower to the same window" a measurement rather + * than a claim. ⛔ Assertions do not belong in this file: a rule written here + * is a rule the other backend is not held to, and that asymmetry is the whole + * defect #16041 split this card off to close. + * + * ## Why the window is read out of `result.sql` + * + * The cube face lowers into a mingo pipeline and the service already returns + * that pipeline as `result.sql` for debugging. Both bounds AND the upper-bound + * OPERATOR are in it, which is exactly the three facts the kit compares — and + * reading them there needs no probe rows, so the comparison is about the WINDOW + * rather than about which rows happen to sit near it. (The row-level readings + * are the sibling files' job: `…-dst.test.ts`, `…-timezone.test.ts`, + * `…-utc-window.test.ts`.) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + analyticsDateRangeConformanceFindings, + type LoweredDateRangeWindow, +} from '@objectstack/core'; +import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; + +/** Frozen so the three rolling presets, whose bound is NOW, are comparable. */ +const NOW = new Date('2026-09-09T12:34:56.789Z'); + +const CUBE: Cube = { + name: 'events', + title: 'Events', + sql: 'events', + measures: { count: { name: 'count', label: 'Count', type: 'count', sql: 'id' } }, + dimensions: { + probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' }, + createdAt: { + name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', + granularities: ['day'], + }, + }, + public: true, +}; + +/** + * ⛔ Deliberately NOT through `AnalyticsQuerySchema.parse`: the schema door + * refuses the out-of-vocabulary cases first (#16041), and what this measures is + * the DRIVER's own answer for an in-process caller past that door — + * `POST /analytics/dataset/query` being the live example, since it types its + * selection from `AnalyticsQuery` and never Zod-parses it. + */ +async function lower(range: string | readonly string[]): Promise { + const driver = new InMemoryDriver({ initialData: { events: [] } }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const result = await service.query({ + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }], + } as unknown as AnalyticsQuery); + const m = /"\$gte":"([^"]+)","(\$lte|\$lt)":"([^"]+)"/.exec(String(result.sql)); + if (!m) throw new Error(`no window in the memory pipeline dump: ${String(result.sql)}`); + return { start: m[1], end: m[3], endExclusive: m[2] === '$lt' }; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('#16322 — driver-memory conforms to the shared dateRange vocabulary', () => { + it('has no conformance findings', async () => { + const findings = await analyticsDateRangeConformanceFindings( + { name: 'driver-memory (cube face)', lower }, + { now: NOW }, + ); + expect(findings).toEqual([]); + }); + + it('⛔ the harness itself is live — a face that lowers nothing is caught', async () => { + // Without this the green above would be ambiguous between "the driver + // conforms" and "the kit found nothing to look at". A deliberately + // broken face must produce findings; if it does not, the run above said + // nothing about this driver either. + const findings = await analyticsDateRangeConformanceFindings({ + name: 'a face that answers every input with one window', + lower: async () => ({ start: 'x', end: 'y', endExclusive: false }), + }, { now: NOW }); + expect(findings.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/runtime/src/analytics-daterange-driver-conformance.test.ts b/packages/runtime/src/analytics-daterange-driver-conformance.test.ts deleted file mode 100644 index 53dd0a498e..0000000000 --- a/packages/runtime/src/analytics-daterange-driver-conformance.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#16322] THE shared conformance fixture the driver half of #16041 owes: every - * analytics-capable face lowers the closed `timeDimensions[].dateRange` preset - * vocabulary to the SAME window, and refuses everything else with the SAME - * ADR-0112 envelope. - * - * ## Why one fixture, and why it lives here - * - * 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 bad 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 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 assertions below are cross-face by construction: each face is measured - * in its own currency (a mingo pipeline, an ObjectQL filter, bound SQL) and the - * three answers are compared to ONE expectation — `@objectstack/core`'s - * `resolveAnalyticsDateRangeString`, the single lowering all three now call. - * A face that grew its own interpretation goes red here even if its own - * package's tests stay green, which is the whole point of the fixture. - * - * `packages/runtime` hosts it because it is the only package that can see all - * three: `@objectstack/driver-memory` is a dependency, `@objectstack/service-analytics` - * a devDependency. Its sibling `analytics-daterange-refusal-envelope.test.ts` - * pins the same envelope at the HTTP door, so the two files together state the - * whole rule — refused at the door, and refused again by every face behind it. - * - * ## ⚠️ The SQL faces are driven through `queryDataset`, deliberately - * - * That is the `/analytics/dataset/query` path, which types its selection from - * `AnalyticsQuery` and NEVER Zod-parses it (recorded on the card). So it is the - * live in-process caller that reaches a face past the schema door — exactly the - * moment a driver-side refusal exists for. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/data'; -import { DatasetSchema } from '@objectstack/spec/ui'; -import type { ExecutionContext } from '@objectstack/spec/kernel'; -import { resolveAnalyticsDateRangeString } from '@objectstack/core'; -import { InMemoryDriver, MemoryAnalyticsService } from '@objectstack/driver-memory'; -import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; -import { AnalyticsService } from '@objectstack/service-analytics'; - -/** Frozen so all three faces resolve against one instant. */ -const NOW = new Date('2026-09-09T12:34:56.789Z'); -const CTX = { tenantId: 'org_A' } as ExecutionContext; - -/** What a face did with one `dateRange` — its window, in one shape. */ -interface Lowered { - start: string; - end: string; - /** `true` when the face compares the upper bound EXCLUSIVELY. */ - endExclusive: boolean; -} - -/** The refusal a face raised, reduced to the envelope facts ADR-0112 fixes. */ -interface Refusal { - code?: string; - status?: number; - message: string; -} - -const CUBE: Cube = { - name: 'events', - title: 'Events', - sql: 'events', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: 'id' } }, - dimensions: { - probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' }, - createdAt: { - name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', - granularities: ['day'], - }, - }, - public: true, -}; - -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' }], -}); - -// ── The three faces ─────────────────────────────────────────────────────── - -/** - * driver-memory's cube face. Its window is read out of the pipeline dump the - * service already returns as `result.sql` — the `$match` stage this path - * builds, which is where the bounds and the upper-bound OPERATOR both live. - */ -async function memoryFace(range: string): Promise { - const driver = new InMemoryDriver({ initialData: { events: [] } }); - await driver.connect(); - const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); - const result = await service.query({ - cube: 'events', - measures: ['events.count'], - dimensions: ['events.probe'], - timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }], - } as unknown as AnalyticsQuery); - const m = /"\$gte":"([^"]+)","(\$lte|\$lt)":"([^"]+)"/.exec(String(result.sql)); - if (!m) throw new Error(`no window in the memory pipeline dump: ${String(result.sql)}`); - return { start: m[1], end: m[3], endExclusive: m[2] === '$lt' }; -} - -/** The ObjectQL aggregate strategy — the path every date-bucketed query takes. */ -async function objectqlFace(range: string): Promise { - const calls: Array> = []; - const svc = new AnalyticsService({ - queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), - executeAggregate: async (_object: 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, - ); - const filter = (calls[0].filter as Record>).created_at; - const exclusive = Object.prototype.hasOwnProperty.call(filter, '$lt'); - return { start: filter.$gte, end: exclusive ? filter.$lt : filter.$lte, endExclusive: exclusive }; -} - -/** The native-SQL strategy — the pushdown path, measured on the bound statement. */ -async function nativeSqlFace(range: string): Promise { - const statements: string[] = []; - const bound: unknown[][] = []; - const svc = new AnalyticsService({ - queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), - executeRawSql: async (_o: string, sql: string, params: unknown[]) => { - statements.push(sql); - bound.push(params); - return []; - }, - }); - await svc.queryDataset( - DATASET, - { - dimensions: ['probe'], measures: ['count'], - timeDimensions: [{ dimension: 'created_at', dateRange: range }], - } as never, - CTX, - ); - const exclusive = / < \$\d+\)/.test(statements[0]); - const params = bound[0].map((p) => (p instanceof Date ? p.toISOString() : String(p))); - return { start: params[0], end: params[1], endExclusive: exclusive }; -} - -const FACES: Array<{ name: string; lower: (range: string) => Promise }> = [ - { name: 'driver-memory (cube face)', lower: memoryFace }, - { name: 'service-analytics (ObjectQL strategy)', lower: objectqlFace }, - { name: 'service-analytics (native SQL strategy)', lower: nativeSqlFace }, -]; - -async function refusalFrom( - face: { name: string; lower: (range: string) => Promise }, - range: string, -): Promise { - try { - await face.lower(range); - return null; - } catch (e) { - const err = e as Error & { code?: string; status?: number }; - return { code: err.code, status: err.status, message: err.message }; - } -} - -/** ⛔ Strings the closed vocabulary does not contain — every one a real spelling. */ -const OUTSIDE_THE_VOCABULARY = [ - 'Last 7 days', // the schema's own former example, and the #16041 case - 'last 7 days', // the relative dialect this repair deleted - 'not a range at all', // the retired driver fence's input - 'last_60_days', // a plausible near-miss that was never declared -]; - -beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(NOW); -}); -afterEach(() => { - vi.useRealTimers(); -}); - -describe('#16322 — every analytics face lowers the preset vocabulary to ONE window', () => { - it.each([...DATE_RANGE_PRESETS])('%s resolves identically on all three faces', async (preset) => { - const expected = resolveAnalyticsDateRangeString(preset, { now: NOW }); - for (const face of FACES) { - const got = await face.lower(preset); - expect(got, `${face.name} disagreed on '${preset}'`).toEqual({ - start: expected.start, - end: expected.end, - endExclusive: expected.endExclusive, - }); - } - }); - - it('⛔ no face answers with the preset NAME as a bound — the fallback shape', async () => { - // The defect stated as a shape rather than a row count, so it is - // checkable on the two faces that never see a row. - for (const preset of DATE_RANGE_PRESETS) { - for (const face of FACES) { - const got = await face.lower(preset); - expect([got.start, got.end], `${face.name} / ${preset}`).not.toContain(preset); - } - } - }); - - it('the thirteen windows are DISTINCT on every face', async () => { - for (const face of FACES) { - const seen = new Set(); - for (const preset of DATE_RANGE_PRESETS) { - const w = await face.lower(preset); - seen.add(`${w.start}..${w.end}`); - } - expect(seen.size, `${face.name} collapsed windows together`).toBe(DATE_RANGE_PRESETS.length); - } - }); - - it('the ten calendar presets compare the upper bound EXCLUSIVELY, the three rolling ones do not', async () => { - // #16179's reading, held across faces: a resolved calendar window stops - // BEFORE its end instant, so two adjacent windows cannot both count a - // row stamped on the boundary. A rolling window ends at NOW, a moment - // it reaches. - const rolling: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'last_90_days']; - for (const preset of DATE_RANGE_PRESETS) { - for (const face of FACES) { - expect((await face.lower(preset)).endExclusive, `${face.name} / ${preset}`) - .toBe(!rolling.includes(preset)); - } - } - }); -}); - -describe('#16322 — every analytics face refuses the SAME strings with the SAME envelope', () => { - it.each(OUTSIDE_THE_VOCABULARY)('refuses %j on all three faces, identically', async (bad) => { - const refusals: Refusal[] = []; - for (const face of FACES) { - const r = await refusalFrom(face, bad); - expect(r, `${face.name} ANSWERED ${JSON.stringify(bad)} instead of refusing`).not.toBeNull(); - expect(r!.code, face.name).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); - expect(r!.status, face.name).toBe(400); - refusals.push(r!); - } - // Identical, not merely equivalent: one condition, one wording (#5240), - // so an author correcting the value reads the same prescription - // whichever backend the deployment runs. - expect(new Set(refusals.map((r) => JSON.stringify(r))).size).toBe(1); - }); - - it('the vocabulary is CASE-SENSITIVE and snake_case on every face', async () => { - for (const face of FACES) { - expect(await refusalFrom(face, 'TODAY'), face.name).not.toBeNull(); - expect(await refusalFrom(face, 'Last_7_Days'), face.name).not.toBeNull(); - expect(await refusalFrom(face, 'today'), face.name).toBeNull(); - } - }); - - it('⛔ CONTROL — every DECLARED name is accepted by all three faces', async () => { - // Without this a face that refused EVERY string would satisfy every - // assertion above, which is the opposite defect and just as silent. - for (const face of FACES) { - for (const preset of DATE_RANGE_PRESETS) { - expect(await refusalFrom(face, preset), `${face.name} refused the declared '${preset}'`) - .toBeNull(); - } - } - }); - - it("⛔ CONTROL — an explicit [start, end] window is NOT refused, and keeps its inclusive upper bound", async () => { - // The #16179 separation, stated across faces: only a window a face - // RESOLVED is compared with `<`. A caller's own bound is theirs, and - // `$lte` is the reading these faces have published since they existed. - // - // ⚠️ Written as full timestamps on purpose. A BARE `YYYY-MM-DD` 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 card does not touch and which would make the three - // spellings differ here for a reason that has nothing to do with the - // preset vocabulary. - const explicit = ['2026-09-01T00:00:00.000Z', '2026-09-30T00:00:00.000Z']; - for (const face of FACES) { - const got = await face.lower(explicit as unknown as string); - expect(got.endExclusive, `${face.name} narrowed a caller's explicit window`).toBe(false); - expect(got.start, face.name).toBe(explicit[0]); - expect(got.end, face.name).toBe(explicit[1]); - } - }); -}); diff --git a/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts new file mode 100644 index 0000000000..d3c59935cb --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16322] The SQL analytics path's arm of the shared `dateRange` conformance + * fixture — BOTH strategies, held to the same rules `driver-memory` is + * (`memory-analytics-date-range-conformance.test.ts`). + * + * The cases and the rules are `@objectstack/core`'s + * `analyticsDateRangeConformanceFindings`; ⛔ no rule is written here, because a + * rule only one backend is held to is the asymmetry this card exists to close. + * + * ## ⚠️ Where the SQL analytics `dateRange` path actually is + * + * Measured, because the card's scope says "the SQL drivers' analytics path" and + * that reads like `driver-sql`: `packages/drivers/driver-sql/src/sql-driver.ts` + * contains NO `dateRange` handling at all — two comment mentions and no code. + * The lowering lives HERE, in the two strategies, which is why the SQL half of + * the conformance fixture is in this package. + * + * ## Driven through `queryDataset`, deliberately + * + * That is the `POST /analytics/dataset/query` path, which types its selection + * from `AnalyticsQuery` and NEVER Zod-parses it — so it is the live in-process + * caller that reaches a face past the schema door, which is the moment a + * face-side refusal exists for. Before this card, MEASURED on `b834b48e7a`, + * that door lowered `last_30_days` — a VALID preset — to + * `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, and lowered + * `'not a range at all'` to exactly the same shape. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + analyticsDateRangeConformanceFindings, + type AnalyticsDateRangeFace, + type LoweredDateRangeWindow, +} from '@objectstack/core'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; + +/** Frozen so the three rolling presets, whose bound is NOW, are comparable. */ +const NOW = new Date('2026-09-09T12:34:56.789Z'); +const CTX = { tenantId: 'org_A' } as ExecutionContext; + +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' }], +}); + +/** The ObjectQL aggregate strategy — every date-bucketed query lands here. */ +async function lowerViaObjectql(range: string | readonly string[]): Promise { + const calls: Array> = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: 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, + ); + const filter = (calls[0].filter as Record>).created_at; + const exclusive = Object.prototype.hasOwnProperty.call(filter, '$lt'); + return { start: filter.$gte, end: exclusive ? filter.$lt : filter.$lte, endExclusive: exclusive }; +} + +/** The native-SQL strategy — the pushdown path, read off the bound statement. */ +async function lowerViaNativeSql(range: string | readonly string[]): Promise { + const statements: string[] = []; + const bound: unknown[][] = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_o: string, sql: string, params: unknown[]) => { + statements.push(sql); + bound.push(params); + return []; + }, + }); + await svc.queryDataset( + DATASET, + { + dimensions: ['probe'], measures: ['count'], + timeDimensions: [{ dimension: 'created_at', dateRange: range }], + } as never, + CTX, + ); + const exclusive = / < \$\d+\)/.test(statements[0]); + const params = bound[0].map((p) => (p instanceof Date ? p.toISOString() : String(p))); + return { start: params[0], end: params[1], endExclusive: exclusive }; +} + +const FACES: AnalyticsDateRangeFace[] = [ + { name: 'service-analytics (ObjectQL strategy)', lower: lowerViaObjectql }, + { name: 'service-analytics (native SQL strategy)', lower: lowerViaNativeSql }, +]; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('#16322 — the SQL analytics path conforms to the shared dateRange vocabulary', () => { + it.each(FACES.map((f) => [f.name, f] as const))('%s has no conformance findings', async (_n, face) => { + expect(await analyticsDateRangeConformanceFindings(face, { now: NOW })).toEqual([]); + }); + + it('⛔ the harness itself is live — a face that lowers nothing is caught', async () => { + // The same control the memory arm carries: without it a green above is + // ambiguous between "these strategies conform" and "the kit found + // nothing to look at". + const findings = await analyticsDateRangeConformanceFindings({ + name: 'a face that answers every input with one window', + lower: async () => ({ start: 'x', end: 'y', endExclusive: false }), + }, { now: NOW }); + expect(findings.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index a6912719a2..091f00ef4f 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -326,8 +326,10 @@ export const ERROR_CODE_LEDGER = { // one code, one wording (`analyticsDateRangeRefusalMessage`, the #5240 // convention); `VALIDATION_FAILED` would say "malformed body" at one // moment and nothing a driver could speak at the other. Registered under - // the door that names the wire vocabulary; each driver adds its own - // provenance row when its refusal lands. + // the door that names the wire vocabulary. #16322 landed that second + // moment as ONE shared constructor in `@objectstack/core` rather than a + // refusal per driver — so it carries a single PROVENANCE_WAIVERS entry for + // that package below, not a row under each backend. 'ANALYTICS_DATE_RANGE_UNRECOGNIZED', // [#16293] The AI-facing action doors refuse a call against an action // whose AUTHOR declared `ai.requiresConfirmation: true` when the request @@ -1585,4 +1587,18 @@ export const PROVENANCE_WAIVERS: readonly ProvenanceWaiver[] = [ 'served under the emitting doors\' own registrations (runtime\'s dispatcher exits, ' + 'rest\'s `mapDataError` — both packages list the code).', }, + { + package: '@objectstack/core', + code: 'ANALYTICS_DATE_RANGE_UNRECOGNIZED', + registeredUnder: '@objectstack/runtime', + reason: 'Shared constructor one package over, the #8016 shape (#16322): ' + + '`analyticsDateRangeUnrecognizedError` (utils/analytics-date-range.ts) spells the ' + + 'string ONCE so driver-memory\'s cube face and BOTH service-analytics strategies ' + + 'refuse identically — which is the property the card\'s shared conformance fixture ' + + 'exists to hold, and which two independent refusals could not give. Core ships no ' + + 'HTTP door; the wire emission stays runtime\'s, whose row names this exact second ' + + 'moment. ⛔ Deliberately ONE waiver rather than a row per driver: with one ' + + 'constructor there is one stamp site, and rows for packages that stamp nothing ' + + 'would be the dead weight this file\'s gate refuses.', + }, ]; From 070eb30e9ee121697d407ba37721223ee740258d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 10:18:14 +0000 Subject: [PATCH 4/5] fix(service-analytics): lower and refuse `dateRange` on the draft-preview face too, and register it in the conformance kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- ...mory-analytics-date-range-timezone.test.ts | 2 +- ...ry-analytics-date-range-utc-window.test.ts | 2 +- .../analytics-date-range-conformance.test.ts | 131 +++++++++++++++++- .../src/preview-evaluator.ts | 95 +++++++++++-- 4 files changed, 217 insertions(+), 13 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts index d291a7b6ef..64dccf0dfc 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts @@ -392,7 +392,7 @@ describe("#16042 — the rolling `last_N_days` window anchors on the zone's cale // The third retirement this card owed back, in its new form: the // `[range, range]` fallback that made this whole family match every row // is a refusal now. The shared conformance fixture - // (`packages/runtime/src/analytics-daterange-driver-conformance.test.ts`) + // (`packages/core/src/utils/analytics-date-range-conformance.ts`) // holds memory and the SQL analytics path to the SAME envelope; this // cell is the driver-local half, next to the window it protects. await at(ZONE, INSTANT, async () => { diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts index 865d87fd9e..db550461ab 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts @@ -275,7 +275,7 @@ describe('#15825 defect 1 fences', () => { // // The cross-driver half (memory and the SQL analytics path refusing with // one envelope) is the shared conformance fixture this card owed: - // `packages/runtime/src/analytics-daterange-driver-conformance.test.ts`. + // `packages/core/src/utils/analytics-date-range-conformance.ts`. it('the unrecognised-range REFUSAL carries no calendar — same envelope in every zone', async () => { const seen = new Set(); for (const zone of ['UTC', 'Asia/Shanghai', 'America/Los_Angeles', 'Pacific/Chatham']) { diff --git a/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts index d3c59935cb..82879b91d7 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#16322] The SQL analytics path's arm of the shared `dateRange` conformance - * fixture — BOTH strategies, held to the same rules `driver-memory` is + * [#16322] This package's arm of the shared `dateRange` conformance fixture — + * ALL THREE of its analytics faces, held to the same rules `driver-memory` is * (`memory-analytics-date-range-conformance.test.ts`). * * The cases and the rules are `@objectstack/core`'s @@ -17,6 +17,16 @@ * The lowering lives HERE, in the two strategies, which is why the SQL half of * the conformance fixture is in this package. * + * ## ⭐ Why a THIRD face, and why the count is what makes the claim true + * + * The two strategies are not all of this package's `dateRange` lowering: the + * draft-preview evaluator (`preview-evaluator.ts`, ADR-0037 P3 — the Live + * Canvas face) is a fourth analytics face platform-wide, and it carried the + * same `[range, range]` fallback the other three shed. Registering only the + * two SQL faces would have left the kit's `FACES` list one short of the claim + * the change makes, and a face outside the kit is a face free to grow a second + * interpretation — which is the exact defect #16041 was split to end. + * * ## Driven through `queryDataset`, deliberately * * That is the `POST /analytics/dataset/query` path, which types its selection @@ -31,12 +41,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { analyticsDateRangeConformanceFindings, + resolveAnalyticsDateRangeString, type AnalyticsDateRangeFace, type LoweredDateRangeWindow, } from '@objectstack/core'; import { DatasetSchema } from '@objectstack/spec/ui'; +import type { Cube } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { AnalyticsService } from '../analytics-service.js'; +import { evaluateAnalyticsQueryOverRows, lowerPreviewDateRange } from '../preview-evaluator.js'; /** Frozen so the three rolling presets, whose bound is NOW, are comparable. */ const NOW = new Date('2026-09-09T12:34:56.789Z'); @@ -99,9 +112,28 @@ async function lowerViaNativeSql(range: string | readonly string[]): Promise { + return lowerPreviewDateRange(range); +} + const FACES: AnalyticsDateRangeFace[] = [ { name: 'service-analytics (ObjectQL strategy)', lower: lowerViaObjectql }, { name: 'service-analytics (native SQL strategy)', lower: lowerViaNativeSql }, + { name: 'service-analytics (draft-preview evaluator)', lower: lowerViaPreview }, ]; beforeEach(() => { @@ -112,7 +144,7 @@ afterEach(() => { vi.useRealTimers(); }); -describe('#16322 — the SQL analytics path conforms to the shared dateRange vocabulary', () => { +describe('#16322 — this package\'s analytics faces conform to the shared dateRange vocabulary', () => { it.each(FACES.map((f) => [f.name, f] as const))('%s has no conformance findings', async (_n, face) => { expect(await analyticsDateRangeConformanceFindings(face, { now: NOW })).toEqual([]); }); @@ -128,3 +160,96 @@ describe('#16322 — the SQL analytics path conforms to the shared dateRange voc expect(findings.length).toBeGreaterThan(0); }); }); + +/** + * ⭐ The preview face is registered above on its LOWERING; these three cases are + * why that registration means anything. + * + * The two strategy faces are driven through the whole service, so "the face + * conforms" and "the live path uses it" are one measurement for them. The + * preview face emits no filter to read, so its registration goes through + * `lowerPreviewDateRange` — and an exported helper that the evaluator does not + * actually call would satisfy the kit while the live path kept its old + * fallback. These cases close that gap end to end, through the exported + * `evaluateAnalyticsQueryOverRows` and against the SHARED resolver's window + * (⛔ not against the helper's own answer, which would only restate itself). + */ +const PREVIEW_CUBE = { + name: 'events', + sql: 'events', + dimensions: { id: { name: 'id', type: 'string', sql: 'id' } }, + measures: { count: { name: 'count', type: 'count', sql: '*' } }, +} as unknown as Cube; + +/** Row ids the evaluator keeps for `dateRange`, grouped by `id` so rows ARE ids. */ +function previewSelects(dateRange: string | readonly string[], rows: Array>): string[] { + const result = evaluateAnalyticsQueryOverRows( + { + measures: ['count'], + dimensions: ['id'], + timeDimensions: [{ dimension: 'created_at', dateRange }], + } as never, + PREVIEW_CUBE, + rows.map((r) => ({ ...r })), + ); + return result.rows.map((r) => String(r.id)).sort(); +} + +const shift = (iso: string, ms: number): string => new Date(Date.parse(iso) + ms).toISOString(); + +describe('#16322 — the draft-preview evaluator APPLIES the window it reports', () => { + // The frozen clock is the file-level `beforeEach` above — the rolling + // presets' upper bound is NOW, so it has to be the same NOW here. + it('a ROLLING preset selects exactly the shared resolver\'s window, upper bound REACHED', () => { + // `last_30_days` ends at NOW and includes it (#16179 leaves the rolling + // leg inclusive). Before the wiring this selected ZERO rows: the bounds + // were the literal name, and no ISO instant sorts inside + // ['last_30_days', 'last_30_days~']. + const w = resolveAnalyticsDateRangeString('last_30_days', { now: NOW }); + expect(w.endExclusive).toBe(false); + expect(previewSelects('last_30_days', [ + { id: 'before_start', created_at: shift(w.start, -1) }, + { id: 'at_start', created_at: w.start }, + { id: 'at_end', created_at: w.end }, + { id: 'after_end', created_at: shift(w.end, 1) }, + ])).toEqual(['at_end', 'at_start']); + }); + + it('a CALENDAR preset stops BEFORE its end instant', () => { + // The other upper reading: two adjacent calendar windows must not both + // count a row stamped on the boundary. + const w = resolveAnalyticsDateRangeString('this_month', { now: NOW }); + expect(w.endExclusive).toBe(true); + expect(previewSelects('this_month', [ + { id: 'before_start', created_at: shift(w.start, -1) }, + { id: 'at_start', created_at: w.start }, + { id: 'just_inside_end', created_at: shift(w.end, -1) }, + { id: 'at_end', created_at: w.end }, + ])).toEqual(['at_start', 'just_inside_end']); + }); + + it('⛔ an unrecognised string is REFUSED by the evaluator with the ADR-0112 envelope', () => { + // ⛔ Asserted on the ENVELOPE, not on `toThrow()`: a face that threw a + // bare `Error` — which is what an unfixed one does — would satisfy a + // bare `toThrow`. + let thrown: (Error & { code?: string; status?: number }) | undefined; + try { + previewSelects('not a range at all', [{ id: 'a', created_at: '2026-09-09T00:00:00.000Z' }]); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + expect(thrown?.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(thrown?.status).toBe(400); + }); + + it('⛔ the CALLER\'s explicit window keeps its own bounds and its inclusive upper reading', () => { + // The #16179 separation on this face too: only a window this face + // RESOLVED states an exclusive upper bound. + expect(previewSelects(['2026-09-01T00:00:00.000Z', '2026-09-30T00:00:00.000Z'], [ + { id: 'before', created_at: '2026-08-31T23:59:59.999Z' }, + { id: 'lower', created_at: '2026-09-01T00:00:00.000Z' }, + { id: 'upper', created_at: '2026-09-30T00:00:00.000Z' }, + { id: 'after', created_at: '2026-09-30T00:00:00.001Z' }, + ])).toEqual(['lower', 'upper']); + }); +}); diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 2edec66595..5ea20bbac4 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -17,7 +17,12 @@ // Anything beyond (joins via `include`, raw SQL) falls back to the caller's // normal execution path — the preview simply doesn't claim it. -import { calendarPartsInTzOrUtc, nextUtcCalendarDay, utcInstantMs } from '@objectstack/core'; +import { + calendarPartsInTzOrUtc, + nextUtcCalendarDay, + resolveAnalyticsDateRangeString, + utcInstantMs, +} from '@objectstack/core'; import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; import type { Cube } from '@objectstack/spec/data'; @@ -273,6 +278,68 @@ function aggregate(rows: Row[], metricType: string, field: string): unknown { } } +/** + * The window one `timeDimensions[].dateRange` lowers to on this face, reduced + * to the three facts the closed vocabulary decides: the two bounds, and whether + * the upper one is excluded. + * + * Structurally `@objectstack/core`'s `LoweredDateRangeWindow` — the shared + * conformance kit's currency — so this face registers in the kit with no + * translation of its own, and a translation layer cannot become the place the + * fourth face quietly grows a fourth interpretation. + */ +export interface PreviewDateRangeWindow { + readonly start: string; + readonly end: string; + readonly endExclusive: boolean; +} + +/** + * [#16322] Lower one `dateRange` for the draft-preview face — the FOURTH + * analytics face, now held to the same closed vocabulary as `driver-memory`'s + * cube face and both `service-analytics` strategies. + * + * ⛔ It used to degenerate to the point window `[range, range]`, exactly the + * shape this card abolished on the other three: a VALID `last_30_days` filtered + * `v >= 'last_30_days' && v <= 'last_30_days~'` — **zero rows, silently** — + * while the published chart beside it answered a real window. That is precisely + * the continuity a draft preview exists to provide: publish materialises the + * SAME seed, so a preview that disagrees with the live face makes the numbers + * jump across the publish boundary for no reason an author can see. + * + * The STRING arm is the closed preset vocabulary (#16041), lowered by the ONE + * shared `resolveAnalyticsDateRangeString` the other three faces call — so this + * is not a second interpretation but the only one — and REFUSED with the + * ADR-0112 `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope when it is not a + * preset name. ⛔ The refusal PROPAGATES: this evaluator is reached through + * `queryDataset`, which types its selection from `AnalyticsQuery` and never + * Zod-parses it, so the schema door is behind it — which is the whole moment a + * face-side refusal exists for. + * + * The ARRAY arm is the CALLER's explicit window and is untouched, bound for + * bound, with the inclusive upper reading it has always had (#16179). Its + * bare-day widening (#3777) stays in the predicate below rather than moving + * here: that is a per-face calendar translation, not a window this vocabulary + * resolved. + * + * @throws the ADR-0112 envelope for a string outside `DATE_RANGE_PRESETS`. + */ +export function lowerPreviewDateRange( + dateRange: string | readonly string[], + timezone?: string, +): PreviewDateRangeWindow { + if (!Array.isArray(dateRange)) { + 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 }; +} + /** * Evaluate `query` over `rows` using the cube's measure/dimension specs. * Mirrors the engine strategies' output contract: rows keyed by bare @@ -290,15 +357,27 @@ export function evaluateAnalyticsQueryOverRows( const dim = cube.dimensions?.[td.dimension]; const field = String(dim?.sql ?? td.dimension); if (!td.dateRange) continue; - const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange]; + // [#16322] One lowering for both arms — the closed preset vocabulary, or + // the caller's explicit window — and a refusal for anything else. + const explicit = Array.isArray(td.dateRange); + const { start, end, endExclusive } = lowerPreviewDateRange(td.dateRange, query.timezone); + // Bare-day end → half-open `< day+1`, the same translation the SQL + // paths apply (#3777); a full-timestamp end keeps the historical + // `'~'`-suffix trick (inclusive of that instant's own sub-values). + // ⛔ Neither reaches a RESOLVED preset window: it states its own upper + // reading and is never a bare day — the ten calendar presets stop BEFORE + // their end instant, the three rolling ones end at NOW and reach it. + const nextDay = explicit ? nextUtcCalendarDay(end) : null; filtered = filtered.filter((r) => { const v = String(r[field] ?? ''); - // Bare-day end → half-open `< day+1`, the same translation the SQL - // paths apply (#3777); a full-timestamp end keeps the historical - // `'~'`-suffix trick (inclusive of that instant's own sub-values). - const nextDay = nextUtcCalendarDay(end); - const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`; - return v >= String(start) && inUpper; + const inUpper = endExclusive + ? v < end + : nextDay != null + ? v < nextDay + : explicit + ? v <= `${end}~` + : v <= end; + return v >= start && inUpper; }); } From aaf953b49d4e43af0c9089db1aaf8c7ed3c68f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 10:50:54 +0000 Subject: [PATCH 5/5] docs(changeset): count the draft-preview evaluator among the faces the fixture holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- .changeset/analytics-daterange-driver-alignment.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.changeset/analytics-daterange-driver-alignment.md b/.changeset/analytics-daterange-driver-alignment.md index 18b40bb847..576241cbf8 100644 --- a/.changeset/analytics-daterange-driver-alignment.md +++ b/.changeset/analytics-daterange-driver-alignment.md @@ -52,16 +52,24 @@ comparison on the other, at HTTP 200 on both. - **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` and both SQL strategies call it, so "memory and - SQL refuse identically" is one function rather than an agreement. + 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 three faces to the same + 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