From 1dd11a1bda97bbb1392dfa1a0a658c6fedff8dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 13:33:03 +0000 Subject: [PATCH 1/5] feat(core): hold every analytics face to the dateRange array ARITY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared conformance kit had exactly one array-arm case — a two-element window — so the arity itself was governed nowhere and each face was free to invent a reading for `['2026-01-01']`, `[]`, `[a, b, c]` and `[null, null]`. Adds `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and the case that holds every REGISTERED face to the rule PR #17593 already landed on the service-analytics faces: a non-two-bound array is refused with the ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope. No existing case is weakened — the two-element window case is this one's control. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude --- .../utils/analytics-date-range-conformance.ts | 105 ++++++++++++++++-- 1 file changed, 97 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/analytics-date-range-conformance.ts b/packages/core/src/utils/analytics-date-range-conformance.ts index e0b8502a05..3b5f89d13a 100644 --- a/packages/core/src/utils/analytics-date-range-conformance.ts +++ b/packages/core/src/utils/analytics-date-range-conformance.ts @@ -70,8 +70,16 @@ export interface AnalyticsDateRangeFace { /** * Lower one `dateRange` and report the window. ⛔ Must let a refusal * PROPAGATE — the kit reads the thrown envelope's `code` and `status`. + * + * ⚠️ The array arm is `readonly unknown[]`, not `readonly string[]`: the + * ARITY case below drives shapes the schema's `z.array(z.string())` types + * away but a real caller still reaches a face with — `[]` and `[null, null]` + * among them (`POST /analytics/dataset/query` types its selection from + * `AnalyticsQuery` and never Zod-parses it). A runner whose own parameter is + * the narrower type still satisfies this — the declaration is a METHOD, so + * its parameter is bivariant — and needs no change. */ - lower(range: string | readonly string[]): Promise; + lower(range: string | readonly unknown[]): Promise; } /** @@ -106,6 +114,49 @@ export const ANALYTICS_DATE_RANGE_EXPLICIT_WINDOW: readonly [string, string] = [ '2026-09-30T00:00:00.000Z', ]; +/** + * [#17596] ⛔ Array arms that do not denote a window — the ARITY case's inputs, + * each a shape an author or a generator really writes, ⛔ not fuzz. + * + * The kit's only array case used to be the two-element window above, so the + * arity itself was governed NOWHERE and every face was free to invent a + * reading for the rest. Four faces in one package had invented three — + * MEASURED on `abc4b83ce` (#17124), one authored document over the same rows: + * `['2026-01-01']` was a point window, an upper bound left unwritten, and a + * window dropped to ALL OF HISTORY, depending on which backend answered. A + * fifth face — `driver-memory`'s cube face — dropped it too (#17596, measured + * end to end: the one-element array emitted a pipeline byte-identical to one + * with no `dateRange` at all). + * + * ⭐ The rule asserted here is NOT invented for the kit: it is the one PR + * #17593 already landed on the `service-analytics` faces — a non-two-bound + * array is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 + * envelope — stated once here so every REGISTERED face is held to it instead + * of one package pinning it for itself. + * + * ⛔ Why a refusal and not an alignment: all three readings are ungoverned, and + * teaching every face the same guess is the "align them independently" shape + * the kit exists to end. What IS governed is the contract the spec's own + * refusal wording states — *an explicit window is the two-element array + * [start, end]* — and the #16322 migration table, which tells an author to + * write a single day as `['2026-01-20', '2026-01-20']`. TWO bounds. + * + * ⚠️ The two-element window case above is this case's CONTROL and is load + * bearing: without it, "refuse every array" would satisfy the whole array arm. + */ +export const ANALYTICS_DATE_RANGE_NOT_A_WINDOW: readonly (readonly unknown[])[] = [ + // The card's own shape: one bound, which is not a window. + ['2026-01-01'], + // No bounds at all — a generator that filtered its list to nothing. + [], + // Three bounds: which two? Every face that answered picked a different pair. + ['2026-09-01T00:00:00.000Z', '2026-09-30T00:00:00.000Z', '2026-10-31T00:00:00.000Z'], + // Two bounds of the right ARITY that are not dates — the shape that reached + // `parseUTC(null)` as a bare `TypeError` on one face, and lowered to the + // string `'null'` on another. Two bounds is necessary, not sufficient. + [null, null], +]; + /** 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']; @@ -117,7 +168,7 @@ interface ThrownEnvelope { async function attempt( face: AnalyticsDateRangeFace, - range: string | readonly string[], + range: string | readonly unknown[], ): Promise<{ window: LoweredDateRangeWindow } | { refusal: ThrownEnvelope }> { try { return { window: await face.lower(range) }; @@ -185,6 +236,29 @@ export async function analyticsDateRangeConformanceFindings( // ── Everything else is REFUSED, with one envelope ──────────────────────── const envelopes = new Set(); + /** + * ⭐ ONE judgement for every refusal this kit demands — the STRING arm's + * out-of-vocabulary spellings and the ARRAY arm's non-windows — so the two + * arms cannot drift into two envelopes for one condition. ⛔ The thrown + * MESSAGE is quoted only when there is no `code` at all, which is the case + * where the text is the only evidence of what the face actually did (a face + * that emitted no window and threw something of its own reads exactly like + * one that refused, until you read it). + */ + const judgeRefusal = (input: unknown, refusal: ThrownEnvelope): void => { + if (refusal.code !== 'ANALYTICS_DATE_RANGE_UNRECOGNIZED') { + say( + `refused ${JSON.stringify(input)} with code ${String(refusal.code)}, ` + + `not ANALYTICS_DATE_RANGE_UNRECOGNIZED` + + (refusal.code === undefined ? ` (${refusal.message ?? 'no message'})` : ''), + ); + } + if (refusal.status !== 400) { + say(`refused ${JSON.stringify(input)} with status ${String(refusal.status)}, not 400`); + } + envelopes.add(JSON.stringify({ code: refusal.code, status: refusal.status })); + }; + for (const bad of ANALYTICS_DATE_RANGE_REFUSED_SPELLINGS) { const got = await attempt(face, bad); if ('window' in got) { @@ -194,14 +268,29 @@ export async function analyticsDateRangeConformanceFindings( ); 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`); + judgeRefusal(bad, got.refusal); + } + + // ── [#17596] ARITY: an array that is not TWO bounds is not a window ─────── + // + // ⭐ The rule PR #17593 landed on the service-analytics faces, stated once + // for every registered face. ⛔ Reported per SHAPE rather than as one + // verdict: which arities a face answers is the finding — a face that + // refuses `[]` and answers `['2026-01-01']` has not adopted the rule, it has + // grown a fourth reading. + for (const bad of ANALYTICS_DATE_RANGE_NOT_A_WINDOW) { + const got = await attempt(face, bad); + if ('window' in got) { + say( + `ANSWERED the ${bad.length}-element array ${JSON.stringify(bad)} with ` + + `[${got.window.start}, ${got.window.end}] instead of refusing — an explicit window is ` + + 'the TWO-element array [start, end], so every other arity is a window this face INVENTED', + ); + continue; } - envelopes.add(JSON.stringify({ code: got.refusal.code, status: got.refusal.status })); + judgeRefusal(bad, got.refusal); } + if (envelopes.size > 1) { say(`raised ${envelopes.size} different envelopes for one condition — ADR-0112 asks for one`); } From 68a558e52f7808a07c0405678f083dd8e1ed860e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 13:49:13 +0000 Subject: [PATCH 2/5] fix(driver-memory): refuse a dateRange array that is not a two-bound window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEASURED end to end on `49cd71548`, four rows spanning 2020…2099: the cube face emitted NO time predicate at all for `['2026-01-01']`, `[]` and `['2026-01-01','2026-01-31','2026-02-01']` — a pipeline byte-identical to one with no `dateRange` — so the query read ALL of history, and `[null, null]` compared against the string `'null'` and selected nothing. The array arm is now judged at the discriminator and yields two bounds or throws the shared ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope, which is the rule the kit's new ARITY case holds every registered face to. The `if (range.length === 2)` guard — the line that dropped the window — is gone, and `ResolvedDateRange.bounds` is a tuple so it cannot come back. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude --- ...y-analytics-date-range-array-arity.test.ts | 160 +++++++++++++++ ...y-analytics-date-range-conformance.test.ts | 11 +- .../driver-memory/src/memory-analytics.ts | 192 +++++++++++++----- 3 files changed, 308 insertions(+), 55 deletions(-) create mode 100644 packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts new file mode 100644 index 0000000000..87983f5d55 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17596] The cube face's ARRAY arm, read at ROW level: an array that is not a + * two-bound window is refused, and a window that is one still selects exactly + * the rows it selected before. + * + * ## Why this file exists beside the conformance runner + * + * `memory-analytics-date-range-conformance.test.ts` runs the shared kit, whose + * ARITY case (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`) is what holds this face and + * the `service-analytics` faces to ONE answer — and ⛔ no rule is written here + * that is not the kit's. What the kit cannot see is the CONSEQUENCE: it reads + * the pipeline dump, so "no window" and "a window" are what it compares, while + * the defect was visible only in the ROWS. MEASURED on `49cd71548`, four rows + * spanning 2020…2099 through `MemoryAnalyticsService.query`: + * + * | `dateRange` | rows selected | pipeline emitted | + * |---|---|---| + * | `['2026-01-01', '2026-01-01']` | `b_target` — the one day | `$match` + `$group` | + * | `['2026-01-01']` | ⛔ ALL FOUR, 2020 and 2099 included | ⛔ byte-identical to a query with NO `dateRange` | + * | `[]` | ⛔ ALL FOUR | ⛔ same | + * | `['2026-01-01', '2026-01-31', '2026-02-01']` | ⛔ ALL FOUR | ⛔ same | + * | `[null, null]` | none — `$gte: 'null'`, which no instant sorts inside | `$match` | + * + * ⇒ the "plot all of history" shape #3650 was filed about, on the arm #16322 + * did not repair. ⭐ The last column is the sharpest statement of it: for three + * of those shapes the face produced the SAME pipeline it produces when the + * caller asked for no time window at all, so nothing downstream — not a status, + * not a field, not the dump — could tell a widened dashboard from a correct one. + * + * ## The population is the KIT's, deliberately + * + * The shapes are imported rather than restated: a shape added to the kit's case + * must gain its row-level reading here automatically, or this file would drift + * back into being one package's private idea of the rule. + */ + +import { describe, it, expect } from 'vitest'; +import { ANALYTICS_DATE_RANGE_NOT_A_WINDOW } from '@objectstack/core'; +import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; + +/** The ADR-0112 fields the REST catch classifies a thrown error on. */ +interface Refusal extends Error { + code?: unknown; + status?: unknown; +} + +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, +}; + +/** + * Rows far outside the window on BOTH sides, so "all of history", "unbounded + * above" and "one day" are three distinguishable answers rather than one. + */ +const ROWS = [ + { id: 'r1', probe: 'a_2020', created_at: '2020-01-01T00:00:00.000Z' }, + { id: 'r2', probe: 'b_target', created_at: '2026-01-01T12:00:00.000Z' }, + { id: 'r3', probe: 'c_2026_06', created_at: '2026-06-15T00:00:00.000Z' }, + { id: 'r4', probe: 'd_2099', created_at: '2099-12-31T00:00:00.000Z' }, +]; + +/** + * Drive the cube face end to end. + * + * ⛔ Deliberately NOT through `AnalyticsQuerySchema.parse`: the schema door is + * BEHIND this face — `POST /analytics/dataset/query` types its selection from + * `AnalyticsQuery` and never Zod-parses it — so an in-process caller reaching + * the face with one of these shapes is the live path, not a contrivance. + */ +async function query(dateRange?: unknown): Promise<{ probes: string[]; sql: string }> { + const driver = new InMemoryDriver({ initialData: { events: ROWS.map((r) => ({ ...r })) } }); + 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 === undefined ? {} : { dateRange }) }], + } as unknown as AnalyticsQuery); + return { + // The projection keys a dimension by its QUALIFIED name (`events.probe`), + // with the short name as the fallback the cube's own shape decides. + probes: (result.rows as Array>) + .map((r) => String(r['events.probe'] ?? r.probe)).sort(), + sql: String(result.sql), + }; +} + +async function refusalFrom(thunk: () => Promise): Promise { + try { + await thunk(); + return undefined; + } catch (e) { + return e as Refusal; + } +} + +describe('#17596 — an array arm that is not a two-bound window is REFUSED, not dropped', () => { + for (const shape of ANALYTICS_DATE_RANGE_NOT_A_WINDOW) { + it(`refuses ${JSON.stringify(shape)} (${shape.length} element(s))`, async () => { + const err = await refusalFrom(() => query(shape)); + expect(err, `the face ANSWERED ${JSON.stringify(shape)} — a window it invented`) + .toBeInstanceOf(Error); + // Read exactly as the REST catch reads them. ⛔ Not `toThrow()`: before + // this change three of these shapes threw NOTHING, and a face that threw + // a bare `Error` would satisfy it. + expect(err?.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(err?.status).toBe(400); + }); + } + + it('says what arrived, the two-element contract, and the single-day spelling to write', async () => { + const msg = String((await refusalFrom(() => query(['2026-01-01'])))?.message); + expect(msg).toContain('["2026-01-01"]'); // ① what arrived + expect(msg).toContain('1-element array'); // ② why it is not a window + expect(msg).toContain('TWO-element array [start, end]'); // ③ the contract + expect(msg).toContain('["2026-01-01", "2026-01-01"]'); // ④ what to write instead + }); +}); + +describe('#17596 CONTROL — a real window still answers exactly as it did before', () => { + // ⭐ Without these, every assertion above is satisfied by a face that refuses + // EVERY array — the opposite defect, and just as silent. + it('a two-element window selects that day and nothing else', async () => { + const { probes, sql } = await query(['2026-01-01', '2026-01-01']); + expect(probes).toEqual(['b_target']); + // #4042's half-open bare-day widening, byte for byte. + expect(sql).toContain('"$gte":"2026-01-01","$lt":"2026-01-02"'); + }); + + it('a preset still resolves through the shared vocabulary', async () => { + const { probes } = await query('today'); + expect(probes).toEqual([]); // no row is stamped today + expect((await query('last_90_days')).sql).toContain('$match'); + }); + + it('⭐ NO dateRange still selects all of history — the answer the defect gave', async () => { + // The measurement that makes the rows above mean something: this is the + // pipeline three refused shapes used to produce, so "refused" and "dropped" + // are distinguishable here rather than both reading as a green. + const { probes, sql } = await query(undefined); + expect(probes).toEqual(['a_2020', 'b_target', 'c_2026_06', 'd_2099']); + expect(sql).not.toContain('$match'); + }); +}); 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 index a488b85f30..0152b82868 100644 --- 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 @@ -68,7 +68,16 @@ async function lower(range: string | readonly string[]): Promise, fieldPath: string): unknown { * so the two paths never had to share an answer. */ interface ResolvedDateRange { - /** `[start, end]`, in the spelling the bounds are compared as. */ - readonly bounds: readonly string[]; + /** + * `[start, end]`, in the spelling the bounds are compared as. + * + * [#17596] A TUPLE, not a `readonly string[]`: both arms now produce exactly + * two bounds or throw, so the bound construction below needs no + * `if (range.length === 2)` — and that guard is precisely what used to drop + * an odd-sized array's window silently, selecting all of history. + */ + readonly bounds: readonly [string, string]; /** * Is `end` the first instant AFTER the window rather than its last instant? * `true` only for a window this driver RESOLVED; ⛔ never for one a caller @@ -718,6 +730,75 @@ interface ResolvedDateRange { readonly endExclusive: boolean; } +/** + * [#17596] The CALLER's explicit window as its two bounds — or the ADR-0112 + * refusal. + * + * ## What this replaces + * + * The array arm used to hand `timeDim.dateRange` to the bound construction + * unexamined, behind an `if (range.length === 2)`. MEASURED on `49cd71548`, + * four rows spanning 2020…2099 and one authored document: + * + * | `dateRange` | rows selected | pipeline | + * |---|---|---| + * | `['2026-01-01', '2026-01-01']` (the window) | the one day | `$match` + `$group` | + * | `['2026-01-01']` | ALL FOUR | ⛔ byte-identical to no `dateRange` at all | + * | `[]` | ALL FOUR | ⛔ same | + * | `['2026-01-01', '2026-01-31', '2026-02-01']` | ALL FOUR | ⛔ same | + * | `[null, null]` | none | `$gte: 'null'`, which no instant sorts inside | + * + * ⇒ the "plot all of history" shape #3650 was filed about and #16322 repaired + * for the STRING arm, resurrected on the array arm of the same face — and + * invisible, because a dashboard that silently widens its window still renders + * a number. + * + * ## Why a refusal, and why THIS refusal + * + * ⛔ Not an alignment: all three readings the platform's five faces gave an + * odd-sized array are ungoverned, so teaching this face one of them is + * inventing a fourth. What IS governed is the contract the spec's own refusal + * wording states — *an explicit window is the two-element array [start, end]* + * — and PR #17593 already landed exactly this refusal on the + * `service-analytics` faces. ⭐ The shared kit's ARITY case + * (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`) is what now holds both to it, which is + * why this is the same envelope and not a driver dialect. + * + * ⛔ Bound VALUES are not judged here: a bare `YYYY-MM-DD` versus a full + * timestamp is this face's own calendar translation (#4042) and happens below. + * + * @param dateRange - the array arm as it reached the face, unparsed. + * @returns the two bounds, in the order the author wrote them. + * @throws the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope when + * the array is not exactly two non-empty string bounds. + */ +function explicitDateRangeWindow(dateRange: readonly unknown[]): [string, string] { + const refuse = (received: string): Error => { + const err = analyticsDateRangeUnrecognizedError(dateRange); + err.message = + `[driver-memory] dateRange ${JSON.stringify(dateRange)} ${received}. An explicit window ` + + 'is the TWO-element array [start, end] of ISO dates or {date-macro} tokens — e.g. ' + + '["2026-01-01", "2026-01-31"]; for a single day write both bounds, ' + + '["2026-01-01", "2026-01-01"]. Refused (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400) ' + + 'rather than guessed: this face used to build no window at all for such an array, ' + + 'so the query read ALL of history — the same document another backend read as a ' + + 'single day.'; + return err; + }; + if (dateRange.length !== 2) { + throw refuse(`is a ${dateRange.length}-element array, not a window`); + } + const [start, end] = dateRange; + for (const bound of [start, end]) { + if (typeof bound !== 'string' || bound.length === 0) { + throw refuse( + `has a bound that is not a date string (${bound === null ? 'null' : typeof bound})`, + ); + } + } + return [start as string, end as string]; +} + /** * Memory-Based Analytics Service * @@ -870,62 +951,65 @@ export class MemoryAnalyticsService implements IAnalyticsService { // than being re-derived from the bounds themselves — which is not // possible, because a resolved window and a caller's window are // rendered identically (`toISOString()` on both sides). + // [#17596] The array arm is judged HERE, at the discriminator, and + // either yields two bounds or throws: a caller's window is the + // TWO-element [start, end], and any other shape used to fall past + // the `if (range.length === 2)` that stood under this line and reach + // the aggregation with NO time predicate emitted at all. const resolved: ResolvedDateRange = Array.isArray(timeDim.dateRange) - ? { bounds: timeDim.dateRange, endExclusive: false } + ? { bounds: explicitDateRangeWindow(timeDim.dateRange), endExclusive: false } : this.parseDateRangeString(timeDim.dateRange, query.timezone); const range = resolved.bounds; - if (range.length === 2) { - // The window matches BOTH stored forms of a datetime value — the - // in-memory table holds whatever the writer produced: `Date` - // objects from direct JS callers AND ISO strings (the driver's own - // `created_at` default, every REST/JSON write). Mingo compares - // cross-type as never-equal, so a single-form bound silently - // empties the other half — the same disease driver-sql's - // mixed-storage CASE repair cures, expressed as the `$or` a - // schemaless store allows. - // - // Both spellings are half-open on a bare-day end (#4042; the SQL - // twin is #3777): a `$lte`-at-midnight upper bound dropped the - // final day's rows for `Date` values and the string spelling - // inherits `<= day`'s whole-day intent via `< nextDay`. - const start = String(range[0]); - const end = String(range[1]); - // [#16179] The upper bound is EXCLUSIVE by exactly two routes, and - // they are mutually exclusive by construction: - // - // - the RESOLVER produced the window, so `end` is already the - // instant the window stops before -- `'today'`'s end is the - // first instant of tomorrow. ⛔ It must NOT be widened again: - // it is an instant, so `nextUtcCalendarDay` refuses it anyway - // (`calendar-day.ts`, pinned by `calendar-day.test.ts`), and - // asking is what would make a future bare-day resolver widen a - // bound that was already exclusive. - // - the CALLER wrote a bare `YYYY-MM-DD`, which denotes the WHOLE - // day and widens to `< nextDay` (#4042; the SQL twin is #3777). - // - // Anything else -- a full timestamp the CALLER wrote -- keeps - // instant semantics and stays INCLUSIVE, byte for byte as before. - const widenedDay = resolved.endExclusive ? null : nextUtcCalendarDay(end); - const upperString = resolved.endExclusive ? end : widenedDay; - const upperDate = widenedDay != null - ? new Date(`${widenedDay}T00:00:00.000Z`) - : (resolved.endExclusive ? new Date(end) : null); - const stringBounds = upperString != null - ? { $gte: start, $lt: upperString } - : { $gte: start, $lte: end }; - const dateBounds = upperDate != null - ? { $gte: new Date(start), $lt: upperDate } - : { $gte: new Date(start), $lte: new Date(end) }; - pipeline.push({ - $match: { - $or: [ - { [fieldPath]: stringBounds }, - { [fieldPath]: dateBounds }, - ], - } - }); - } + // The window matches BOTH stored forms of a datetime value — the + // in-memory table holds whatever the writer produced: `Date` + // objects from direct JS callers AND ISO strings (the driver's own + // `created_at` default, every REST/JSON write). Mingo compares + // cross-type as never-equal, so a single-form bound silently + // empties the other half — the same disease driver-sql's + // mixed-storage CASE repair cures, expressed as the `$or` a + // schemaless store allows. + // + // Both spellings are half-open on a bare-day end (#4042; the SQL + // twin is #3777): a `$lte`-at-midnight upper bound dropped the + // final day's rows for `Date` values and the string spelling + // inherits `<= day`'s whole-day intent via `< nextDay`. + const start = String(range[0]); + const end = String(range[1]); + // [#16179] The upper bound is EXCLUSIVE by exactly two routes, and + // they are mutually exclusive by construction: + // + // - the RESOLVER produced the window, so `end` is already the + // instant the window stops before -- `'today'`'s end is the + // first instant of tomorrow. ⛔ It must NOT be widened again: + // it is an instant, so `nextUtcCalendarDay` refuses it anyway + // (`calendar-day.ts`, pinned by `calendar-day.test.ts`), and + // asking is what would make a future bare-day resolver widen a + // bound that was already exclusive. + // - the CALLER wrote a bare `YYYY-MM-DD`, which denotes the WHOLE + // day and widens to `< nextDay` (#4042; the SQL twin is #3777). + // + // Anything else -- a full timestamp the CALLER wrote -- keeps + // instant semantics and stays INCLUSIVE, byte for byte as before. + const widenedDay = resolved.endExclusive ? null : nextUtcCalendarDay(end); + const upperString = resolved.endExclusive ? end : widenedDay; + const upperDate = widenedDay != null + ? new Date(`${widenedDay}T00:00:00.000Z`) + : (resolved.endExclusive ? new Date(end) : null); + const stringBounds = upperString != null + ? { $gte: start, $lt: upperString } + : { $gte: start, $lte: end }; + const dateBounds = upperDate != null + ? { $gte: new Date(start), $lt: upperDate } + : { $gte: new Date(start), $lte: new Date(end) }; + pipeline.push({ + $match: { + $or: [ + { [fieldPath]: stringBounds }, + { [fieldPath]: dateBounds }, + ], + } + }); } } } From 9ae3613e17f0fea259422c04eb8d40c195d615eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:18:50 +0000 Subject: [PATCH 3/5] =?UTF-8?q?chore(changeset):=20dateRange=20array=20ari?= =?UTF-8?q?ty=20=E2=80=94=20one=20reading=20on=20every=20face?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude --- .changeset/17596-daterange-array-arity.md | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .changeset/17596-daterange-array-arity.md diff --git a/.changeset/17596-daterange-array-arity.md b/.changeset/17596-daterange-array-arity.md new file mode 100644 index 0000000000..60fa3dd032 --- /dev/null +++ b/.changeset/17596-daterange-array-arity.md @@ -0,0 +1,32 @@ +--- +'@objectstack/core': patch +'@objectstack/driver-memory': patch +--- + +`dateRange`'s array arm has ONE arity everywhere: a two-element window, or the ADR-0112 refusal (#17596) + +The shared conformance kit +(`analyticsDateRangeConformanceFindings`) had exactly one array case — a +two-element window — so the ARITY of the array arm was governed nowhere and +every analytics face was free to invent a meaning for `dateRange: +['2026-01-01']`. Four faces in one package had invented three (#17124), and a +fifth — `driver-memory`'s cube face — had invented a fourth. + +**The kit** now exports `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and holds every +registered face to the rule the `service-analytics` faces already carry: an +array that is not two non-empty string bounds is refused with +`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. No new rule was invented for it, and +the existing two-element window case is untouched — it is this case's control, +so "refuse every array" cannot pass. + +**`driver-memory`** now answers that refusal instead of dropping the window. +MEASURED end to end over four rows spanning 2020…2099: `['2026-01-01']`, `[]` +and `['2026-01-01', '2026-01-31', '2026-02-01']` each emitted a pipeline +byte-identical to one with **no `dateRange` at all** — every row selected, the +"plot all of history" failure #3650 was filed about — and `[null, null]` +compared instants against the string `'null'` and selected none. + +**If you wrote a one-element array**, write both bounds: `['2026-01-01']` +becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every +face and did so before this change too. The refusal names the shape that +arrived, the two-element contract and that spelling. From 7fdb888aeefe1ff5c35862e46619974eec83e72a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:20:38 +0000 Subject: [PATCH 4/5] refactor(core): the ARITY finding names which half of the contract broke Two bounds is necessary, not sufficient: calling `[null, null]` an arity problem sends the next reader to the wrong line. Surfaced by this change's own ablation, where a real window smuggled into the not-a-window population was reported as "every other arity". Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude --- .../src/utils/analytics-date-range-conformance.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/analytics-date-range-conformance.ts b/packages/core/src/utils/analytics-date-range-conformance.ts index 3b5f89d13a..e13dcecc45 100644 --- a/packages/core/src/utils/analytics-date-range-conformance.ts +++ b/packages/core/src/utils/analytics-date-range-conformance.ts @@ -281,10 +281,16 @@ export async function analyticsDateRangeConformanceFindings( for (const bad of ANALYTICS_DATE_RANGE_NOT_A_WINDOW) { const got = await attempt(face, bad); if ('window' in got) { + // ⛔ Name WHICH half of the contract the shape breaks: two bounds is + // necessary, not sufficient, and a finding that calls `[null, null]` an + // arity problem sends the next reader to the wrong line. + const why = bad.length === 2 + ? 'its two bounds are not date strings' + : `a ${bad.length}-element array is not a window`; say( - `ANSWERED the ${bad.length}-element array ${JSON.stringify(bad)} with ` - + `[${got.window.start}, ${got.window.end}] instead of refusing — an explicit window is ` - + 'the TWO-element array [start, end], so every other arity is a window this face INVENTED', + `ANSWERED ${JSON.stringify(bad)} with [${got.window.start}, ${got.window.end}] instead ` + + `of refusing — an explicit window is the TWO-element array [start, end] of date ` + + `strings, and ${why}, so this is a window the face INVENTED`, ); continue; } From 4ab57672f6fce3145a1e1248cec8c228b42a943f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:38:17 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(changeset):=20grade=20@objectstack/co?= =?UTF-8?q?re=20`minor`=20=E2=80=94=20it=20gains=20an=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A purely additive widening of a published package's public surface takes at least `minor` (maintainer ruling 2026-09-04, decision batch #35, on #15294): `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` is absent on `origin/main`, exported from `analytics-date-range-conformance.ts` and re-exported by the package entry. `@objectstack/driver-memory` stays `patch` and the changeset now says why: its public surface is byte-unchanged, and the behaviour it stops producing was a defect the contract never admitted. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude --- .changeset/17596-daterange-array-arity.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.changeset/17596-daterange-array-arity.md b/.changeset/17596-daterange-array-arity.md index 60fa3dd032..0bf904421f 100644 --- a/.changeset/17596-daterange-array-arity.md +++ b/.changeset/17596-daterange-array-arity.md @@ -1,5 +1,5 @@ --- -'@objectstack/core': patch +'@objectstack/core': minor '@objectstack/driver-memory': patch --- @@ -26,6 +26,19 @@ byte-identical to one with **no `dateRange` at all** — every row selected, the "plot all of history" failure #3650 was filed about — and `[null, null]` compared instants against the string `'null'` and selected none. +**Levels.** `@objectstack/core` is `minor`: it gains a new exported symbol on +its index (`ANALYTICS_DATE_RANGE_NOT_A_WINDOW`), and a purely additive widening +of a published package's public surface takes at least `minor` whatever the +commit type says. `@objectstack/driver-memory` is `patch`: its public surface is +byte-unchanged — no new export, no new accepted key or value. Its behaviour does +change, from selecting every row to refusing with `400 +ANALYTICS_DATE_RANGE_UNRECOGNIZED`, and that is a `patch` because the old +behaviour was a defect and never a contract: the spec's own refusal wording +already said an explicit window is the two-element array, and the #16322 +migration table already told authors to write a single day as two bounds. A +release that stops answering a shape the contract never admitted is a fix, not a +feature — and the shapes it now refuses had no correct answer to lose. + **If you wrote a one-element array**, write both bounds: `['2026-01-01']` becomes `['2026-01-01', '2026-01-01']`, which selects exactly that day on every face and did so before this change too. The refusal names the shape that