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
25 changes: 25 additions & 0 deletions .changeset/27505-analytics-empty-cube.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/spec': minor
'@objectstack/service-analytics': minor
'@objectstack/runtime': minor
---

fix(spec)!: `POST /analytics/query` and `POST /analytics/sql` refuse an empty cube name with 400 VALIDATION_FAILED, not 500, and the engine's in-process guard throws the same typed refusal (#27505)

Clause-②: no (narrowing) — `AnalyticsQueryRequestSchema.cube`, the body of the two analytics query doors, refuses the empty string, which it admitted before as "a string". Nothing is widened and no key is added, removed or renamed.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` names a cube only by a real name (`skills/objectstack-ui/SKILL.md`, `rules/dashboards.md` and the `analytics-inline-vs-dataset` eval); no file teaches an empty `cube`, and every taught use stays true.

<!-- adr-0087: registered analytics-query-empty-cube-name-refused -->

**BREAKING** accept-set narrowing at the analytics query doors (`@objectstack/spec`), and the error shape of the engine's own guard (`@objectstack/service-analytics`).

- **Before (`@objectstack/spec@18.2.0`, the last release).** `cube` was `z.string()`, so `{ "cube": "", "measures": ["x.count"] }` passed the body schema at `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql`, reached the engine, and the engine's guard threw a plain `Error` (`Cube name is required …`), which the door mapped to **500 `INTERNAL_ERROR`** — a client input mistake read as a server fault, paged operators and got retried. The same body with `cube` left out answered 400.
- **Now.** `cube` is `z.string({ error: … }).min(1, …)` (JSON Schema `minLength: 1`; the one sentence is the type-level `error` and the `.min(1)` message alike). An empty cube name is refused at the door, `400 VALIDATION_FAILED` with one `fields[]` entry `{ field: 'cube', code: 'min_length' }` reading the schema's prescription (`Name the cube to query: …`, exported as `ANALYTICS_CUBE_NAME_REQUIRED`), and the engine is never called. A missing `cube` keeps the 400 it already had, and now reads the same sentence (the member carries a type-level `error` beside its `.min(1)`) where it read Zod's `expected string, received undefined`; a present `cube` that is not a string reads the same sentence with code `invalid_type`. The field code is what tells the cases apart (`required` / `invalid_type` / `min_length`).
- **`@objectstack/runtime`.** The two doors now hand the body to the field mapper (`fieldsFromZodIssues(issues, body)`, as the `/data` doors do), so an ABSENT required key of the analytics body is reported with the ADR-0114 code `required` where it used to read Zod's `invalid_type` for the same body: `cube` left out, `measures` left out, and a request with no body at all (`field: '(body)'`). A present key of the wrong type still reads `invalid_type`. Status and `error.code` (`400 VALIDATION_FAILED`) are unchanged; only the per-field `fields[].code` moves.
- **`@objectstack/service-analytics`.** `AnalyticsService.query` and `generateSql` refuse an absent or empty `cube` with the same typed refusal — `code: 'VALIDATION_FAILED'`, `status: 400`, `name: 'ValidationError'`, the schema's own sentence as the message, and a `fields[]` entry at `cube` produced by running the value through the schema's `cube` member and the one D3 mapper, so its code is the door's for the same value (`required` absent, `min_length` empty, `invalid_type` for a `null`) — in place of the two plain `Error` throws, so a plugin, flow or test that holds the service reads the answer the door gives, and a boundary that serves the throw (`resolveThrownHttpError`, the dispatcher's error exits) answers 400 rather than 500.
- **Not covered.** `MemoryAnalyticsService` (`@objectstack/driver-memory`), the other `IAnalyticsService` implementation, keeps its plain `Error('Cube name is required')` for the same input in this release; this change does not touch it. At the HTTP doors the schema now refuses an empty cube before either implementation runs, so the difference reaches in-process callers only.
- **Unchanged.** A non-empty cube name parses and runs as before. A whitespace-only name is a name no cube carries and keeps its `404 CUBE_NOT_FOUND` from the engine; this change adds no trimming rule. Nothing is stored: a cube name travels in a request, never in stack metadata, so there is no row to rewrite.
- **Who sees it.** A dashboard, client or script that sends `cube: ""` — an unset selection or a blank form field — read a 500 before and reads a 400 with the field named now. A test that pinned the old plain-Error text (`Cube name is required`) pins the envelope instead.

**The remedy.** Name the cube to query: a cube `GET /analytics/meta` lists, or a registered object name. There is no mechanical rewrite: an empty string names no cube, so the choice is the caller's.
2 changes: 1 addition & 1 deletion content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const result = AnalyticsEndpoint.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **cube** | `string` | ✅ | Target cube name |
| **cube** | `string` | ✅ | Target cube name — the cube to query, as `GET /analytics/meta` lists them, or a registered object name (read as an auto-inferred cube). Required and non-empty: an empty string names no cube and is refused at the schema. |
| **measures** | `string[]` | ✅ | List of metrics to calculate |
| **dimensions** | `string[]` | optional | List of dimensions to group by |
| **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). |
Expand Down
160 changes: 160 additions & 0 deletions packages/runtime/src/analytics-empty-cube-refusal.test.ts
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.

/**
* `POST /analytics/query` and `POST /analytics/sql` refuse an EMPTY cube name
* at the door — `400 VALIDATION_FAILED`, a `fields[]` entry at `cube` — and
* the analytics service is never reached.
*
* The defect this pins shut: `AnalyticsQueryRequestSchema.cube` was a bare
* `z.string()`, so `{ cube: '' }` passed the door as "a string" and reached the
* engine, whose own guard threw a plain `Error`, which the dispatcher mapped
* to `500 INTERNAL_ERROR` — a client input mistake answered as a server fault,
* while the same body with `cube` left out answered 400. The accept set closes
* at the schema (`.min(1)`, one prescriptive sentence), so the assertions read
* the code, the status, the field entry and the "service not called" fact.
*
* Harness: the shape `analytics-daterange-refusal-envelope.test.ts` uses to
* drive the mounted route through `dispatcher-plugin`'s thrown-error exit.
*/

import { describe, it, expect } from 'vitest';
import { ApiErrorSchema, ANALYTICS_CUBE_NAME_REQUIRED } from '@objectstack/spec/api';

import { createDispatcherPlugin } from './dispatcher-plugin.js';

// [#21061] The analytics domain refuses an anonymous caller first (ADR-0056 D2),
// so this harness signs its caller in; only identity is stubbed.
const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } };

function makeFakeServer() {
const handlers: Record<string, (req: any, res: any) => any> = {};
const rec = (verb: string) => (path: string, handler: any) => {
handlers[`${verb} ${path}`] = handler;
};
return {
handlers,
server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') },
};
}

function makeCtx(fakeServer: any, calls: { query: unknown[]; sql: unknown[] }) {
const analytics = {
query: async (body: unknown) => { calls.query.push(body); return { rows: [], fields: [] }; },
getMeta: async () => [],
generateSql: async (body: unknown) => { calls.sql.push(body); return { sql: 'SELECT 1', params: [] }; },
};
const kernel = {
getService: (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
};
return {
getKernel: () => kernel,
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
environmentId: undefined,
logger: { info() {}, warn() {}, error() {}, debug() {} },
hook: () => {},
on: () => {},
} as any;
}

function makeRes() {
const res: any = {
statusCode: undefined as number | undefined,
body: undefined as any,
status(c: number) { res.statusCode = c; return res; },
header() { return res; },
json(b: any) { res.body = b; return res; },
};
return res;
}

type Door = '/analytics/query' | '/analytics/sql';

async function post(path: Door, body: unknown) {
const { server, handlers } = makeFakeServer();
const calls = { query: [] as unknown[], sql: [] as unknown[] };
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(makeCtx(server, calls));
const handler = handlers[`POST /api/v1${path}`];
expect(handler, `POST /api/v1${path} must be mounted`).toBeTypeOf('function');
const res = makeRes();
await handler({ body, query: {} }, res);
return { res, calls };
}

const serviceCalls = (calls: { query: unknown[]; sql: unknown[] }) => [...calls.query, ...calls.sql];

describe.each<Door>(['/analytics/query', '/analytics/sql'])('%s — an empty cube name', (door) => {
it('answers 400 VALIDATION_FAILED with a `cube` field entry, and never calls the service', async () => {
const { res, calls } = await post(door, { cube: '', measures: ['x.count'] });

expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error.httpStatus).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
// The per-field carrier the UI anchors on: the ADR-0114 catalog code for
// a too-short string, never Zod's `too_small`.
expect(res.body.error.details.fields).toEqual([
{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED },
]);
// The message locates the field and carries the schema's prescription.
expect(res.body.error.message).toContain('cube: Name the cube to query');
// The whole point: a client input mistake is no longer the engine's 500.
expect(serviceCalls(calls)).toEqual([]);
});

it('the envelope parses against ApiErrorSchema', async () => {
const { res } = await post(door, { cube: '', measures: ['x.count'] });
const parsed = ApiErrorSchema.safeParse(res.body.error);
expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true);
});

it('a missing cube keeps the 400 VALIDATION_FAILED it already had, now with the `required` field code', async () => {
const { res, calls } = await post(door, { measures: ['x.count'] });

expect(res.statusCode).toBe(400);
expect(res.body.error.httpStatus).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
expect(res.body.error.details.fields).toHaveLength(1);
// `required`, the ADR-0114 D3 code for an ABSENT key — the door hands
// the body to the mapper now; it used to answer Zod's `invalid_type`
// for the same body. The same code the engine's in-process guard
// answers (service-analytics `cube-name-refusal.ts`), so one condition
// reads one code on both sides; and the one prescription, carried by
// the member's type-level `error`, not Zod's generic words.
expect(res.body.error.details.fields[0]).toEqual({ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED });
expect(res.body.error.message).toContain('cube: Name the cube to query');
expect(serviceCalls(calls)).toEqual([]);
});

it('the `required` code reaches `measures` left out and a bodiless request too', async () => {
// The door hands the body to the mapper, so the D3 split reports a
// missing key as `required` (it used to read `invalid_type`): the two
// other cases the changeset names, pinned by field and code (their
// messages are Zod's own, no sentence of this change's).
const noMeasures = await post(door, { cube: 'orders' });
expect(noMeasures.res.statusCode).toBe(400);
expect(noMeasures.res.body.error.code).toBe('VALIDATION_FAILED');
expect(noMeasures.res.body.error.details.fields).toEqual([
expect.objectContaining({ field: 'measures', code: 'required' }),
]);

const noBody = await post(door, undefined);
expect(noBody.res.statusCode).toBe(400);
expect(noBody.res.body.error.code).toBe('VALIDATION_FAILED');
expect(noBody.res.body.error.details.fields).toEqual([
expect.objectContaining({ field: '(body)', code: 'required' }),
]);
expect(serviceCalls(noMeasures.calls)).toEqual([]);
expect(serviceCalls(noBody.calls)).toEqual([]);
});

it('preservation: a named cube reaches the service with the body untouched', async () => {
const body = { cube: 'orders', measures: ['orders.count'] };
const { res, calls } = await post(door, body);

expect(res.statusCode ?? 200).toBe(200);
expect(res.body.success).toBe(true);
expect(serviceCalls(calls)).toEqual([body]);
});
});
7 changes: 6 additions & 1 deletion packages/runtime/src/domains/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ function assertAnalyticsQueryBody(body: unknown): void {
}
const parsed = AnalyticsQueryRequestSchema.safeParse(body);
if (!parsed.success) {
const fields = fieldsFromZodIssues(parsed.error.issues);
// The body rides along so the ADR-0114 D3 split fires: an ABSENT
// required key (`cube` left out) is reported as `required`, not the
// `invalid_type` Zod spells it as — the code the engine's own guard
// answers for the same input in-process (`cube-name-refusal.ts`,
// service-analytics), so one condition reads one code on both sides.
const fields = fieldsFromZodIssues(parsed.error.issues, body);
const message = `Invalid AnalyticsQuery body: ${fields.map((f) => `${f.field}: ${f.message}`).join('; ')}`;
// [#16041] The closed-vocabulary refusal of `timeDimensions[].dateRange`
// answers its own registered code, `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Cube } from '@objectstack/spec/data';
import { ANALYTICS_CUBE_NAME_REQUIRED } from '@objectstack/spec/api';
import type { AnalyticsQuery, AnalyticsResult, AnalyticsStrategy, IAnalyticsService } from '@objectstack/spec/contracts';
import { AnalyticsService } from '../analytics-service.js';
import { CubeRegistry } from '../cube-registry.js';
Expand Down Expand Up @@ -478,9 +479,62 @@ describe('AnalyticsService', () => {
await expect(service.query(baseQuery)).rejects.toThrow('No strategy can handle');
});

it('should throw when cube name is missing', async () => {
const service = new AnalyticsService({ logger: silentLogger });
await expect(service.query({ measures: ['count'] })).rejects.toThrow('Cube name is required');
// A query that names no cube is refused with the typed `VALIDATION_FAILED` /
// 400 the HTTP doors answer for the same body — not a plain Error, which
// every boundary maps to a 500 (`cube-name-refusal.ts`). The message is the
// schema's own sentence (`ANALYTICS_CUBE_NAME_REQUIRED`); the `fields[]`
// entry is the door's answer for the same value, produced by the one D3
// mapper. The pins read the envelope (`code` + `status`), the field entry
// and the sentence's head.
describe('a query that names no cube is the typed VALIDATION_FAILED refusal, in-process', () => {
const refused = { code: 'VALIDATION_FAILED', status: 400, name: 'ValidationError', message: ANALYTICS_CUBE_NAME_REQUIRED };
const missing = { ...refused, fields: [{ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }] };
const empty = { ...refused, fields: [{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }] };

it('query(): a missing cube', async () => {
const service = new AnalyticsService({ logger: silentLogger });
await expect(service.query({ measures: ['count'] })).rejects.toMatchObject(missing);
});

it('query(): an empty cube name', async () => {
const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger });
await expect(service.query({ cube: '', measures: ['count'] })).rejects.toMatchObject(empty);
});

it('generateSql(): a missing cube and an empty cube name', async () => {
const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger });
await expect(service.generateSql({ measures: ['count'] })).rejects.toMatchObject(missing);
await expect(service.generateSql({ cube: '', measures: ['count'] })).rejects.toMatchObject(empty);
});

it('a null cube from an untyped caller reads as the door reads it: invalid_type, not required', async () => {
const service = new AnalyticsService({ logger: silentLogger });
await expect(service.query({ cube: null as unknown as string, measures: ['count'] })).rejects.toMatchObject({
...refused,
fields: [{ field: 'cube', code: 'invalid_type', message: ANALYTICS_CUBE_NAME_REQUIRED }],
});
});

it('the refusal reads the prescription first', async () => {
const service = new AnalyticsService({ logger: silentLogger });
await expect(service.query({ cube: '', measures: ['count'] })).rejects.toMatchObject({
message: expect.stringMatching(/^Name the cube to query/),
});
});

it('a whitespace-only name is a NAME, so it passes this guard: whatever refuses it later, it is not the empty-name refusal', async () => {
// The composed runtime answers it CUBE_NOT_FOUND / 404 from cube
// resolution; this harness has no object registry to resolve against, so
// only the guard's half is pinned here: the rule is minLength 1, no trim.
const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger });
const err: unknown = await service.query({ cube: ' ', measures: ['count'] }).then(
() => { throw new Error('expected a rejection'); },
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect(err).not.toMatchObject({ code: 'VALIDATION_FAILED' });
expect(err).not.toMatchObject({ message: expect.stringMatching(/^Name the cube to query/) });
});
});

it('should return cube metadata via getMeta()', async () => {
Expand Down
Loading
Loading