Skip to content

Commit 70a0e63

Browse files
committed
wip(analytics): the shipped origin sentence names the real runtime caller, and the arity refusal keeps one wording on the wire
F1 — `packages/spec/package.json` ships `src/**/*.zod.ts` literally, so the TSDoc at `analytics.zod.ts` reaches npm verbatim. It listed `POST /analytics/dataset/query` as a runtime-origin caller "which types its selection from `AnalyticsQuery` but never Zod-parses it". Measured on this tree, that route answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with a message ending `Refused at the schema (…)`: since #17058 its door parses the selection's shared members — `timeDimensions` included — against `AnalyticsQuerySchema.pick(…)` ahead of the executor. The sentence now names `queryDataset` / the dataset executor reached IN PROCESS as the runtime origin and states that every REST analytics route is a schema-origin door. Same correction at the core echo (which pre-exists on the base) and at the `service-analytics` test comment. F2 — on both REST doors the tuple arm's own `Too small: expected array to have >=2 items` rode along as a second `fields[]` entry beside the prescription: one condition, two wordings (#5240), introduced by this card's narrowing (on the base a 1-element array was not refused by the schema at all). The branch issues that land at the union's OWN path are now dropped in `fieldsFromZodIssues`, the one mapper both doors share, keyed on `isAnalyticsDateRangeRefusalIssue`. Branch issues naming a DEEPER position (`dateRange.1`, the non-string bound) are kept — this narrows a restatement, never a diagnosis — and no other union is touched. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1974dd5 commit 70a0e63

8 files changed

Lines changed: 295 additions & 25 deletions

File tree

‎packages/core/src/utils/analytics-date-range.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -207,23 +207,30 @@ export function resolveAnalyticsDateRangePreset(
207207
* condition, one wording), quoted rather than restated — asked for the
208208
* `'runtime'` ORIGIN, which is the one this constructor has.
209209
*
210-
* ⚠️ That argument is not decoration (#17598 item ②). Every refusal raised
211-
* here is raised PAST the schema door, so the sentence the spec used to return
212-
* unconditionally — "Refused at the schema" — was false for every one of
213-
* them, and sent an author to inspect a parse call that never ran. The origin
214-
* is a parameter precisely so this call site states the truth it alone knows;
215-
* ⛔ it is never omitted and there is no default to omit it to.
210+
* ⚠️ That argument is not decoration (#17598 item ②). Every refusal whose
211+
* MESSAGE leaves here is raised PAST the schema door, so the sentence the spec
212+
* used to return unconditionally — "Refused at the schema" — was false for
213+
* every one of them, and sent an author to inspect a parse call that never
214+
* ran. The origin is a parameter precisely so this call site states the truth
215+
* it alone knows; ⛔ it is never omitted and there is no default to omit it to.
216+
* The one caller that keeps the `.code`/`.status` and DISCARDS the message is
217+
* the REST dataset door, whose own refusal is the schema's — Reachability
218+
* below says why that is not an exception to the sentence.
216219
*
217220
* ⚠️ The code is registered under `@objectstack/runtime` (the door that names
218221
* the wire vocabulary) and this package carries a recorded provenance waiver
219222
* in `error-code-ledger.zod.ts` — the shared-constructor shape, the same one
220223
* `UPDATE_ID_MISMATCH` records.
221224
*
222-
* Reachability: on `POST /analytics/query` and `/analytics/sql` the schema door
223-
* refuses first and this never fires. It is the answer for the in-process
224-
* caller past that door — `AnalyticsService.query`, a driver's cube face
225-
* called directly, and `POST /analytics/dataset/query`, which types
226-
* `selection.timeDimensions` from `AnalyticsQuery` but does not Zod-parse it.
225+
* Reachability: on EVERY REST analytics route the schema door refuses first and
226+
* this never fires. `POST /analytics/query` and `/analytics/sql` parse the whole
227+
* body; `POST /analytics/dataset/query` has parsed its selection's shared
228+
* members — `timeDimensions` included — against `AnalyticsQuerySchema.pick(…)`
229+
* since #17058 (`rest/src/analytics-selection-door.ts`, wired ahead of the
230+
* executor), so that route answers "Refused at the schema" and takes only this
231+
* constructor's `.code`/`.status`. This is the answer for the IN-PROCESS caller
232+
* past those doors — `AnalyticsService.query`, `queryDataset` and the dataset
233+
* executor behind it, and a driver's cube face called directly.
227234
*/
228235
export function analyticsDateRangeUnrecognizedError(input: unknown): Error {
229236
const err = new Error(analyticsDateRangeRefusalMessage(input, 'runtime')) as Error & {

‎packages/rest/src/analytics-dataset-selection-door.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,84 @@ describe('#17058 §5 — a valid selection still passes, and passes through unch
371371
}
372372
});
373373
});
374+
375+
// ─────────────────────────────────────────────────────────────────────────────
376+
// §5 — [#17598] an arity refusal is ONE condition with ONE wording ON THE WIRE
377+
// ─────────────────────────────────────────────────────────────────────────────
378+
379+
/**
380+
* The array arm is `z.tuple([z.string(), z.string()])` since #17598, so a
381+
* 1-element window is refused HERE — on the base it was not refused by the
382+
* schema at all, which is why the arm's own `Too small` text only started
383+
* riding the wire with that narrowing. The prescription already names the
384+
* arity, so the arm's restatement is dropped in `fieldsFromZodIssues`
385+
* (`@objectstack/types`), the one mapper both analytics doors share.
386+
*
387+
* ⚠️ These assert the SERVED BODY. The review that found the second wording
388+
* recorded that nothing pinned the wire in either direction; at `error.issues`
389+
* the union has always been a single issue, so an issue-level pin would have
390+
* stayed green through exactly this defect.
391+
*/
392+
describe('#17598 §5 — the arity refusal carries one wording on the wire', () => {
393+
const arities: Array<[string, unknown]> = [
394+
['one bound', ['2026-01-01']],
395+
['no bounds', []],
396+
['three bounds', ['2026-01-01', '2026-01-15', '2026-01-31']],
397+
];
398+
399+
for (const [name, dateRange] of arities) {
400+
it(`${name}: the prescription, and NOT the arm's own arity text`, async () => {
401+
const { res, queryDataset } = await post({
402+
dataset: inlineDataset,
403+
selection: {
404+
measures: ['revenue'],
405+
timeDimensions: [{ dimension: 'close_date', dateRange }],
406+
},
407+
});
408+
409+
expect(res.statusCode).toBe(400);
410+
expect(res.body.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED');
411+
expect(res.body.message).toContain('not the two bounds [start, end]');
412+
expect(res.body.message).not.toMatch(/Too (small|big)/);
413+
// One entry for the member, so the `<field>: <message>` join names
414+
// it once — the shape a second wording showed up as.
415+
expect(String(res.body.message).match(/selection\.timeDimensions\.0\.dateRange/g))
416+
.toHaveLength(1);
417+
expect(queryDataset).not.toHaveBeenCalled();
418+
});
419+
}
420+
421+
it('a selection wrong in MORE than the dateRange keeps every other diagnosis', async () => {
422+
// ⛔ The collapse is not a silencer: the generic envelope still carries
423+
// the other member, and the dateRange condition appears exactly once.
424+
const { res } = await post({
425+
dataset: inlineDataset,
426+
selection: {
427+
measures: ['revenue'],
428+
dimensions: 'region',
429+
timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-01'] }],
430+
},
431+
});
432+
433+
expect(res.statusCode).toBe(400);
434+
expect(res.body.code).toBe('VALIDATION_FAILED');
435+
const fields: Array<{ field: string; message: string }> = res.body.details.fields;
436+
expect(fields.map((f) => f.field)).toContain('selection.dimensions');
437+
expect(fields.filter((f) => f.field === 'selection.timeDimensions.0.dateRange'))
438+
.toHaveLength(1);
439+
expect(fields.some((f) => /Too (small|big)/.test(f.message))).toBe(false);
440+
});
441+
442+
it('a non-string bound still names WHICH bound — that is a location, not a restatement', async () => {
443+
const { res } = await post({
444+
dataset: inlineDataset,
445+
selection: {
446+
measures: ['revenue'],
447+
timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-01', 3] }],
448+
},
449+
});
450+
451+
expect(res.statusCode).toBe(400);
452+
expect(res.body.message).toContain('selection.timeDimensions.0.dateRange.1');
453+
});
454+
});

‎packages/rest/src/analytics-selection-door.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@
7676
* own TSDoc names this route as the caller it was waiting for.
7777
*
7878
* The `message` is built the way the sibling builds it — `<field>: <message>`
79-
* joined — over `zodIssuesToFields`, the one ADR-0114 D3 mapper. Field paths
79+
* joined — over `fieldsFromZodIssues` (`@objectstack/types`), which is
80+
* `zodIssuesToFields`, the one ADR-0114 D3 mapper, plus the two things every
81+
* HTTP boundary owes on top of it: the root-path rename, and [#17598] the drop
82+
* of the date-range union's own arm RESTATEMENT, so an arity refusal reaches
83+
* the wire with ONE wording rather than the prescription followed by zod's
84+
* `Too small: expected array to have >=2 items`. That collapse lives in the one
85+
* mapper both analytics doors share, ⛔ never as a second copy here — which is
86+
* why this door reads the wrapper rather than the raw D3 function. Field paths
8087
* are prefixed `selection.` because they are reported against the REQUEST
8188
* body, where the parsed object sits one level down.
8289
*
@@ -86,7 +93,7 @@
8693
* not silently override the engine's own resolution chain).
8794
*/
8895

89-
import { zodIssuesToFields } from '@objectstack/spec/api';
96+
import { fieldsFromZodIssues } from '@objectstack/types';
9097
import { analyticsDateRangeUnrecognizedError } from '@objectstack/core';
9198

9299
/**
@@ -166,9 +173,13 @@ export async function datasetSelectionRefusal(
166173
const parsed = schema.safeParse(projection);
167174
if (parsed.success) return undefined;
168175

169-
const issues: Array<{ code: string; path: ReadonlyArray<PropertyKey>; input?: unknown }> =
170-
parsed.error.issues;
171-
const fields = zodIssuesToFields(issues, projection).map((entry) => ({
176+
const issues: Array<{
177+
code: string;
178+
path: Array<string | number | symbol>;
179+
message: string;
180+
input?: unknown;
181+
}> = parsed.error.issues;
182+
const fields = fieldsFromZodIssues(issues, projection).map((entry) => ({
172183
...entry,
173184
field: `selection.${entry.field}`,
174185
}));

‎packages/runtime/src/analytics-daterange-refusal-envelope.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,47 @@ describe('#16041 — /analytics/query refuses an unrecognised dateRange string a
143143
expect(calls.query).toEqual([]);
144144
});
145145
});
146+
147+
/**
148+
* [#17598] The same door, the arity half. The array arm is exactly two string
149+
* bounds now, so `['2026-01-01']` is refused here; the union's own issue is the
150+
* prescription and names the arity, and the tuple arm's `Too small: expected
151+
* array to have >=2 items` is that same condition in zod's words. One condition
152+
* keeps one wording (#5240), ON THE WIRE — the served envelope, not
153+
* `error.issues`, where the union has always been a single issue.
154+
*/
155+
describe('#17598 — an arity refusal carries one wording on the wire', () => {
156+
const arities: Array<[string, unknown]> = [
157+
['one bound', ['2026-01-01']],
158+
['no bounds', []],
159+
['three bounds', ['2026-01-01', '2026-01-15', '2026-01-31']],
160+
];
161+
162+
for (const [name, dateRange] of arities) {
163+
it(`${name}: the registered code, the prescription, and no second wording`, async () => {
164+
const { res, calls } = await post('/analytics/query', body(dateRange));
165+
166+
expect(res.statusCode).toBe(400);
167+
expect(res.body.error.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED');
168+
expect(res.body.error.message).toContain('not the two bounds [start, end]');
169+
expect(res.body.error.message).not.toMatch(/Too (small|big)/);
170+
expect(String(res.body.error.message).match(/timeDimensions\.0\.dateRange/g))
171+
.toHaveLength(1);
172+
expect(calls.query).toEqual([]);
173+
});
174+
}
175+
176+
it('a body wrong in MORE than the dateRange keeps the other diagnosis and still says the arity once', async () => {
177+
const { res, calls } = await post('/analytics/query', body(['2026-01-01'], { granuarity: 'day' }));
178+
179+
expect(res.statusCode).toBe(400);
180+
expect(res.body.error.code).toBe('VALIDATION_FAILED');
181+
const fields: Array<{ field: string; code: string; message: string }> = res.body.error.details.fields;
182+
// The typo'd key still speaks…
183+
expect(fields.map((f) => f.code)).toContain('unknown_field');
184+
// …and the dateRange condition is named exactly once, prescription only.
185+
expect(fields.filter((f) => f.field === 'timeDimensions.0.dateRange')).toHaveLength(1);
186+
expect(fields.some((f) => /Too (small|big)/.test(f.message))).toBe(false);
187+
expect(calls.query).toEqual([]);
188+
});
189+
});

‎packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,11 @@ describe('ObjectQLStrategy — timeDimensions[].dateRange (#3650)', () => {
219219
// [#17598] The array arm now types `dateRange` as EXACTLY two string
220220
// bounds, so this window no longer COMPILES — that is the schema door
221221
// doing its half of the same rule. The half THIS test pins is the
222-
// face's own refusal for a caller past that door (`POST
223-
// /analytics/dataset/query` types its selection from `AnalyticsQuery`
224-
// and never Zod-parses it), so the value is still handed over and the
225-
// type error is expected by name rather than the case deleted.
222+
// face's own refusal for a caller past that door — an IN-PROCESS
223+
// `AnalyticsService.query` / `queryDataset` call, never a REST route:
224+
// every analytics route parses `timeDimensions` at its own door and
225+
// answers "Refused at the schema". So the value is still handed over
226+
// and the type error is expected by name rather than the case deleted.
226227
// @ts-expect-error — one bound is not a window; write the day twice
227228
timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-20'] }],
228229
},

‎packages/spec/src/data/analytics.zod.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,9 +406,13 @@ function describeRefusedDateRange(input: unknown): string {
406406
* Where the refusal happened is the one clause no INPUT can supply: the same
407407
* value is refused at parse time by {@link AnalyticsDateRangeSchema} and, for a
408408
* caller past that door, in-process by `analyticsDateRangeUnrecognizedError`
409-
* (`@objectstack/core`) — `AnalyticsService.query`, a driver's cube face called
410-
* directly, `POST /analytics/dataset/query`, which types its selection from
411-
* `AnalyticsQuery` but never Zod-parses it. Until this parameter existed the
409+
* (`@objectstack/core`) — `AnalyticsService.query`, `queryDataset` and the
410+
* dataset executor behind it reached IN PROCESS, and a driver's cube face
411+
* called directly. ⚠️ Every REST analytics route is a SCHEMA-origin door,
412+
* `POST /analytics/dataset/query` included: since #17058 that route parses its
413+
* selection's shared members — `timeDimensions` among them — against
414+
* `AnalyticsQuerySchema.pick(…)` ahead of the executor, so that route's
415+
* refusal is THIS schema's and says so. Until this parameter existed the
412416
* shared sentence asserted the SCHEMA origin for both, so an author refused past
413417
* the door was sent to inspect a parse call that never ran; the one package that
414418
* noticed (`service-analytics`, #17593) had to OVERWRITE the message instead of

‎packages/types/src/validation-failure.test.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
*/
1818

1919
import { describe, it, expect } from 'vitest';
20-
import { FieldErrorCode, MarkNotificationsReadRequestSchema } from '@objectstack/spec/api';
20+
import {
21+
AnalyticsQueryRequestSchema,
22+
FieldErrorCode,
23+
MarkNotificationsReadRequestSchema,
24+
} from '@objectstack/spec/api';
2125
import { FlowSchema } from '@objectstack/spec/automation';
2226
import { fieldsFromZodIssues } from './validation-failure';
2327

@@ -101,3 +105,74 @@ describe('fieldsFromZodIssues — ADR-0114 D3 catalog codes, not Zod codes (#812
101105
expect(informed.find((f) => f.field.endsWith('label'))?.code).toBe('required');
102106
});
103107
});
108+
109+
/**
110+
* [#17598] ONE condition, ONE wording — for the `timeDimensions[].dateRange`
111+
* refusal, on the wire and not merely at `error.issues`.
112+
*
113+
* `AnalyticsDateRangeSchema` is a `z.union` carrying its own error map, so the
114+
* single issue zod raises is already a prescription AND already names the
115+
* arity ("received a 1-element array, not the two bounds [start, end]"). Its
116+
* tuple arm complains about the same value at the same path in zod's own words
117+
* ("Too small: expected array to have >=2 items"), and the #5014 union
118+
* expansion put both on the wire — one condition, two wordings, which is what
119+
* the #5240 convention exists to prevent and what `analytics.zod.ts` claims for
120+
* this refusal.
121+
*
122+
* Before #17598 the arm was `z.array(z.string())` with no length constraint, so
123+
* a 1-element window was not refused by the schema at all and there was no
124+
* second wording to have; the narrowing is what introduced it, and this is
125+
* where it is collapsed. The two edge cases below are as load-bearing as the
126+
* collapse itself: this narrows a RESTATEMENT, never a diagnosis.
127+
*/
128+
describe('fieldsFromZodIssues — the dateRange refusal keeps one wording (#17598)', () => {
129+
const analyticsBody = (dateRange: unknown) => ({
130+
cube: 'orders',
131+
measures: ['count'],
132+
timeDimensions: [{ dimension: 'created_at', granularity: 'day', dateRange }],
133+
});
134+
135+
const arities: Array<[string, unknown]> = [
136+
['a 1-element window', ['2026-01-01']],
137+
['an empty array', []],
138+
['three bounds', ['2026-01-01', '2026-01-15', '2026-01-31']],
139+
];
140+
141+
for (const [name, dateRange] of arities) {
142+
it(`${name} maps to exactly one entry, and it is the prescription`, () => {
143+
const fields = fieldsFromZodIssues(
144+
issuesOf(AnalyticsQueryRequestSchema, analyticsBody(dateRange)),
145+
);
146+
expect(fields).toHaveLength(1);
147+
expect(fields[0].field).toBe('timeDimensions.0.dateRange');
148+
expect(fields[0].message).toContain('not the two bounds [start, end]');
149+
// ⛔ The arm's own arity text is the second wording, and it is gone.
150+
expect(fields[0].message).not.toMatch(/Too (small|big)/);
151+
});
152+
}
153+
154+
it('a NON-string bound keeps the branch entry naming WHICH bound is wrong', () => {
155+
// The prescription says "an array with a non-string bound"; it does not
156+
// say WHICH one. `dateRange.1` names a position the prescription has
157+
// not, so it is a diagnosis rather than a restatement and it stays.
158+
const fields = fieldsFromZodIssues(
159+
issuesOf(AnalyticsQueryRequestSchema, analyticsBody(['2026-01-01', 3])),
160+
);
161+
expect(fields.map((f) => f.field)).toContain('timeDimensions.0.dateRange');
162+
expect(fields.map((f) => f.field)).toContain('timeDimensions.0.dateRange.1');
163+
});
164+
165+
it('CONTROL — a branch issue at its own branch ROOT still reaches the wire for every other key', () => {
166+
// `unrecognized_keys` is raised at the BRANCH root — structurally the
167+
// same position as the tuple arm's arity text — and it carries the
168+
// #4001 campaign's curated prose. If the collapse above were written as
169+
// "drop branch issues at the union's own path" rather than keyed on the
170+
// date-range recogniser, this is the family it would have silenced.
171+
const fields = fieldsFromZodIssues(issuesOf(FlowSchema, {
172+
...WELL_FORMED_FLOW,
173+
nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }],
174+
}));
175+
expect(fields.map((f) => f.code)).toContain('unknown_field');
176+
expect(fields.some((f) => f.message.includes('next'))).toBe(true);
177+
});
178+
});

0 commit comments

Comments
 (0)