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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/17596-daterange-array-arity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@objectstack/core': minor
'@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.

**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
arrived, the two-element contract and that spelling.
111 changes: 103 additions & 8 deletions packages/core/src/utils/analytics-date-range-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoweredDateRangeWindow>;
lower(range: string | readonly unknown[]): Promise<LoweredDateRangeWindow>;
}

/**
Expand Down Expand Up @@ -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'];

Expand All @@ -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) };
Expand Down Expand Up @@ -185,6 +236,29 @@ export async function analyticsDateRangeConformanceFindings(

// ── Everything else is REFUSED, with one envelope ────────────────────────
const envelopes = new Set<string>();
/**
* ⭐ 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) {
Expand All @@ -194,14 +268,35 @@ 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) {
// ⛔ 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 ${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;
}
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`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>)
.map((r) => String(r['events.probe'] ?? r.probe)).sort(),
sql: String(result.sql),
};
}

async function refusalFrom(thunk: () => Promise<unknown>): Promise<Refusal | undefined> {
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,16 @@ async function lower(range: string | readonly string[]): Promise<LoweredDateRang
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)}`);
// [#17596] ⛔ "no window" is not "a refusal", and the kit can only tell them
// apart by what this says: a pipeline with no `$match` stage selects EVERY
// row, which is the defect the ARITY case exists to catch on this face. The
// kit quotes this text whenever a thrown thing carries no ADR-0112 `code`.
if (!m) {
throw new Error(
'emitted NO window — the pipeline has no time predicate at all, so every row is '
+ `selected: ${String(result.sql)}`,
);
}
return { start: m[1], end: m[3], endExclusive: m[2] === '$lt' };
}

Expand Down
Loading
Loading