Skip to content

Commit e0aed00

Browse files
fix(spec)!: analytics query doors refuse an empty cube name with 400 VALIDATION_FAILED, not 500 (#27691)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiEosMC3zDfHaauURHFNq1GJ)_ Fixes #27505 Clause-②: no (narrowing) Metadata-protocol: narrowed Fable review: owed — the account's Fable quota was exhausted at 2026-10-29 ~17:05Z; the seat runs it when the quota returns. ## 维护者速读 - **改了什么**:analytics 查询请求体的 `cube` 改为必填且非空(`.min(1)`,一句带修法的拒收文案);`POST /analytics/query`、`/analytics/sql` 收到空 cube 名时在入口直接答 400 `VALIDATION_FAILED`,不再进引擎;service-analytics 里 `query` / `generateSql` 的两处裸 `Error` 改为同一个类型化拒收(400 `VALIDATION_FAILED`),进程内调用方拿到与 HTTP 入口相同的答案。 - **为什么改**:空 cube 名原先答 500 `INTERNAL_ERROR`,监控和调用方都当成服务端故障,会告警、会重试;同样的错误把键省掉却答 400。 - **风险与回滚**:这是一次收窄(BREAKING,changeset 为 minor,ADR-0087 已登记语义迁移条目);只影响发送 `cube: ""` 的调用方。附带一处可见变化:analytics 入口对缺失必填键的 `fields[].code` 从 `invalid_type` 改为 ADR-0114 的 `required`(状态码与 `error.code` 不变)。回滚 = revert 本 PR。 - **先看**:① `packages/spec/src/api/analytics.zod.ts` 的 `cube` 成员;② `packages/services/service-analytics/src/cube-name-refusal.ts`;③ `packages/runtime/src/domains/analytics.ts` 中把 body 传给 `fieldsFromZodIssues` 的那一行。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/analytics-empty-cube-refusal.test.ts`,预期 `Tests 10 passed (10)`:两个入口对空 cube 都答 400 `VALIDATION_FAILED`,且 service 未被调用。 - **需要你做的**:无,席位落地(落地前席位补 Fable 复核与 `## Contract review` 记录)。 ## What changes - **`@objectstack/spec`** — `AnalyticsQueryRequestSchema.cube` becomes `z.string({ error: ANALYTICS_CUBE_NAME_REQUIRED }).min(1, ANALYTICS_CUBE_NAME_REQUIRED)` (JSON Schema `minLength: 1`). `ANALYTICS_CUBE_NAME_REQUIRED` is a new export: one prescriptive sentence ("Name the cube to query: …") for every refusal of the slot (absent, non-string, empty). ADR-0087 disposition: a semantic migration entry, `analytics-query-empty-cube-name-refused` (no D2 conversion: a cube name is a request value, never stored metadata). Regenerated: `api-surface/api.json`, `export-origins/api.json`, `content/docs/references/api/analytics.mdx`. - **`@objectstack/service-analytics`** — the two plain `Error('Cube name is required …')` throws (`queryIn`, `generateSql`) become `cubeNameRequiredError(cube)`: `validationFailure` from `@objectstack/types` with `status: 400`, the schema's sentence as message, and a `fields[]` entry built by running the value through the schema's own `cube` member and the one ADR-0114 D3 mapper, so the field code is the door's for the same value (`required` / `min_length` / `invalid_type`). - **`@objectstack/runtime`** — the analytics door now passes the body to `fieldsFromZodIssues(issues, body)` (as the `/data` doors do), so an absent required key reads `required` instead of Zod's `invalid_type`. This is the one line outside the claim's declared file surface; see Acceptance notes. - **Whitespace-only `" "`**: deliberately unchanged. `.min(1)` does not cover it and no trim rule is added; it is a name no cube carries and keeps its `404 CUBE_NOT_FOUND` from cube resolution. Pinned on both the schema side and the service guard side. ## Tests - `packages/spec/src/api/analytics.test.ts`: `cube: ""` gives one `too_small` issue at `cube` reading the prescription; a missing cube keeps `invalid_type` with the same sentence; `"o"` and `" "` still parse; JSON Schema carries `minLength: 1`. - `packages/runtime/src/analytics-empty-cube-refusal.test.ts` (new), both doors: `cube: ""` gives `400`, `error.code: VALIDATION_FAILED`, `fields: [{ field: 'cube', code: 'min_length' }]`, and the service is never called; the envelope parses against `ApiErrorSchema`; missing `cube` / missing `measures` / no body keep their 400 with code `required`; a named cube reaches the service with the body untouched. - `packages/services/service-analytics/src/__tests__/analytics-service.test.ts`: in-process `query()` / `generateSql()` with `''` or no cube reject with `{ code: 'VALIDATION_FAILED', status: 400, name: 'ValidationError' }` and the door's field entry; `null` reads `invalid_type`; whitespace is not this refusal. The old `toThrow('Cube name is required')` pin is replaced (pin sweep: no other repo pin on either old message outside `driver-memory`, which is out of scope, see below). <details><summary>Package runs at head 427f5e9</summary> - `pnpm --filter @objectstack/spec typecheck` exit 0; `… service-analytics typecheck` exit 0; `… runtime typecheck` exit 0. - `pnpm --filter @objectstack/service-analytics test`: Test Files 196 passed (196), Tests 4739 passed, 282 skipped. - `pnpm --filter @objectstack/spec test`: Test Files 752 passed (752), Tests 22349 passed, 1 todo. - `pnpm --filter @objectstack/runtime test`: Test Files 478 passed (478), Tests 7794 passed, 19 skipped. </details> <details><summary>Ablation at head 427f5e9 (one-off, via scripts/ablation-replace.mjs, restored to the HEAD blob)</summary> - Baseline with the fix: spec `analytics.test.ts` 38/38, runtime `analytics-empty-cube-refusal.test.ts` 10/10, service `analytics-service.test.ts` 46/46. - Leg A, `.min(1, ANALYTICS_CUBE_NAME_REQUIRED)` deleted from the schema (anchor 1 → 0, blob 73d6676 → a99fae0ade; spec and runtime read spec source through the alias): spec 2 failed / 36 passed (the empty-cube refusal and the `minLength` pin); runtime 4 failed / 6 passed (the empty-cube refusal and the envelope pin, on both doors). Missing-cube pins stay green, as expected: the type-level `error` is untouched. Restored: blob == HEAD, `git diff HEAD` empty. - Leg B, both `throw cubeNameRequiredError(queryInput.cube);` replaced with `throw new Error('Cube name is required');` (anchor 2 → 0, blob 34d1d84 → 2ea1302a68): service 5 failed / 41 passed (every typed-refusal pin; the whitespace pin stays green as expected). Restored: blob == HEAD, `git diff HEAD` empty. </details> ## Acceptance notes - **File surface.** The claim declared spec, the regenerated artifacts, `analytics-service.ts`, tests beside, and changesets. The diff also touches `packages/runtime/src/domains/analytics.ts` (one line: hand the body to the D3 mapper) and adds `packages/runtime/src/analytics-empty-cube-refusal.test.ts`. Reason: without the body, the door answers an absent `cube` with `invalid_type` and the in-process guard answers `required`, so the same condition would get two codes. As a result an absent `measures` and a request with no body now read `required` too. Status and `error.code` do not change. The seat decides whether that line stays. - **Changeset** moves `@objectstack/runtime` (minor) besides the two packages the order names, for the field-code change above. - **Not covered:** `MemoryAnalyticsService` (`@objectstack/driver-memory`) keeps its own plain `Error('Cube name is required')` for the same input; noted in the changeset, not changed here. ## Verification - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands at 427f5e9. All were run, with each exit code recorded before any pipe, 109 exited 0 on the sweep. `pnpm check:type-check-debt` exited 3 there (PREREQUISITE NOT MET: it did not get the verify lock while this branch's dogfood run held it) and exited 0 when re-run under the lock (`0 ledger entries re-measured … none above its recorded number`). `--ran` reconciliation: 110 derived, 110 run, 0 NOT-MEASURED (a derived zero: every entry carries its exit code). - Dogfood battery (the `(narrowing)` arm), head 427f5e9: `pnpm --filter @objectstack/dogfood test` gives Test Files 351 passed, 3 skipped (354); Tests 2825 passed, 20 skipped (2845); exit 0. - CI check-runs: see this PR's checks. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81d00a4 commit e0aed00

12 files changed

Lines changed: 460 additions & 8 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
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)
8+
9+
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.
10+
Metadata-protocol: narrowed
11+
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.
12+
13+
<!-- adr-0087: registered analytics-query-empty-cube-name-refused -->
14+
15+
**BREAKING** accept-set narrowing at the analytics query doors (`@objectstack/spec`), and the error shape of the engine's own guard (`@objectstack/service-analytics`).
16+
17+
- **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.
18+
- **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`).
19+
- **`@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.
20+
- **`@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.
21+
- **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.
22+
- **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.
23+
- **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.
24+
25+
**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.

‎content/docs/references/api/analytics.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ const result = AnalyticsEndpoint.parse(data);
9191

9292
| Property | Type | Required | Description |
9393
| :--- | :--- | :--- | :--- |
94-
| **cube** | `string` | ✅ | Target cube name |
94+
| **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. |
9595
| **measures** | `string[]` | ✅ | List of metrics to calculate |
9696
| **dimensions** | `string[]` | optional | List of dimensions to group by |
9797
| **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`). |
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `POST /analytics/query` and `POST /analytics/sql` refuse an EMPTY cube name
5+
* at the door — `400 VALIDATION_FAILED`, a `fields[]` entry at `cube` — and
6+
* the analytics service is never reached.
7+
*
8+
* The defect this pins shut: `AnalyticsQueryRequestSchema.cube` was a bare
9+
* `z.string()`, so `{ cube: '' }` passed the door as "a string" and reached the
10+
* engine, whose own guard threw a plain `Error`, which the dispatcher mapped
11+
* to `500 INTERNAL_ERROR` — a client input mistake answered as a server fault,
12+
* while the same body with `cube` left out answered 400. The accept set closes
13+
* at the schema (`.min(1)`, one prescriptive sentence), so the assertions read
14+
* the code, the status, the field entry and the "service not called" fact.
15+
*
16+
* Harness: the shape `analytics-daterange-refusal-envelope.test.ts` uses to
17+
* drive the mounted route through `dispatcher-plugin`'s thrown-error exit.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { ApiErrorSchema, ANALYTICS_CUBE_NAME_REQUIRED } from '@objectstack/spec/api';
22+
23+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
24+
25+
// [#21061] The analytics domain refuses an anonymous caller first (ADR-0056 D2),
26+
// so this harness signs its caller in; only identity is stubbed.
27+
const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } };
28+
29+
function makeFakeServer() {
30+
const handlers: Record<string, (req: any, res: any) => any> = {};
31+
const rec = (verb: string) => (path: string, handler: any) => {
32+
handlers[`${verb} ${path}`] = handler;
33+
};
34+
return {
35+
handlers,
36+
server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') },
37+
};
38+
}
39+
40+
function makeCtx(fakeServer: any, calls: { query: unknown[]; sql: unknown[] }) {
41+
const analytics = {
42+
query: async (body: unknown) => { calls.query.push(body); return { rows: [], fields: [] }; },
43+
getMeta: async () => [],
44+
generateSql: async (body: unknown) => { calls.sql.push(body); return { sql: 'SELECT 1', params: [] }; },
45+
};
46+
const kernel = {
47+
getService: (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
48+
getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined),
49+
};
50+
return {
51+
getKernel: () => kernel,
52+
getService: (name: string) => (name === 'http.server' ? fakeServer : undefined),
53+
environmentId: undefined,
54+
logger: { info() {}, warn() {}, error() {}, debug() {} },
55+
hook: () => {},
56+
on: () => {},
57+
} as any;
58+
}
59+
60+
function makeRes() {
61+
const res: any = {
62+
statusCode: undefined as number | undefined,
63+
body: undefined as any,
64+
status(c: number) { res.statusCode = c; return res; },
65+
header() { return res; },
66+
json(b: any) { res.body = b; return res; },
67+
};
68+
return res;
69+
}
70+
71+
type Door = '/analytics/query' | '/analytics/sql';
72+
73+
async function post(path: Door, body: unknown) {
74+
const { server, handlers } = makeFakeServer();
75+
const calls = { query: [] as unknown[], sql: [] as unknown[] };
76+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
77+
await plugin.start?.(makeCtx(server, calls));
78+
const handler = handlers[`POST /api/v1${path}`];
79+
expect(handler, `POST /api/v1${path} must be mounted`).toBeTypeOf('function');
80+
const res = makeRes();
81+
await handler({ body, query: {} }, res);
82+
return { res, calls };
83+
}
84+
85+
const serviceCalls = (calls: { query: unknown[]; sql: unknown[] }) => [...calls.query, ...calls.sql];
86+
87+
describe.each<Door>(['/analytics/query', '/analytics/sql'])('%s — an empty cube name', (door) => {
88+
it('answers 400 VALIDATION_FAILED with a `cube` field entry, and never calls the service', async () => {
89+
const { res, calls } = await post(door, { cube: '', measures: ['x.count'] });
90+
91+
expect(res.statusCode).toBe(400);
92+
expect(res.body.success).toBe(false);
93+
expect(res.body.error.httpStatus).toBe(400);
94+
expect(res.body.error.code).toBe('VALIDATION_FAILED');
95+
// The per-field carrier the UI anchors on: the ADR-0114 catalog code for
96+
// a too-short string, never Zod's `too_small`.
97+
expect(res.body.error.details.fields).toEqual([
98+
{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED },
99+
]);
100+
// The message locates the field and carries the schema's prescription.
101+
expect(res.body.error.message).toContain('cube: Name the cube to query');
102+
// The whole point: a client input mistake is no longer the engine's 500.
103+
expect(serviceCalls(calls)).toEqual([]);
104+
});
105+
106+
it('the envelope parses against ApiErrorSchema', async () => {
107+
const { res } = await post(door, { cube: '', measures: ['x.count'] });
108+
const parsed = ApiErrorSchema.safeParse(res.body.error);
109+
expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true);
110+
});
111+
112+
it('a missing cube keeps the 400 VALIDATION_FAILED it already had, now with the `required` field code', async () => {
113+
const { res, calls } = await post(door, { measures: ['x.count'] });
114+
115+
expect(res.statusCode).toBe(400);
116+
expect(res.body.error.httpStatus).toBe(400);
117+
expect(res.body.error.code).toBe('VALIDATION_FAILED');
118+
expect(res.body.error.details.fields).toHaveLength(1);
119+
// `required`, the ADR-0114 D3 code for an ABSENT key — the door hands
120+
// the body to the mapper now; it used to answer Zod's `invalid_type`
121+
// for the same body. The same code the engine's in-process guard
122+
// answers (service-analytics `cube-name-refusal.ts`), so one condition
123+
// reads one code on both sides; and the one prescription, carried by
124+
// the member's type-level `error`, not Zod's generic words.
125+
expect(res.body.error.details.fields[0]).toEqual({ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED });
126+
expect(res.body.error.message).toContain('cube: Name the cube to query');
127+
expect(serviceCalls(calls)).toEqual([]);
128+
});
129+
130+
it('the `required` code reaches `measures` left out and a bodiless request too', async () => {
131+
// The door hands the body to the mapper, so the D3 split reports a
132+
// missing key as `required` (it used to read `invalid_type`): the two
133+
// other cases the changeset names, pinned by field and code (their
134+
// messages are Zod's own, no sentence of this change's).
135+
const noMeasures = await post(door, { cube: 'orders' });
136+
expect(noMeasures.res.statusCode).toBe(400);
137+
expect(noMeasures.res.body.error.code).toBe('VALIDATION_FAILED');
138+
expect(noMeasures.res.body.error.details.fields).toEqual([
139+
expect.objectContaining({ field: 'measures', code: 'required' }),
140+
]);
141+
142+
const noBody = await post(door, undefined);
143+
expect(noBody.res.statusCode).toBe(400);
144+
expect(noBody.res.body.error.code).toBe('VALIDATION_FAILED');
145+
expect(noBody.res.body.error.details.fields).toEqual([
146+
expect.objectContaining({ field: '(body)', code: 'required' }),
147+
]);
148+
expect(serviceCalls(noMeasures.calls)).toEqual([]);
149+
expect(serviceCalls(noBody.calls)).toEqual([]);
150+
});
151+
152+
it('preservation: a named cube reaches the service with the body untouched', async () => {
153+
const body = { cube: 'orders', measures: ['orders.count'] };
154+
const { res, calls } = await post(door, body);
155+
156+
expect(res.statusCode ?? 200).toBe(200);
157+
expect(res.body.success).toBe(true);
158+
expect(serviceCalls(calls)).toEqual([body]);
159+
});
160+
});

‎packages/runtime/src/domains/analytics.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ function assertAnalyticsQueryBody(body: unknown): void {
6969
}
7070
const parsed = AnalyticsQueryRequestSchema.safeParse(body);
7171
if (!parsed.success) {
72-
const fields = fieldsFromZodIssues(parsed.error.issues);
72+
// The body rides along so the ADR-0114 D3 split fires: an ABSENT
73+
// required key (`cube` left out) is reported as `required`, not the
74+
// `invalid_type` Zod spells it as — the code the engine's own guard
75+
// answers for the same input in-process (`cube-name-refusal.ts`,
76+
// service-analytics), so one condition reads one code on both sides.
77+
const fields = fieldsFromZodIssues(parsed.error.issues, body);
7378
const message = `Invalid AnalyticsQuery body: ${fields.map((f) => `${f.field}: ${f.message}`).join('; ')}`;
7479
// [#16041] The closed-vocabulary refusal of `timeDimensions[].dateRange`
7580
// answers its own registered code, `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`

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

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

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

481-
it('should throw when cube name is missing', async () => {
482-
const service = new AnalyticsService({ logger: silentLogger });
483-
await expect(service.query({ measures: ['count'] })).rejects.toThrow('Cube name is required');
482+
// A query that names no cube is refused with the typed `VALIDATION_FAILED` /
483+
// 400 the HTTP doors answer for the same body — not a plain Error, which
484+
// every boundary maps to a 500 (`cube-name-refusal.ts`). The message is the
485+
// schema's own sentence (`ANALYTICS_CUBE_NAME_REQUIRED`); the `fields[]`
486+
// entry is the door's answer for the same value, produced by the one D3
487+
// mapper. The pins read the envelope (`code` + `status`), the field entry
488+
// and the sentence's head.
489+
describe('a query that names no cube is the typed VALIDATION_FAILED refusal, in-process', () => {
490+
const refused = { code: 'VALIDATION_FAILED', status: 400, name: 'ValidationError', message: ANALYTICS_CUBE_NAME_REQUIRED };
491+
const missing = { ...refused, fields: [{ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }] };
492+
const empty = { ...refused, fields: [{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }] };
493+
494+
it('query(): a missing cube', async () => {
495+
const service = new AnalyticsService({ logger: silentLogger });
496+
await expect(service.query({ measures: ['count'] })).rejects.toMatchObject(missing);
497+
});
498+
499+
it('query(): an empty cube name', async () => {
500+
const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger });
501+
await expect(service.query({ cube: '', measures: ['count'] })).rejects.toMatchObject(empty);
502+
});
503+
504+
it('generateSql(): a missing cube and an empty cube name', async () => {
505+
const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger });
506+
await expect(service.generateSql({ measures: ['count'] })).rejects.toMatchObject(missing);
507+
await expect(service.generateSql({ cube: '', measures: ['count'] })).rejects.toMatchObject(empty);
508+
});
509+
510+
it('a null cube from an untyped caller reads as the door reads it: invalid_type, not required', async () => {
511+
const service = new AnalyticsService({ logger: silentLogger });
512+
await expect(service.query({ cube: null as unknown as string, measures: ['count'] })).rejects.toMatchObject({
513+
...refused,
514+
fields: [{ field: 'cube', code: 'invalid_type', message: ANALYTICS_CUBE_NAME_REQUIRED }],
515+
});
516+
});
517+
518+
it('the refusal reads the prescription first', async () => {
519+
const service = new AnalyticsService({ logger: silentLogger });
520+
await expect(service.query({ cube: '', measures: ['count'] })).rejects.toMatchObject({
521+
message: expect.stringMatching(/^Name the cube to query/),
522+
});
523+
});
524+
525+
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 () => {
526+
// The composed runtime answers it CUBE_NOT_FOUND / 404 from cube
527+
// resolution; this harness has no object registry to resolve against, so
528+
// only the guard's half is pinned here: the rule is minLength 1, no trim.
529+
const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger });
530+
const err: unknown = await service.query({ cube: ' ', measures: ['count'] }).then(
531+
() => { throw new Error('expected a rejection'); },
532+
(e: unknown) => e,
533+
);
534+
expect(err).toBeInstanceOf(Error);
535+
expect(err).not.toMatchObject({ code: 'VALIDATION_FAILED' });
536+
expect(err).not.toMatchObject({ message: expect.stringMatching(/^Name the cube to query/) });
537+
});
484538
});
485539

486540
it('should return cube metadata via getMeta()', async () => {

0 commit comments

Comments
 (0)