diff --git a/.changeset/analytics-dataset-query-selection-door-parse.md b/.changeset/analytics-dataset-query-selection-door-parse.md new file mode 100644 index 00000000000..afd9fb60f52 --- /dev/null +++ b/.changeset/analytics-dataset-query-selection-door-parse.md @@ -0,0 +1,39 @@ +--- +'@objectstack/rest': minor +--- + +`POST /analytics/dataset/query` parses its `selection` at the door, the way its two siblings already do + +The route checked one thing about the body it forwards — that +`selection.measures` was a non-empty array — and forwarded everything else +unexamined. `/analytics/query` and `/analytics/sql` Zod-parse their body at +the entry and lift a malformed member to a 400 before the service is reached, +so a client met two postures on one family depending on which door it knocked +on, and a malformed member of `selection` travelled into `dataset-executor` to +be answered by whatever the face behind it happened to do with it. + +⚠️ **A 400 is newly reachable.** Requests that previously slipped through are +now refused. Two shapes: + +- A `timeDimensions[].dateRange` outside the closed preset vocabulary answers + `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` — the same code, status and wording + the sibling door has answered for the identical condition since the + vocabulary closed. Measured on the tree before this change, the literal + string `not a range at all` reached the executor under an ordinary `200`. +- Anything else malformed answers `400 VALIDATION_FAILED` with + `details.fields[]`, each entry naming the member as `selection.`. + +**What is NOT newly refused, deliberately.** `selection` is a +`DatasetSelection`, which is *not* the `AnalyticsQuery` the siblings parse: it +carries no `cube`, and `runtimeFilter`, `dateGranularity`, `compareTo` and +`totals` are members of its own. Reusing the sibling schema would have refused +every real dashboard widget. What the door parses is the projection of the +seven members whose declaration on `DatasetSelection` *is* the `AnalyticsQuery` +member of the same name — `dimensions`, `measures`, `timeDimensions` (declared +there by reference), `order`, `limit`, `offset`, `timezone` — so the refusal +set is exactly what the published interface already declared. The four +dataset-only members are projected away before the parse and keep reaching the +executor untouched. + +Validation-only: the caller's `selection` object is what `queryDataset` +receives, by identity, never a parse output. diff --git a/packages/rest/src/analytics-dataset-selection-door.test.ts b/packages/rest/src/analytics-dataset-selection-door.test.ts new file mode 100644 index 00000000000..db290ade1d2 --- /dev/null +++ b/packages/rest/src/analytics-dataset-selection-door.test.ts @@ -0,0 +1,373 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17058] `POST /analytics/dataset/query` parses its `selection` AT THE DOOR. + * + * The defect: the route checked only that `selection.measures` was a non-empty + * array, so every other member reached `dataset-executor` unrefused — while the + * sibling routes (`/analytics/query`, `/analytics/sql`) lift the identical + * failure to a 400 at their entry. One family, two postures. + * + * ## The measurement the card left open, taken here and PINNED + * + * The card asked whether the dataset route's `selection` is genuinely the same + * shape as the siblings' before reusing their schema. It is **not** — §1 below + * drives that against the real schema — so the door parses a PROJECTION of the + * members whose declarations coincide, and the four dataset-only members are + * projected away rather than refused. §5 is the other half of that answer and + * the one that matters most: a fully-loaded VALID selection still passes. + * A door that refuses too much is a worse defect than the one being fixed. + */ + +// The dynamic `import()`s below are paid HERE, at module scope, so the +// transform lands during collection rather than inside a clocked window +// (`pnpm check:test-source-alias`; this package resolves both specifiers +// through `dist/`). The dynamic calls stay where they are — this only decides +// where the first load is paid. +import '@objectstack/spec/api'; +import '@objectstack/spec/data'; + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; +import { + SELECTION_MEMBERS_SHARED_WITH_ANALYTICS_QUERY, + datasetSelectionRefusal, +} from './analytics-selection-door'; + +// ── harness (the shape `analytics-routes.test.ts` uses) ────────────────────── + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} +function mockProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + }; +} +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.end = vi.fn(() => res); + return res; +} + +const inlineDataset = { + name: 'sales', + label: 'Sales', + object: 'opportunity', + dimensions: [ + { name: 'region', field: 'region', type: 'string' }, + { name: 'close_date', field: 'close_date', type: 'date' }, + ], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], +}; + +function buildRoute() { + const queryDataset = vi.fn().mockResolvedValue({ rows: [], fields: [] }); + const server = mockServer(); + const rest = new RestServer( + server as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + async () => ({ queryDataset }), + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query'))!; + expect(route, 'POST …/analytics/dataset/query must be registered').toBeTruthy(); + return { route, queryDataset }; +} + +/** POST a body through the REAL route and return `{ res, queryDataset }`. */ +async function post(body: unknown) { + const { route, queryDataset } = buildRoute(); + const res = mockRes(); + await route.handler({ method: 'POST', params: {}, headers: {}, body } as any, res); + return { res, queryDataset }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// §1 — the shape question: `DatasetSelection` is NOT the siblings' shape +// ───────────────────────────────────────────────────────────────────────────── + +describe('#17058 §1 — the dataset route\'s `selection` is not the sibling routes\' body shape', () => { + /** + * A legal, ordinary dashboard-widget selection. Every member here is + * declared on `DatasetSelection` (`spec/contracts/analytics-service.ts`). + */ + const legalSelection = { + dimensions: ['region'], + measures: ['revenue'], + runtimeFilter: { region: 'NA' }, + timeDimensions: [{ dimension: 'close_date', granularity: 'month', dateRange: 'last_30_days' }], + dateGranularity: 'month', + order: { revenue: 'desc' }, + limit: 10, + offset: 0, + compareTo: { kind: 'previousPeriod' }, + totals: { groupings: [['region'], []] }, + timezone: 'Asia/Shanghai', + } as const; + + it('the siblings\' own schema REFUSES it — on `cube` and on all four dataset-only members', async () => { + const { AnalyticsQueryRequestSchema } = await import('@objectstack/spec/api'); + const parsed = (AnalyticsQueryRequestSchema as any).safeParse(legalSelection); + + expect(parsed.success, 'a legal DatasetSelection must NOT parse as an AnalyticsQuery').toBe(false); + const paths: string[] = parsed.error.issues.map((i: any) => i.path.join('.')); + const unrecognized: string[] = parsed.error.issues + .filter((i: any) => i.code === 'unrecognized_keys') + .flatMap((i: any) => i.keys ?? []); + + // `cube` is required there and absent here — a dataset selection names + // no cube; the dataset is addressed by `body.dataset`/`datasetName`. + expect(paths).toContain('cube'); + // …and the schema is `.strict()`, so the dataset-only members are keys + // it has never heard of. This is why reusing it would 400 every real + // dashboard widget. + expect(unrecognized.sort()).toEqual( + ['compareTo', 'dateGranularity', 'runtimeFilter', 'totals'].sort(), + ); + }); + + it('the shared projection accepts the same selection — that is the half this door parses', async () => { + expect(await datasetSelectionRefusal(legalSelection)).toBeUndefined(); + }); + + /** + * The projection list is a claim about two declarations agreeing. Pin both + * directions so a later edit cannot quietly move a member into or out of it. + */ + it('every projected member is an `AnalyticsQuery` member; no dataset-only member is', async () => { + const { AnalyticsQuerySchema } = await import('@objectstack/spec/data'); + const analyticsMembers = Object.keys((AnalyticsQuerySchema as any).shape); + for (const member of SELECTION_MEMBERS_SHARED_WITH_ANALYTICS_QUERY) { + expect(analyticsMembers, `${member} must be declared on AnalyticsQuery`).toContain(member); + } + for (const datasetOnly of ['runtimeFilter', 'dateGranularity', 'compareTo', 'totals']) { + expect(analyticsMembers).not.toContain(datasetOnly); + expect(SELECTION_MEMBERS_SHARED_WITH_ANALYTICS_QUERY as readonly string[]) + .not.toContain(datasetOnly); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §2 — the card's measured specimen, driven through the real route +// ───────────────────────────────────────────────────────────────────────────── + +describe('#17058 §2 — a malformed `dateRange` is refused at the door, not by the face behind it', () => { + it('answers 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED and never reaches the service', async () => { + const { res, queryDataset } = await post({ + dataset: inlineDataset, + selection: { + dimensions: ['region'], + measures: ['revenue'], + timeDimensions: [{ dimension: 'close_date', dateRange: 'not a range at all' }], + }, + }); + + expect(res.statusCode).toBe(400); + // The SAME code the sibling door answers for the identical condition — + // one condition, one code (ADR-0112 D3 / the #5240 convention). + expect(res.body.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + // The message locates the offending member on the REQUEST body… + expect(res.body.message).toContain('selection.timeDimensions.0.dateRange'); + // …and carries the schema's own prescription, quoted not restated. + expect(res.body.message).toContain('not a range at all'); + expect(res.body.message).toContain('last_7_days'); + // The whole point of a door: the executor never sees it. + expect(queryDataset).not.toHaveBeenCalled(); + }); + + it('the envelope\'s code is a registered vocabulary member, not a dialect', async () => { + const { ApiErrorSchema } = await import('@objectstack/spec/api'); + const { res } = await post({ + dataset: inlineDataset, + selection: { + measures: ['revenue'], + timeDimensions: [{ dimension: 'close_date', dateRange: 'Last 7 days' }], + }, + }); + const parsed = (ApiErrorSchema as any).safeParse({ + code: res.body.code, + message: res.body.message, + httpStatus: res.statusCode, + }); + expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §3 — the rest of the shape: `timeDimensions` is not special +// ───────────────────────────────────────────────────────────────────────────── + +describe('#17058 §3 — the generic refusal is 400 VALIDATION_FAILED + details.fields[]', () => { + const cases: Array<{ name: string; selection: Record; field: string }> = [ + { + name: 'a typo\'d nested key (`granuarity`) — top-level strictness does not recurse', + selection: { + measures: ['revenue'], + timeDimensions: [{ dimension: 'close_date', granuarity: 'month' }], + }, + field: 'selection.timeDimensions.0', + }, + { + name: 'a measure name that is not a string', + selection: { measures: [42] }, + field: 'selection.measures.0', + }, + { + name: 'a dimension list that is not a list', + selection: { measures: ['revenue'], dimensions: 'region' }, + field: 'selection.dimensions', + }, + { + name: 'an order direction outside the declared pair', + selection: { measures: ['revenue'], order: { revenue: 'ASC' } }, + field: 'selection.order.revenue', + }, + { + name: 'a `limit` sent as a string', + selection: { measures: ['revenue'], limit: '10' }, + field: 'selection.limit', + }, + ]; + + for (const c of cases) { + it(`refuses ${c.name}`, async () => { + const { res, queryDataset } = await post({ dataset: inlineDataset, selection: c.selection }); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + const fields: Array<{ field: string; code: string }> = res.body.details.fields; + expect(fields.map((f) => f.field)).toContain(c.field); + expect(queryDataset).not.toHaveBeenCalled(); + }); + } + + it('the date-range lift is all-or-nothing: a body wrong in MORE places stays generic', async () => { + const { res } = await post({ + dataset: inlineDataset, + selection: { + measures: ['revenue'], + timeDimensions: [{ dimension: 'close_date', dateRange: 'nope', granuarity: 'month' }], + }, + }); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + const fields: Array<{ field: string }> = res.body.details.fields; + expect(fields.map((f) => f.field)).toContain('selection.timeDimensions.0.dateRange'); + expect(fields.map((f) => f.field)).toContain('selection.timeDimensions.0'); + }); + + it('the `measures` door ahead of the parse keeps its own sentence', async () => { + const { res } = await post({ dataset: inlineDataset, selection: { dimensions: ['region'] } }); + expect(res.statusCode).toBe(400); + expect(res.body.code).toBe('VALIDATION_FAILED'); + expect(res.body.message).toContain('body.selection.measures'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §4 — the four dataset-only members keep passing (they are projected away) +// ───────────────────────────────────────────────────────────────────────────── + +describe('#17058 §4 — the dataset-only members are NOT judged by the sibling schema', () => { + const datasetOnly: Array<[string, unknown]> = [ + ['runtimeFilter', { region: { $ne: 'EU' } }], + ['dateGranularity', 'quarter'], + ['compareTo', { kind: 'previousYear' }], + ['totals', { groupings: [[]] }], + ]; + + for (const [member, value] of datasetOnly) { + it(`\`${member}\` reaches the service untouched`, async () => { + const selection = { dimensions: ['region'], measures: ['revenue'], [member]: value }; + const { res, queryDataset } = await post({ dataset: inlineDataset, selection }); + expect(res.statusCode).toBe(200); + expect(queryDataset).toHaveBeenCalledTimes(1); + expect(queryDataset.mock.calls[0][1]).toBe(selection); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §5 — ⭐ the negative side: a valid selection still passes, unmodified +// ───────────────────────────────────────────────────────────────────────────── + +describe('#17058 §5 — a valid selection still passes, and passes through unchanged', () => { + it('a fully-loaded selection — all eleven members — answers 200', async () => { + const selection = { + dimensions: ['region'], + measures: ['revenue'], + runtimeFilter: { region: 'NA' }, + timeDimensions: [{ dimension: 'close_date', granularity: 'month', dateRange: 'last_30_days' }], + dateGranularity: 'month', + order: { revenue: 'desc' }, + limit: 10, + offset: 0, + compareTo: { kind: 'previousPeriod', dimension: 'close_date' }, + totals: { groupings: [['region'], []] }, + timezone: 'Asia/Shanghai', + }; + const { res, queryDataset } = await post({ dataset: inlineDataset, selection }); + expect(res.statusCode).toBe(200); + expect(queryDataset).toHaveBeenCalledTimes(1); + // Validation-only: the CALLER's object is what the service receives, + // by identity — never a parse output that could carry a schema default. + expect(queryDataset.mock.calls[0][1]).toBe(selection); + }); + + it('the explicit `[start, end]` window arm is untouched by the closing', async () => { + const { res, queryDataset } = await post({ + dataset: inlineDataset, + selection: { + measures: ['revenue'], + timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-01', '{today}'] }], + }, + }); + expect(res.statusCode).toBe(200); + expect(queryDataset).toHaveBeenCalledTimes(1); + }); + + /** + * The leniency sweep, as a test. Every `selection` literal the in-repo + * suites POST at this route (and the one `packages/qa/dogfood` sends) is + * replayed through the new door: if any of them had been relying on the + * leniency, the fix would have broken it, and triage asked for that answer + * explicitly rather than as an impression. + */ + it('every in-repo selection specimen still passes the door', async () => { + const specimens: Array> = [ + // packages/rest/src/analytics-dataset-dimension-gate.test.ts + { measures: ['account_count'], dimensions: ['bogus_dim'] }, + { measures: ['account_count'], dimensions: ['industry'], timeDimensions: [{ dimension: 'bogus_dim', granularity: 'month' }] }, + // packages/rest/src/analytics-dataset-where-gate.test.ts + { measures: ['account_count'], runtimeFilter: { bogus_col: 'x' } }, + { measures: ['account_count'], runtimeFilter: { industry: { $sortOf: 'tech' } } }, + // packages/rest/src/analytics-dataset-refusal-envelope.test.ts + { dimensions: ['stage'], measures: ['revenue'], order: { profit: 'desc' } }, + { dimensions: ['stage'], measures: ['revenue'], totals: { groupings: [['region']] } }, + // packages/rest/src/analytics-dataset-unlisted-refusal-envelope.test.ts + { dimensions: ['stage'], measures: ['revenue'], timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-01', 'the-first-of-never'] }], compareTo: { kind: 'previousPeriod' } }, + { dimensions: ['stage'], measures: ['revenue'], timeDimensions: [{ dimension: 'close_date', granularity: 'month' }], compareTo: { kind: 'previousPeriod' } }, + { dimensions: ['stage'], measures: ['revenue'], timeDimensions: [{ dimension: 'account_opened', granularity: 'month' }] }, + // packages/client/src/client.test.ts + { measures: ['amount_sum'] }, + // packages/qa/dogfood/test/temporal-storage-e2e.dogfood.test.ts + { measures: ['cnt'], timeDimensions: [{ dimension: 'issued', granularity: 'month' }] }, + ]; + for (const selection of specimens) { + expect( + await datasetSelectionRefusal(selection), + `specimen must still pass: ${JSON.stringify(selection)}`, + ).toBeUndefined(); + } + }); +}); diff --git a/packages/rest/src/analytics-selection-door.ts b/packages/rest/src/analytics-selection-door.ts new file mode 100644 index 00000000000..987fdea03b2 --- /dev/null +++ b/packages/rest/src/analytics-selection-door.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17058] The door parse for `POST {basePath}/analytics/dataset/query`'s + * `selection` — the half of the analytics family this route never had. + * + * ## The gap + * + * `/analytics/query` and `/analytics/sql` Zod-parse their body at the entry + * (`runtime/src/domains/analytics.ts` → `assertAnalyticsQueryBody`) and lift a + * malformed member to a 400 before the service is reached. The dataset route + * checked only that `selection.measures` was a non-empty array, so every other + * member travelled into `dataset-executor` unrefused and was answered by + * whatever the face behind it happened to do with it. That is the same door, + * one family, two postures — the inconsistency a client cannot predict. + * + * ## Why this is a PROJECTION and not a reuse of the siblings' schema + * + * ⚠️ Measured before writing a line, because the card left it open: **the + * dataset route's `selection` is NOT the sibling routes' shape.** It is + * `DatasetSelection` (`spec/contracts/analytics-service.ts`), and against + * `AnalyticsQueryRequestSchema` a perfectly legal selection fails twice over — + * the sibling schema requires `cube` (a dataset selection never carries one: + * the dataset is addressed by `body.dataset` / `body.datasetName`) and it is + * `.strict()`, so `runtimeFilter`, `dateGranularity`, `compareTo` and `totals` + * are all rejected as unrecognized keys. ⛔ Reusing it would refuse every real + * dashboard widget — a far worse defect than the one being fixed. + * + * What IS shared is member-by-member, and it is most of the shape. Seven of + * `DatasetSelection`'s eleven members declare exactly the type the + * `AnalyticsQuery` member of the same name declares: + * + * | member | `DatasetSelection` | `AnalyticsQuery` | + * |:---|:---|:---| + * | `dimensions` | `string[]?` | `string[]?` | + * | `measures` | `string[]` | `string[]` | + * | `timeDimensions` | `AnalyticsQuery['timeDimensions']` — declared BY REFERENCE | itself | + * | `order` | `Record?` | same | + * | `limit` / `offset` | `number?` | same | + * | `timezone` | `string?` | same | + * + * So parsing those seven against `AnalyticsQuerySchema.pick(…)` enforces the + * contract `DatasetSelection` already declares — a pull-back onto published + * text, never a narrowing past it. The four dataset-only members + * (`runtimeFilter`, `dateGranularity`, `compareTo`, `totals`) are PROJECTED + * AWAY before the parse, deliberately: `.pick()` carries `.strict()` through, + * so handing the raw selection to the picked schema would reject them. + * + * ⚠️ Those four therefore still have no door. `DatasetSelection` is a + * TypeScript interface with no Zod schema anywhere in the repo, and authoring + * one belongs in `packages/spec` beside the interface (Prime Directive #1), + * not here in a consumer — a second declaration of a spec-owned wire shape is + * the dialect Prime Directive #12 exists to prevent. Filed separately; this + * module is deliberately the derivable half. + * + * ## The refusal shapes, and why the date-range code is not spelled here + * + * Two answers, matching the family: + * + * - Every issue is the closed-vocabulary `timeDimensions[].dateRange` refusal + * ⇒ `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, the ADR-0112 envelope the + * sibling door answers for the identical condition. All-or-nothing, exactly + * as `assertAnalyticsQueryBody` lifts it: a body wrong in several places + * stays the generic failure, whose per-field carrier is the right one there. + * - Anything else ⇒ `400 VALIDATION_FAILED` + `details.fields[]`, the shape + * this route's two neighbouring hand-built doors already answer with. + * + * ⛔ The date-range code is read off {@link analyticsDateRangeUnrecognizedError} + * — `@objectstack/core`'s ONE constructor for this refusal — and never spelled + * as a literal in this package. Two reasons, and both are load-bearing: + * ADR-0112 D3 registers the code under `@objectstack/runtime` (the door that + * names the wire vocabulary) with a recorded provenance waiver for `core`'s + * shared constructor, so a literal here would be a stamp site under an owner + * key that does not list it; and the #5240 convention wants one condition to + * keep one wording, which a second spelling quietly ends. That constructor's + * own TSDoc names this route as the caller it was waiting for. + * + * The `message` is built the way the sibling builds it — `: ` + * joined — over `zodIssuesToFields`, the one ADR-0114 D3 mapper. Field paths + * are prefixed `selection.` because they are reported against the REQUEST + * body, where the parsed object sits one level down. + * + * Validation-only: the caller's `selection` is forwarded to the service + * untouched, never the parse output — the rule `assertAnalyticsQueryBody` + * records for the same reason (a default someone adds to the schema later must + * not silently override the engine's own resolution chain). + */ + +import { zodIssuesToFields } from '@objectstack/spec/api'; +import { analyticsDateRangeUnrecognizedError } from '@objectstack/core'; + +/** + * The `DatasetSelection` members whose declared type IS the `AnalyticsQuery` + * member of the same name — the projection this door parses. + * + * ⛔ Adding a member here is a claim about the two declarations agreeing: + * check `DatasetSelection` in `spec/contracts/analytics-service.ts` against + * `AnalyticsQuerySchema` in `spec/data/analytics.zod.ts` first. A member that + * only LOOKS alike (`runtimeFilter` vs `where` — same `FilterCondition`, a + * different key on each side) does not belong: this list is what makes the + * parse a pull-back rather than a new contract. `.pick()` is type-checked + * against the schema, so a member that leaves `AnalyticsQuery` fails the + * build here rather than silently dropping out of coverage. + */ +export const SELECTION_MEMBERS_SHARED_WITH_ANALYTICS_QUERY = [ + 'dimensions', + 'measures', + 'timeDimensions', + 'order', + 'limit', + 'offset', + 'timezone', +] as const; + +/** A door refusal, ready for `res.status(...).json(...)`. */ +export interface DatasetSelectionRefusal { + status: number; + body: Record; +} + +/** + * Built on first use and memoised — `@objectstack/spec/data` stays off this + * module's init path, the same lazy `await import` the analytics route already + * performs for `DatasetSchema`. + */ +let sharedSelectionSchema: { safeParse(input: unknown): any } | undefined; + +async function getSharedSelectionSchema(): Promise<{ safeParse(input: unknown): any }> { + if (!sharedSelectionSchema) { + const { AnalyticsQuerySchema } = await import('@objectstack/spec/data'); + sharedSelectionSchema = (AnalyticsQuerySchema as any).pick({ + dimensions: true, + measures: true, + timeDimensions: true, + order: true, + limit: true, + offset: true, + timezone: true, + }); + } + return sharedSelectionSchema!; +} + +/** + * Parse the shared members of a dataset `selection` and describe the refusal, + * or `undefined` when the selection passes. + * + * A non-object `selection` answers `undefined`: the route's own check ahead of + * this one (`selection.measures` must be a non-empty array) already owns that + * case and answers it with a message naming the member, which is the better + * sentence for by far the most common mistake. This function is about the + * members that had no door at all. + */ +export async function datasetSelectionRefusal( + selection: unknown, +): Promise { + if (!selection || typeof selection !== 'object' || Array.isArray(selection)) return undefined; + + const source = selection as Record; + const projection: Record = {}; + for (const member of SELECTION_MEMBERS_SHARED_WITH_ANALYTICS_QUERY) { + if (member in source) projection[member] = source[member]; + } + + const schema = await getSharedSelectionSchema(); + const parsed = schema.safeParse(projection); + if (parsed.success) return undefined; + + const issues: Array<{ code: string; path: ReadonlyArray; input?: unknown }> = + parsed.error.issues; + const fields = zodIssuesToFields(issues, projection).map((entry) => ({ + ...entry, + field: `selection.${entry.field}`, + })); + const message = `Invalid dataset selection: ${fields + .map((f) => `${f.field}: ${f.message}`) + .join('; ')}`; + + const { isAnalyticsDateRangeRefusalIssue } = await import('@objectstack/spec/data'); + if (issues.length > 0 && issues.every((issue) => isAnalyticsDateRangeRefusalIssue(issue))) { + // The code and status come from the platform's one constructor for this + // condition; only the sentence is this door's, and it is the family's + // `: ` form over the schema's own prescription. + const declared = analyticsDateRangeUnrecognizedError(issues[0]?.input) as Error & { + code?: string; + status?: number; + }; + return { status: declared.status ?? 400, body: { code: declared.code, message } }; + } + + return { status: 400, body: { code: 'VALIDATION_FAILED', message, details: { fields } } }; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a858fef3a49..efdd46f79a9 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -304,6 +304,10 @@ import { runImport } from './import-runner.js'; // [#16581] The public picker's authoring-dialect → parser-grammar lowering. import { lowerViewFilterRules } from './view-filter-rule-lowering.js'; import { prepareImportRequest } from './import-prepare.js'; +// [#17058] The `POST …/analytics/dataset/query` door parse — the half of the +// analytics family this route never had. See the module header for the +// measurement that decides its shape. +import { datasetSelectionRefusal } from './analytics-selection-door.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js'; import { buildBuiltinPaths } from './openapi-builtin-paths.js'; @@ -10920,6 +10924,29 @@ export class RestServer { }); } + // [#17058] …and every OTHER member of `selection` had no + // door at all, so a malformed one travelled into + // `dataset-executor` and was answered by whatever the face + // behind it happened to do with it — while the sibling + // routes (`/analytics/query`, `/analytics/sql`) lift the + // identical failure to a 400 at the entry. One family, two + // postures, decided by which door the client knocked on. + // + // The parse is a PROJECTION, never the siblings' schema: + // `selection` is a `DatasetSelection`, which is NOT the + // `AnalyticsQuery` the siblings parse — it carries no + // `cube` and has four members of its own, so the sibling + // schema would 400 every real dashboard widget. + // {@link datasetSelectionRefusal} carries that measurement + // and the reason those four are deliberately left out. + // + // Validation-only: the caller's `selection` is what reaches + // `queryDataset` below, never a parse output. + const selectionRefusal = await datasetSelectionRefusal(selection); + if (selectionRefusal) { + return res.status(selectionRefusal.status).json(selectionRefusal.body); + } + // ADR-0037 P3 — draft data preview: the canvas / preview // pages pass the flag so (a) the dataset lookup sees // draft-overlaid definitions and (b) the selection runs