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
35 changes: 35 additions & 0 deletions .changeset/18232-analytics-one-refusal-wording.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/service-analytics': patch
'@objectstack/core': patch
---

analytics `dateRange`: one condition, one refusal wording

An array `dateRange` that is not a two-bound window is refused by the
`service-analytics` faces with the platform's ONE shared sentence
(`analyticsDateRangeRefusalMessage`, origin `runtime`) instead of a
package-private second wording. The envelope is unchanged —
`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — so nothing that classifies on
`code`/`status` is affected; only the `message` text changes, and it now agrees
byte-for-byte with the sentence the schema door answers with for the same value.

The second wording existed because the shared sentence used to judge a bare
string against the preset vocabulary and to end with "Refused at the schema",
neither of which is true of an array refused past the schema door. Both grounds
were removed when `analyticsDateRangeRefusalMessage` gained its required
`origin` parameter and began describing a non-string by what is wrong with it.

⚠️ **The message no longer echoes the value you sent.** For an ARRAY
`dateRange` the shared sentence DESCRIBES the shape instead: what used to read
`dateRange ["a","b","c"] is a 3-element array` now reads `received a 3-element
array, not the two bounds [start, end]`. That applies to EVERY array shape this
face refuses, not to unusual ones only — `[null, null]` now reads `received an
array with a non-string bound`, and `['', '']` is where the description carries
least, `received a two-element array`. A bare STRING `dateRange` is still quoted
back to you. So a log line that used to carry the offending array no longer
does: if you need the value at that site, read it from the request you already
have, ⛔ not from the message.

⛔ If you match on the old text (`[service-analytics] dateRange …`), match on
`error.code === 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'` instead — the message was
never the contract, the envelope is.
30 changes: 19 additions & 11 deletions packages/core/src/utils/analytics-date-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,19 @@ export function resolveAnalyticsDateRangePreset(
* every one of them, and sent an author to inspect a parse call that never
* ran. The origin is a parameter precisely so this call site states the truth
* it alone knows; ⛔ it is never omitted and there is no default to omit it to.
* Three of the four callers keep the `.code`/`.status` and supply their OWN
* TWO of the four callers keep the `.code`/`.status` and supply their OWN
* message: the REST dataset door (`rest/src/analytics-selection-door.ts`),
* which serves the schema's own prescription because its refusal IS the
* schema's, and the two face-side array arms
* (`service-analytics/src/date-range-array-arm.ts` and
* `driver-memory/src/memory-analytics.ts`), each of which names what its own
* face would otherwise have guessed. The sentence built here leaves only
* through this package's own string resolver below — reached in process, past
* every door — which is the caller the `'runtime'` origin describes.
* schema's, and `driver-memory`'s face-side array arm
* (`driver-memory/src/memory-analytics.ts`), which names what its own face
* would otherwise have guessed. ⚠️ `service-analytics`' array arm
* (`service-analytics/src/date-range-array-arm.ts`) was a third until #18232:
* it overwrote the message on two grounds — a sentence that judged a bare
* STRING, and "Refused at the schema" — that this parameter and
* `describeRefusedDateRange` removed, so it now raises the sentence built here
* unchanged. That sentence therefore leaves through this package's own string
* resolver below AND through that arm, which are the callers the `'runtime'`
* origin describes.
*
* ⚠️ The code is registered under `@objectstack/runtime` (the door that names
* the wire vocabulary) and this package carries a recorded provenance waiver
Expand All @@ -242,10 +246,14 @@ export function resolveAnalyticsDateRangePreset(
* `z.tuple([z.string(), z.string()])` — it judges arity and bound TYPE, never a
* bound's VALUE — so the residue it cannot refuse, a two-string tuple with an
* empty bound such as `['', '']`, passes every door and reaches this
* constructor at each face (`date-range-array-arm.ts`, `memory-analytics.ts`),
* which then replaces the message. What does hold on a REST route is about the
* SENTENCE: each of the three callers a route can reach supplies its own, and a
* string that passed the preset enum cannot reach the resolver's throw below.
* constructor at each face (`date-range-array-arm.ts`, `memory-analytics.ts`) —
* `memory-analytics.ts` then replaces the message and, since #18232,
* `date-range-array-arm.ts` does not. What does hold on a REST route is about
* the SENTENCE: each of the three callers a route can reach answers with the
* sentence chosen for its own door — the schema's own prescription at the REST
* door, `driver-memory`'s at that face, and the one built here at
* `service-analytics`' — and a string that passed the preset enum cannot reach
* the resolver's throw below.
* This is also the answer for the IN-PROCESS caller past those doors —
* `AnalyticsService.query`, `queryDataset` and the dataset executor behind it,
* and a driver's cube face called directly.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,18 @@
* throwing a bare `Error` would satisfy, and which `[null, null]`'s
* `TypeError` did satisfy;
* - ONE envelope across the four, because one condition gets one envelope;
* - the message discipline: what arrived, the two-element contract, the
* single-day spelling to write instead;
* - ⭐ the WORDING, byte-for-byte equal to the spec's own
* `analyticsDateRangeRefusalMessage(input, 'runtime')` on every face and
* every shape — one condition keeps one wording (#5240), and #18232 removed
* the second wording this package used to overwrite it with once #18230 had
* removed both grounds that wording gave for existing. ⛔ Pinned by
* IDENTITY, not by substrings of a sentence this package owns: a substring
* pin is satisfied by any private wording that quotes the contract, which is
* exactly what stood here before;
* - the discipline that identity alone does NOT buy — identity tracks the
* shared builder wherever it goes, so what arrived, the two-bound contract,
* the single-day spelling and the RUNTIME origin are asserted as well, with
* the schema-origin sentence as the negative control;
* - ⭐ the CONTROL that the refusal did not widen — the two-element window
* answers byte-for-byte as it did before on each face, including the #3777
* half-open bare-day widening on the SQL side and the inclusive upper
Expand All @@ -48,6 +58,7 @@ import { DatasetSchema } from '@objectstack/spec/ui';
import type { Cube } from '@objectstack/spec/data';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { AnalyticsQuery, AnalyticsResult, IAnalyticsService } from '@objectstack/spec/contracts';
import { analyticsDateRangeRefusalMessage } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js';
import { compileDataset } from '../dataset-compiler.js';
Expand Down Expand Up @@ -220,15 +231,42 @@ describe('#17124 — an array arm that is not a two-bound window is REFUSED on e
expect(envelopes.size, `raised ${envelopes.size} envelopes: ${[...envelopes].join(' | ')}`).toBe(1);
});

it('says what arrived, the two-element contract, and the single-day spelling to write', async () => {
const msg = String((await refusalFrom(() => objectqlBounds(['2026-01-01'])))?.message);
// ① what arrived — so the author can find it in the document they wrote.
expect(msg).toContain('["2026-01-01"]');
it('every face raises the SHARED wording, byte-for-byte — one condition, one wording', async () => {
// ⭐ #18232. The `.code`/`.status` pin above is satisfied by a face that
// keeps the envelope and writes its own sentence — which is precisely what
// this package did until #18230 gave `analyticsDateRangeRefusalMessage` its
// `origin` parameter and removed the two grounds the second wording named.
for (const [faceName, drive] of FACES) {
for (const [shapeName, range] of NOT_A_WINDOW) {
const msg = String((await refusalFrom(() => drive(range)))?.message);
expect(msg, `${faceName} answered ${shapeName} with a wording of its own`)
.toBe(analyticsDateRangeRefusalMessage(range, 'runtime'));
}
}
});

it('that shared wording says what is wrong, the contract, the spelling and the ORIGIN', async () => {
// ⚠️ Identity above tracks the shared builder wherever it goes, so it
// would stay green if the builder itself dropped a clause. These four are
// the clauses an author needs, asserted against the sentence they receive.
const range: readonly string[] = ['2026-01-01'];
const msg = String((await refusalFrom(() => objectqlBounds(range)))?.message);
// ① what is WRONG with what arrived — the spec describes the shape rather
// than echoing the value (`describeRefusedDateRange`).
expect(msg).toContain('1-element array');
// ② the contract, in the spec's own words.
expect(msg).toContain('TWO-element array [start, end]');
// ③ what to do instead — the spelling every face already agrees on.
expect(msg).toContain('["2026-01-01", "2026-01-01"]');
expect(msg).toContain('two-element array [start, end]');
// ③ what to do instead — the single day written as both bounds.
expect(msg).toContain('BOTH bounds');
// ④ ⭐ the ORIGIN clause #18230 made a parameter: this refusal happened
// PAST the schema door, so the schema-origin sentence is the one it must
// NOT be — the negative control for the identity pin above.
expect(msg).toContain('Refused past the schema door');
expect(msg).not.toContain('Refused at the schema');
expect(msg).not.toBe(analyticsDateRangeRefusalMessage(range, 'schema'));
// ⑤ ⛔ and no package-private prefix: the second wording announced itself
// with one, so its absence is checkable.
expect(msg).not.toContain('[service-analytics]');
});
});

Expand Down
108 changes: 62 additions & 46 deletions packages/services/service-analytics/src/date-range-array-arm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
*
* ## What was wrong
*
* `AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no
* length constraint, so `['2026-01-01']` is schema-valid, and the four faces in
* this package that read the arm answered it three different ways — MEASURED on
* `abc4b83ce`, one authored document over the same four rows:
* `AnalyticsDateRangeSchema`'s array arm WAS a bare `z.array(z.string())` with
* no length constraint, so `['2026-01-01']` was schema-valid, and the four
* faces in this package that read the arm answered it three different ways —
* MEASURED on `abc4b83ce`, one authored document over the same four rows:
*
* | face | `['2026-01-01']` meant |
* |---|---|
Expand All @@ -36,55 +36,73 @@
*
* and by the shipped #16322 migration table, which tells an author to write a
* single day as `['2026-01-20', '2026-01-20']` — TWO bounds. So the arm's arity
* is declared; only the Zod type is weaker than the contract the same file
* states. A one-element array is therefore not an under-specified shape needing
* a meaning invented for it: it is a shape the contract already excludes, and
* the kit's rule for a `dateRange` that does not denote a window is
* *"an unresolvable window is a refusal, never a window"*.
* was declared long before the Zod type said it; only that type was weaker than
* the contract the same file states. A one-element array is therefore not an
* under-specified shape needing a meaning invented for it: it is a shape the
* contract already excludes, and the kit's rule for a `dateRange` that does not
* denote a window is *"an unresolvable window is a refusal, never a window"*.
*
* ⭐ And the author loses nothing: `['2026-01-01', '2026-01-01']` selects exactly
* that one day on all four faces today (measured as this change's control), so
* the refusal costs a second bound and buys a document that means one thing.
*
* ## Reachability — why a face-side refusal exists at all
* ## Reachability — why a face-side refusal is STILL needed (#18232)
*
* `POST /analytics/dataset/query` types its selection from `AnalyticsQuery` and
* never Zod-parses it, so the schema door is BEHIND these faces. ⛔ Tightening
* `AnalyticsDateRangeSchema` itself is `packages/spec`'s call and is deliberately
* NOT done here; this is the in-process door past that one, the same seam an
* unrecognised `compareTo.kind` is refused at.
* ⚠️ Three sentences this section carried until #18232 were true when written
* and are false now, because `packages/spec` has since made the call they said
* it had not made. Re-measured on `origin/main`:
*
* - the array arm is `z.tuple([z.string(), z.string()])`, ⛔ no longer the
* bare `z.array(z.string())` the section above describes as history —
* maintainer ruling A on #17598 (decision batch #117 item 3), landed by
* PR #18230;
* - so tightening `AnalyticsDateRangeSchema` is ⛔ not "deliberately NOT done
* here" any more: it is done, upstream, where the contract lives;
* - and `POST /analytics/dataset/query` is ⛔ no longer a route that never
* Zod-parses its selection — since PR #17548 it parses the selection's
* shared members, `timeDimensions` among them, against
* `AnalyticsQuerySchema.pick(…)` ahead of the executor
* (`rest/src/analytics-selection-door.ts`, wired in `rest-server.ts`), so on
* THAT route the schema door is AHEAD of these faces, not behind them.
*
* ⛔ None of which retires this door. Two reasons, structural rather than
* historical:
*
* - **The in-process callers never parse.** `AnalyticsService.query`,
* `queryDataset` and the dataset executor behind them type their selection
* from `AnalyticsQuery` and Zod-parse nothing, so every shape in the table
* above still arrives at these faces exactly as it did.
* - **The tuple judges ARITY and bound TYPE, never a bound's VALUE.** The
* residue it cannot refuse — a two-string window with an empty bound,
* `['', '']` — satisfies the arm at every door and is refused here.
*/

import { analyticsDateRangeUnrecognizedError } from '@objectstack/core';

/**
* Build the ADR-0112 refusal for an array arm that does not denote a window.
*
* ⭐ The ENVELOPE comes from the ONE shared constructor — the `code` + `status`
* pair is what the cross-package conformance kit reads, and it must have a
* single origin. Only the SENTENCE is this condition's own: the shared wording
* judges a bare STRING against the preset vocabulary and ends with
* "Refused at the schema", and neither is true of an array refused past the
* schema door by a face. ⛔ A message stating two falsehoods is not reuse.
*/
function arrayArmRefusal(dateRange: readonly unknown[], received: string): Error {
const err = analyticsDateRangeUnrecognizedError(dateRange);
err.message =
`[service-analytics] 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"] or ["{7_days_ago}", "{today}"]; for a single day '
+ 'write both bounds, ["2026-01-01", "2026-01-01"]. Refused '
+ '(ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400) rather than guessed: this package\'s four '
+ 'analytics faces read an odd-sized array three different ways — a point window, a '
+ 'window dropped to all of history, and an upper bound left unwritten — so any number '
+ 'computed from one would depend on which backend answered.';
return err;
}

/**
* The CALLER's explicit window, as the two bounds every face in this package
* lowers — or the refusal.
*
* ## ⭐ ONE condition, ONE wording — envelope AND sentence (#18232)
*
* The `code` + `status` pair has always come from the ONE shared constructor,
* because the cross-package conformance kit reads it and it must have a single
* origin. The MESSAGE used to be OVERWRITTEN here with a second wording for the
* same condition, on two stated grounds: that the shared sentence judged a bare
* STRING against the preset vocabulary, and that it ended with "Refused at the
* schema" — neither true of an array refused past the schema door by a face.
* ⛔ PR #18230 removed both grounds. `analyticsDateRangeRefusalMessage` now
* describes a non-string by what is WRONG with it (`describeRefusedDateRange`)
* and takes the ORIGIN as a required parameter, so the sentence
* `analyticsDateRangeUnrecognizedError` builds — origin `'runtime'`, *"Refused
* past the schema door, by the analytics reader that received it"* — is true of
* exactly this door. With its two grounds gone, the second wording was what the
* #5240 convention exists to prevent: one condition with two wordings.
*
* ⛔ The reason this package refuses rather than guesses is this module's
* header, ⛔ not the message — re-stating it in the sentence is how the second
* wording got here in the first place.
*
* ⛔ Bound VALUES are not judged here: a bare `YYYY-MM-DD` versus a full
* timestamp is a per-face calendar translation (#3777 / #4042) and a
* `{date-macro}` token is expanded upstream, neither of which this arity rule
Expand All @@ -93,20 +111,18 @@ function arrayArmRefusal(dateRange: readonly unknown[], received: string): Error
*
* @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 string bounds.
* @throws the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope,
* carrying the spec's own `'runtime'`-origin wording, when the array is not
* exactly two non-empty string bounds.
*/
export function explicitDateRangeWindow(dateRange: readonly unknown[]): [string, string] {
if (dateRange.length !== 2) {
throw arrayArmRefusal(dateRange, `is a ${dateRange.length}-element array, not a window`);
throw analyticsDateRangeUnrecognizedError(dateRange);
}
const [start, end] = dateRange;
for (const bound of [start, end]) {
if (typeof bound !== 'string' || bound.length === 0) {
throw arrayArmRefusal(
dateRange,
`has a bound that is not a date string (${bound === null ? 'null' : typeof bound})`,
);
throw analyticsDateRangeUnrecognizedError(dateRange);
}
}
return [start as string, end as string];
Expand Down
Loading