From b117e9c5221e9e33981d3e26a61cb2e9be17b06e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:48:14 +0000 Subject: [PATCH 01/10] fix(spec)!: analytics query doors refuse an empty cube name with 400 VALIDATION_FAILED, not 500 AnalyticsQueryRequestSchema.cube takes .min(1) with one prescriptive sentence (ANALYTICS_CUBE_NAME_REQUIRED); the two plain Error throws in service-analytics (queryIn, generateSql) become the same typed refusal for in-process callers. Semantic migration entry registered; tests at the schema, the door and the service; changeset for both packages. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27505-analytics-empty-cube.md | 22 +++ .../src/analytics-empty-cube-refusal.test.ts | 130 ++++++++++++++++++ .../src/__tests__/analytics-service.test.ts | 55 +++++++- .../src/analytics-service.ts | 10 +- .../src/cube-name-refusal.ts | 48 +++++++ packages/spec/src/api/analytics.test.ts | 42 ++++++ packages/spec/src/api/analytics.zod.ts | 33 ++++- ...analytics-query-empty-cube-name-refused.ts | 45 ++++++ 8 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 .changeset/27505-analytics-empty-cube.md create mode 100644 packages/runtime/src/analytics-empty-cube-refusal.test.ts create mode 100644 packages/services/service-analytics/src/cube-name-refusal.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts diff --git a/.changeset/27505-analytics-empty-cube.md b/.changeset/27505-analytics-empty-cube.md new file mode 100644 index 00000000000..ffb6d65b780 --- /dev/null +++ b/.changeset/27505-analytics-empty-cube.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': 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. + + + +**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().min(1, …)` (JSON Schema `minLength: 1`). 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 (`code: 'required'`). +- **`@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, a `fields[]` entry at `cube` — 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. +- **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. diff --git a/packages/runtime/src/analytics-empty-cube-refusal.test.ts b/packages/runtime/src/analytics-empty-cube-refusal.test.ts new file mode 100644 index 00000000000..fc054cd2176 --- /dev/null +++ b/packages/runtime/src/analytics-empty-cube-refusal.test.ts @@ -0,0 +1,130 @@ +// 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 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(['/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 it already had, with the `required` field code', async () => { + const { res, calls } = await post(door, { measures: ['x.count'] }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(res.body.error.details.fields).toHaveLength(1); + expect(res.body.error.details.fields[0]).toMatchObject({ field: 'cube', code: 'required' }); + expect(serviceCalls(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]); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index 189a92d2692..4230b54f28f 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -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'; @@ -478,9 +479,57 @@ 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 sentence is the + // schema's own (`ANALYTICS_CUBE_NAME_REQUIRED`); the pins below 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' }; + + it('query(): a missing cube', async () => { + const service = new AnalyticsService({ logger: silentLogger }); + await expect(service.query({ measures: ['count'] })).rejects.toMatchObject({ + ...refused, + fields: [{ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }], + }); + }); + + it('query(): an empty cube name', async () => { + const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger }); + await expect(service.query({ cube: '', measures: ['count'] })).rejects.toMatchObject({ + ...refused, + fields: [{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }], + }); + }); + + 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({ + ...refused, + fields: [{ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }], + }); + await expect(service.generateSql({ cube: '', measures: ['count'] })).rejects.toMatchObject({ + ...refused, + fields: [{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }], + }); + }); + + it('the refusal reads the prescription first, and is the schema\'s sentence', async () => { + const service = new AnalyticsService({ logger: silentLogger }); + const err = await service.query({ cube: '', measures: ['count'] }).catch((e: unknown) => e as Error); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/^Name the cube to query/); + expect((err as Error).message).toBe(ANALYTICS_CUBE_NAME_REQUIRED); + }); + + it('a whitespace-only name is a NAME no cube carries: CUBE_NOT_FOUND / 404, unchanged', async () => { + const service = new AnalyticsService({ cubes: [ordersCube], logger: silentLogger }); + await expect(service.query({ cube: ' ', measures: ['count'] })).rejects.toMatchObject({ + code: 'CUBE_NOT_FOUND', + status: 404, + }); + }); }); it('should return cube metadata via getMeta()', async () => { diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 07dcbe8c144..34d1d846868 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -34,6 +34,7 @@ import { import { matchMissingColumnOfRelation } from '@objectstack/types'; import { CubeRegistry } from './cube-registry.js'; import { cubeNotFoundError, isCubePublic } from './cube-visibility.js'; +import { cubeNameRequiredError } from './cube-name-refusal.js'; // The object-level read admission asked at this door, ahead of every strategy // — the layer the raw-SQL path could not inherit from the engine. See that // module's header for the request that reached the database without it. @@ -2412,8 +2413,12 @@ export class AnalyticsService implements IAnalyticsService { queryInput: AnalyticsQuery, context?: ExecutionContext, ): Promise { + // A query that names no cube — `cube` absent or the empty string — is + // refused before any other gate, as the typed `VALIDATION_FAILED` / 400 + // the HTTP doors answer for the same body; a plain Error here read as a + // 500 to the caller (`cube-name-refusal.ts`). if (!queryInput.cube) { - throw new Error('Cube name is required in analytics query'); + throw cubeNameRequiredError(queryInput.cube); } // `analytics_cube.public` — first, ahead of token resolution, cube // inference, admission and every strategy: a hidden cube is refused @@ -3358,8 +3363,9 @@ export class AnalyticsService implements IAnalyticsService { * Generate SQL for a query without executing it (dry-run). */ async generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{ sql: string; params: unknown[] }> { + // Same refusal as `queryIn`: no cube named, no statement compiled. if (!queryInput.cube) { - throw new Error('Cube name is required for SQL generation'); + throw cubeNameRequiredError(queryInput.cube); } // Same gate as `query()`: the dry-run door must not hand out the // statement — the cube's measures, raw SQL included — of a hidden cube. diff --git a/packages/services/service-analytics/src/cube-name-refusal.ts b/packages/services/service-analytics/src/cube-name-refusal.ts new file mode 100644 index 00000000000..fceb9c410a0 --- /dev/null +++ b/packages/services/service-analytics/src/cube-name-refusal.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ANALYTICS_CUBE_NAME_REQUIRED, type FieldErrorCode } from '@objectstack/spec/api'; +import { validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types'; + +/** + * The ONE refusal for an analytics query that names no cube — `cube` absent or + * the empty string — thrown by `AnalyticsService.query` (through `queryIn`) and + * `AnalyticsService.generateSql` before any other gate runs. + * + * ## Why a typed refusal, and why its words are the schema's + * + * The HTTP doors (`POST /analytics/query`, `POST /analytics/sql`) parse the body + * with `AnalyticsQueryRequestSchema`, whose `cube` is `z.string().min(1, …)`: + * a missing or empty name is refused there, `400 VALIDATION_FAILED` with a + * `fields[]` entry at `cube`, and the engine is never reached. The engine's + * own guard stays for the callers that do not pass through that schema — a + * plugin, a flow, a test, anything that holds the service — and the base + * `AnalyticsQuery` type leaves `cube` optional for them. Until this module the + * guard threw a plain `Error`, which every boundary maps to a 500: a client + * input mistake read as a server fault, paged operators and got retried. + * + * The refusal is `validationFailure` from `@objectstack/types` — the shape both + * dispatcher error exits and `resolveThrownHttpError` recognise as + * `VALIDATION_FAILED` — with `status: 400` declared on it as this service's + * sibling refusals do (`cubeNotFoundError`, 404), so an in-process caller reads + * `{ code, status }` without a boundary to decide the status for it. Its + * sentence is `ANALYTICS_CUBE_NAME_REQUIRED`, imported from the schema that + * refuses the same input at the door: one rule, one wording, two readers. + * + * The `fields[]` entry carries the ADR-0114 catalog code the door would have + * produced for the same input — `required` for an absent `cube`, `min_length` + * for the empty string (Zod's `too_small` on a string, mapped by + * `zodIssuesToFields`) — so a UI anchoring per-field errors sees one vocabulary + * whichever side refused. + * + * ⛔ A whitespace-only name is NOT refused here: it is a name, one no cube + * carries, and the engine's cube resolution answers it `CUBE_NOT_FOUND` / 404. + * Refusing it would be a second rule beside the schema's `.min(1)`. + */ +export function cubeNameRequiredError(cube: string | undefined): Error & { code: string; status: number } { + const code: FieldErrorCode = cube === undefined ? 'required' : 'min_length'; + const err = validationFailure(ANALYTICS_CUBE_NAME_REQUIRED, [ + { field: 'cube', code, message: ANALYTICS_CUBE_NAME_REQUIRED }, + ]) as Error & { code: string; status: number }; + err.status = VALIDATION_FAILED_STATUS; + return err; +} diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 7fd492fe06e..91c7e553750 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { + ANALYTICS_CUBE_NAME_REQUIRED, AnalyticsEndpoint, AnalyticsQueryRequestSchema, AnalyticsResultResponseSchema, @@ -98,6 +100,46 @@ describe('AnalyticsQueryRequestSchema — the BARE AnalyticsQuery shape', () => ).toThrow(); }); + // An empty cube name used to pass this schema as "a string" and reach the + // engine, whose guard threw a plain Error — a 500 for a client input mistake, + // while a missing `cube` answered 400. The accept set closes at the schema: + // one issue, at `cube`, carrying the prescription the engine's own guard + // throws too (`ANALYTICS_CUBE_NAME_REQUIRED`, imported by service-analytics). + describe('an empty cube name is refused at the schema', () => { + it('refuses `cube: ""` with one too_small issue at `cube` reading the prescription', () => { + const parsed = AnalyticsQueryRequestSchema.safeParse({ cube: '', measures: ['x'] }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues).toHaveLength(1); + expect(parsed.error.issues[0].path).toEqual(['cube']); + expect(parsed.error.issues[0].code).toBe('too_small'); + expect(parsed.error.issues[0].message).toBe(ANALYTICS_CUBE_NAME_REQUIRED); + expect(ANALYTICS_CUBE_NAME_REQUIRED).toMatch(/^Name the cube to query/); + }); + + it('a missing cube keeps its own refusal: invalid_type at `cube`, not the empty-name sentence', () => { + const parsed = AnalyticsQueryRequestSchema.safeParse({ measures: ['x'] }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + const cubeIssues = parsed.error.issues.filter((issue) => issue.path[0] === 'cube'); + expect(cubeIssues).toHaveLength(1); + expect(cubeIssues[0].code).toBe('invalid_type'); + }); + + it('a one-character name and a whitespace-only name still parse: the rule is minLength 1, nothing wider', () => { + expect(AnalyticsQueryRequestSchema.safeParse({ cube: 'o', measures: ['x'] }).success).toBe(true); + // Whitespace is a NAME, one no cube carries: the engine answers it + // CUBE_NOT_FOUND / 404, and this schema does not add a second rule. + expect(AnalyticsQueryRequestSchema.safeParse({ cube: ' ', measures: ['x'] }).success).toBe(true); + }); + + it('publishes the floor to JSON Schema as minLength 1 on `cube`', () => { + const json = z.toJSONSchema(AnalyticsQueryRequestSchema, { io: 'input' }) as any; + expect(json.properties.cube.minLength).toBe(1); + expect(json.required).toContain('cube'); + }); + }); + it('should reject missing measures', () => { expect(() => AnalyticsQueryRequestSchema.parse({ cube: 'test' }) diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index e1c9c6e1623..9cae20d9a53 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -30,6 +30,20 @@ export const AnalyticsEndpoint = z.enum([ // 2. Query Execution // ========================================== +/** + * The one sentence an empty `cube` is refused with — by + * {@link AnalyticsQueryRequestSchema} at the HTTP doors (`POST /analytics/query`, + * `POST /analytics/sql`) and by the engine's in-process guard, which imports it + * rather than wording a second rule. Exported so the two cannot drift. + * + * Declared ahead of the schema on purpose: under `OS_EAGER_SCHEMAS=1` + * (`gen:schema`) the factory runs at module evaluation, and a constant below + * it would still be in its temporal dead zone. + */ +export const ANALYTICS_CUBE_NAME_REQUIRED = + 'Name the cube to query: `cube` takes a cube name, as `GET /analytics/meta` lists them, or a ' + + 'registered object name; an empty string names no cube.'; + /** * Query Request Body — the BARE `AnalyticsQuery` shape (#3878). * @@ -47,10 +61,27 @@ export const AnalyticsEndpoint = z.enum([ * rejected now — `.strict()` — and the dispatcher's `/analytics` entry answers * 400 with a migration hint. The unimplemented `format` field went with it * (declared ≠ enforced: every response is the JSON envelope). + * + * `cube` is REQUIRED and NON-EMPTY here: the base `AnalyticsQuerySchema` leaves + * it optional for the in-process callers that resolve the cube themselves, but + * a request body names its cube or it names nothing the engine can serve. The + * empty string used to pass this schema as "a string" and reach the engine, + * whose own guard threw a plain Error — a client input mistake answered as a + * 500. The schema refuses it now with one prescriptive sentence, + * {@link ANALYTICS_CUBE_NAME_REQUIRED}, which the engine's in-process guard + * (`AnalyticsService.query` / `generateSql`, `@objectstack/service-analytics`) + * throws as the same `VALIDATION_FAILED` refusal, so a caller inside the + * process and one at the HTTP door read the same answer. A whitespace-only + * name is a NAME, just one no cube carries, and keeps its `CUBE_NOT_FOUND` + * answer from the engine. */ export const AnalyticsQueryRequestSchema = lazySchema(() => AnalyticsQuerySchema.extend({ - cube: z.string().describe('Target cube name'), + cube: z.string().min(1, ANALYTICS_CUBE_NAME_REQUIRED).describe( + '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.', + ), }).strict() ); diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts new file mode 100644 index 00000000000..2b0364ae291 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-query-empty-cube-name-refused.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The request body of the two analytics query doors admitted an empty cube +// name as "a string" and let the engine answer it with a 500. No D2 +// conversion: nothing written can be rewritten into the cube the author meant. +export const entry: SemanticMigration = { + id: 'analytics-query-empty-cube-name-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'AnalyticsQueryRequestSchema.cube (the body of POST /analytics/query and POST /analytics/sql), ' + + 'sent as the EMPTY string', + replacement: + 'name the cube to query (`cube: \'orders\'`): a cube `GET /analytics/meta` lists, or a registered ' + + 'object name, which is read as an auto-inferred cube. ⛔ There is no replacement that can be ' + + 'derived from what was written: an empty string names no cube, so the platform must not pick ' + + 'one for the caller.', + reason: + 'A request body names its cube or it names nothing the engine can serve. Measured against the ' + + 'published @objectstack/spec 18.2.0: POST /api/v1/analytics/query and POST /api/v1/analytics/sql ' + + 'with cube: "" passed the body schema (cube was z.string()) and reached the engine, whose own ' + + 'guard threw a plain Error, which the door mapped to 500 INTERNAL_ERROR — a client input mistake ' + + 'that read as a server fault, paged operators and got retried, while the same body with cube left ' + + 'out answered 400. The schema now refuses the empty string (minLength 1) with one prescriptive ' + + 'sentence ("Name the cube to query …"), 400 VALIDATION_FAILED with a fields[] entry at cube, and ' + + 'the engine is never reached. In the same release the engine\'s in-process guard ' + + '(AnalyticsService.query and generateSql in @objectstack/service-analytics) throws the same typed ' + + 'refusal — code VALIDATION_FAILED, status 400, the schema\'s own sentence — for an absent or ' + + 'empty cube, so a plugin, flow or test that holds the service reads the same answer the door ' + + 'gives. Nothing is stored: a cube name travels in a request, never in stack metadata, so there is ' + + 'no row to rewrite and no D2 conversion — one semantic TODO for API callers. A whitespace-only ' + + 'name is unchanged: it is a name no cube carries and keeps its 404 CUBE_NOT_FOUND. ADR-0087.', + acceptanceCriteria: + 'Search every caller of POST /analytics/query and POST /analytics/sql (dashboard widgets, ' + + 'client.analytics.query / generateSql, scripts) and every in-process AnalyticsService.query / ' + + 'generateSql call for a cube built from a value that can be empty — an unset selection, a blank ' + + 'form field, a missing config key. Name the cube, or refuse to send the query until one is ' + + 'chosen. The sweep is mechanical at the door: AnalyticsQueryRequestSchema.safeParse answers one ' + + 'issue at the cube path reading "Name the cube to query", and the HTTP doors answer 400 ' + + 'VALIDATION_FAILED with fields[0].field === "cube" and the engine not called. A non-empty cube ' + + 'parses unchanged, and a missing cube keeps the 400 it already had. The repository carried no ' + + 'caller sending an empty cube at the time of the change.', +}; From 0ea14ec55f225d1155f05bb4b0f4b8e7a52f9879 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:55:18 +0000 Subject: [PATCH 02/10] chore(spec): regenerate api-surface, export-origins and the analytics reference for the new export Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/api/analytics.mdx | 2 +- packages/spec/api-surface/api.json | 1 + packages/spec/export-origins/api.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 30d0615ad73..c2ed54ee728 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -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`). | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 81959d222f5..1b1751dbcd7 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -2,6 +2,7 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./api", "exports": [ + "ANALYTICS_CUBE_NAME_REQUIRED (const)", "AckMessage (type)", "AckMessageSchema (const)", "AiAgentCapabilities (type)", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 5397029f01a..bcf94723730 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -2,6 +2,7 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./api", "exports": { + "ANALYTICS_CUBE_NAME_REQUIRED": "src/api/analytics.zod.ts#ANALYTICS_CUBE_NAME_REQUIRED (const)", "AckMessage": "src/api/websocket.zod.ts#AckMessage (type)", "AckMessageSchema": "src/api/websocket.zod.ts#AckMessageSchema (const)", "AiAgentCapabilities": "src/api/protocol.zod.ts#AiAgentCapabilities (type)", From 1f9a7ed8bda4ead391162e7771a863a350560920 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:05:39 +0000 Subject: [PATCH 03/10] test(analytics): pin the whitespace and missing-cube cases to what each harness can measure Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../src/analytics-empty-cube-refusal.test.ts | 10 ++++++++-- .../src/__tests__/analytics-service.test.ts | 13 ++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/analytics-empty-cube-refusal.test.ts b/packages/runtime/src/analytics-empty-cube-refusal.test.ts index fc054cd2176..5a264428ad3 100644 --- a/packages/runtime/src/analytics-empty-cube-refusal.test.ts +++ b/packages/runtime/src/analytics-empty-cube-refusal.test.ts @@ -109,13 +109,19 @@ describe.each(['/analytics/query', '/analytics/sql'])('%s — an empty cub expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); }); - it('a missing cube keeps the 400 it already had, with the `required` field code', async () => { + it('a missing cube keeps the 400 VALIDATION_FAILED it already had, at the `cube` field', 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); - expect(res.body.error.details.fields[0]).toMatchObject({ field: 'cube', code: 'required' }); + // The field code for an ABSENT key is the door's pre-existing answer + // (no input is handed to the mapper there), not this change's subject: + // what is pinned is the field and the envelope, and that the empty-name + // sentence is not borrowed for the missing case. + expect(res.body.error.details.fields[0]).toMatchObject({ field: 'cube' }); + expect(res.body.error.details.fields[0].message).not.toBe(ANALYTICS_CUBE_NAME_REQUIRED); expect(serviceCalls(calls)).toEqual([]); }); diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index 4230b54f28f..d6cf0a326c9 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -523,12 +523,15 @@ describe('AnalyticsService', () => { expect((err as Error).message).toBe(ANALYTICS_CUBE_NAME_REQUIRED); }); - it('a whitespace-only name is a NAME no cube carries: CUBE_NOT_FOUND / 404, unchanged', async () => { + 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 }); - await expect(service.query({ cube: ' ', measures: ['count'] })).rejects.toMatchObject({ - code: 'CUBE_NOT_FOUND', - status: 404, - }); + const err = await service.query({ cube: ' ', measures: ['count'] }).catch((e: unknown) => e as Error & { code?: string }); + expect(err).toBeInstanceOf(Error); + expect(err.code).not.toBe('VALIDATION_FAILED'); + expect(err.message).not.toMatch(/^Name the cube to query/); }); }); From 9a8abf37c290fdcf9fab5a4765946476f33a447d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:26:24 +0000 Subject: [PATCH 04/10] fix(runtime,service-analytics): one field code for a missing cube on both sides, and an absent non-string cube reads as absent The analytics door hands the body to fieldsFromZodIssues so an absent cube maps to the ADR-0114 required code, matching the engine's in-process guard; the guard classifies only a string as too short. Review findings folded in. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27505-analytics-empty-cube.md | 3 ++- .../src/analytics-empty-cube-refusal.test.ts | 14 ++++++++------ packages/runtime/src/domains/analytics.ts | 7 ++++++- .../service-analytics/src/cube-name-refusal.ts | 18 +++++++++++------- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.changeset/27505-analytics-empty-cube.md b/.changeset/27505-analytics-empty-cube.md index ffb6d65b780..b11b42d514f 100644 --- a/.changeset/27505-analytics-empty-cube.md +++ b/.changeset/27505-analytics-empty-cube.md @@ -14,8 +14,9 @@ Skills-docs: none — `skills/**` names a cube only by a real name (`skills/obje **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().min(1, …)` (JSON Schema `minLength: 1`). 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 (`code: 'required'`). +- **Now.** `cube` is `z.string().min(1, …)` (JSON Schema `minLength: 1`). 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; its `fields[]` code is now `required` (the door hands the body to the field mapper, so an absent key is reported as absent) where it used to read Zod's `invalid_type` for the same body. - **`@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, a `fields[]` entry at `cube` — 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; it is a card of its own, not this change. - **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. diff --git a/packages/runtime/src/analytics-empty-cube-refusal.test.ts b/packages/runtime/src/analytics-empty-cube-refusal.test.ts index 5a264428ad3..881a60b43ff 100644 --- a/packages/runtime/src/analytics-empty-cube-refusal.test.ts +++ b/packages/runtime/src/analytics-empty-cube-refusal.test.ts @@ -109,18 +109,20 @@ describe.each(['/analytics/query', '/analytics/sql'])('%s — an empty cub 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, at the `cube` field', async () => { + 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); - // The field code for an ABSENT key is the door's pre-existing answer - // (no input is handed to the mapper there), not this change's subject: - // what is pinned is the field and the envelope, and that the empty-name - // sentence is not borrowed for the missing case. - expect(res.body.error.details.fields[0]).toMatchObject({ field: 'cube' }); + // `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. The empty-name sentence is not + // borrowed for the missing case. + expect(res.body.error.details.fields[0]).toMatchObject({ field: 'cube', code: 'required' }); expect(res.body.error.details.fields[0].message).not.toBe(ANALYTICS_CUBE_NAME_REQUIRED); expect(serviceCalls(calls)).toEqual([]); }); diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index 03cf5d2b8b8..57bd7f6ac55 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -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` diff --git a/packages/services/service-analytics/src/cube-name-refusal.ts b/packages/services/service-analytics/src/cube-name-refusal.ts index fceb9c410a0..d93bcb17739 100644 --- a/packages/services/service-analytics/src/cube-name-refusal.ts +++ b/packages/services/service-analytics/src/cube-name-refusal.ts @@ -28,18 +28,22 @@ import { validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types' * sentence is `ANALYTICS_CUBE_NAME_REQUIRED`, imported from the schema that * refuses the same input at the door: one rule, one wording, two readers. * - * The `fields[]` entry carries the ADR-0114 catalog code the door would have - * produced for the same input — `required` for an absent `cube`, `min_length` - * for the empty string (Zod's `too_small` on a string, mapped by - * `zodIssuesToFields`) — so a UI anchoring per-field errors sees one vocabulary - * whichever side refused. + * The `fields[]` entry carries the ADR-0114 catalog code the door produces + * for the same input — `required` for an absent `cube` (the door hands the + * body to `fieldsFromZodIssues`, so the D3 split reports a missing key as + * `required`), `min_length` for the empty string (Zod's `too_small` on a + * string, mapped by `zodIssuesToFields`) — so a UI anchoring per-field errors + * sees one vocabulary whichever side refused. `analytics-empty-cube-refusal.test.ts` + * (runtime) pins the door's half, `analytics-service.test.ts` this one. * * ⛔ A whitespace-only name is NOT refused here: it is a name, one no cube * carries, and the engine's cube resolution answers it `CUBE_NOT_FOUND` / 404. * Refusing it would be a second rule beside the schema's `.min(1)`. */ -export function cubeNameRequiredError(cube: string | undefined): Error & { code: string; status: number } { - const code: FieldErrorCode = cube === undefined ? 'required' : 'min_length'; +export function cubeNameRequiredError(cube: unknown): Error & { code: string; status: number } { + // Only a string can be too short; anything else that `!queryInput.cube` + // let through (`undefined`, or a `null` from an untyped caller) is absent. + const code: FieldErrorCode = typeof cube === 'string' ? 'min_length' : 'required'; const err = validationFailure(ANALYTICS_CUBE_NAME_REQUIRED, [ { field: 'cube', code, message: ANALYTICS_CUBE_NAME_REQUIRED }, ]) as Error & { code: string; status: number }; From be08e9d2a7e07b9d78b1f6038a0f2d7f1a897454 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:28:34 +0000 Subject: [PATCH 05/10] test(service-analytics): type the whitespace-name rejection so the package typecheck reads it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../src/__tests__/analytics-service.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index d6cf0a326c9..2de403f2a30 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -528,7 +528,10 @@ describe('AnalyticsService', () => { // 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 = await service.query({ cube: ' ', measures: ['count'] }).catch((e: unknown) => e as Error & { code?: string }); + const err: Error & { code?: string } = await service.query({ cube: ' ', measures: ['count'] }).then( + () => { throw new Error('expected a rejection'); }, + (e: unknown) => e as Error & { code?: string }, + ); expect(err).toBeInstanceOf(Error); expect(err.code).not.toBe('VALIDATION_FAILED'); expect(err.message).not.toMatch(/^Name the cube to query/); From 5a8fdf62bb5ec7fcfb66e34e02b44fae4f5b5544 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:34:57 +0000 Subject: [PATCH 06/10] fix(service-analytics): the in-process cube refusal takes its field entry from the schema member and the one D3 mapper No hand-rolled code split: the value runs through AnalyticsQueryRequestSchema.shape.cube and fieldsFromZodIssues with the value beside it, so the entry is the door's for the same value (required / min_length / invalid_type). The changeset names the runtime line and the full required flip for every absent key of the analytics body. Delta-review findings folded in. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27505-analytics-empty-cube.md | 6 +- .../src/__tests__/analytics-service.test.ts | 56 +++++++++---------- .../src/cube-name-refusal.ts | 42 ++++++++------ 3 files changed, 55 insertions(+), 49 deletions(-) diff --git a/.changeset/27505-analytics-empty-cube.md b/.changeset/27505-analytics-empty-cube.md index b11b42d514f..94cba80af06 100644 --- a/.changeset/27505-analytics-empty-cube.md +++ b/.changeset/27505-analytics-empty-cube.md @@ -1,6 +1,7 @@ --- '@objectstack/spec': minor '@objectstack/service-analytics': minor +'@objectstack/runtime': patch --- 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) @@ -14,8 +15,9 @@ Skills-docs: none — `skills/**` names a cube only by a real name (`skills/obje **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().min(1, …)` (JSON Schema `minLength: 1`). 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; its `fields[]` code is now `required` (the door hands the body to the field mapper, so an absent key is reported as absent) where it used to read Zod's `invalid_type` for the same body. -- **`@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, a `fields[]` entry at `cube` — 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. +- **Now.** `cube` is `z.string().min(1, …)` (JSON Schema `minLength: 1`). 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. +- **`@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; it is a card of its own, not this change. - **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. diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index 2de403f2a30..45f1b1ff71f 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -481,46 +481,45 @@ describe('AnalyticsService', () => { // 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 sentence is the - // schema's own (`ANALYTICS_CUBE_NAME_REQUIRED`); the pins below read the - // envelope (`code` + `status`), the field entry and the sentence's head. + // 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' }; + const refused = { code: 'VALIDATION_FAILED', status: 400, name: 'ValidationError', message: ANALYTICS_CUBE_NAME_REQUIRED }; + const missing = { ...refused, fields: [{ field: 'cube', code: '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({ - ...refused, - fields: [{ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }], - }); + 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({ - ...refused, - fields: [{ field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }], - }); + 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({ - ...refused, - fields: [{ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }], - }); - await expect(service.generateSql({ cube: '', measures: ['count'] })).rejects.toMatchObject({ + 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: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }], + fields: [{ field: 'cube', code: 'invalid_type' }], }); }); - it('the refusal reads the prescription first, and is the schema\'s sentence', async () => { + it('the refusal reads the prescription first', async () => { const service = new AnalyticsService({ logger: silentLogger }); - const err = await service.query({ cube: '', measures: ['count'] }).catch((e: unknown) => e as Error); - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toMatch(/^Name the cube to query/); - expect((err as Error).message).toBe(ANALYTICS_CUBE_NAME_REQUIRED); + 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 () => { @@ -528,13 +527,10 @@ describe('AnalyticsService', () => { // 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: Error & { code?: string } = await service.query({ cube: ' ', measures: ['count'] }).then( - () => { throw new Error('expected a rejection'); }, - (e: unknown) => e as Error & { code?: string }, - ); - expect(err).toBeInstanceOf(Error); - expect(err.code).not.toBe('VALIDATION_FAILED'); - expect(err.message).not.toMatch(/^Name the cube to query/); + const query = () => service.query({ cube: ' ', measures: ['count'] }); + await expect(query()).rejects.toThrow(); + await expect(query()).rejects.not.toMatchObject({ code: 'VALIDATION_FAILED' }); + await expect(query()).rejects.not.toMatchObject({ message: expect.stringMatching(/^Name the cube to query/) }); }); }); diff --git a/packages/services/service-analytics/src/cube-name-refusal.ts b/packages/services/service-analytics/src/cube-name-refusal.ts index d93bcb17739..b3158eed8a2 100644 --- a/packages/services/service-analytics/src/cube-name-refusal.ts +++ b/packages/services/service-analytics/src/cube-name-refusal.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { ANALYTICS_CUBE_NAME_REQUIRED, type FieldErrorCode } from '@objectstack/spec/api'; -import { validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types'; +import { ANALYTICS_CUBE_NAME_REQUIRED, AnalyticsQueryRequestSchema } from '@objectstack/spec/api'; +import { fieldsFromZodIssues, validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types'; /** * The ONE refusal for an analytics query that names no cube — `cube` absent or @@ -25,28 +25,36 @@ import { validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types' * `VALIDATION_FAILED` — with `status: 400` declared on it as this service's * sibling refusals do (`cubeNotFoundError`, 404), so an in-process caller reads * `{ code, status }` without a boundary to decide the status for it. Its - * sentence is `ANALYTICS_CUBE_NAME_REQUIRED`, imported from the schema that - * refuses the same input at the door: one rule, one wording, two readers. + * message is `ANALYTICS_CUBE_NAME_REQUIRED`, the sentence the schema refuses + * the empty string with: one rule, one wording, two readers. * - * The `fields[]` entry carries the ADR-0114 catalog code the door produces - * for the same input — `required` for an absent `cube` (the door hands the - * body to `fieldsFromZodIssues`, so the D3 split reports a missing key as - * `required`), `min_length` for the empty string (Zod's `too_small` on a - * string, mapped by `zodIssuesToFields`) — so a UI anchoring per-field errors - * sees one vocabulary whichever side refused. `analytics-empty-cube-refusal.test.ts` - * (runtime) pins the door's half, `analytics-service.test.ts` this one. + * ## The `fields[]` entry is the door's, literally + * + * The value is run through the schema's own `cube` member and the issues + * through `fieldsFromZodIssues` with the value beside them — the one ADR-0114 + * D3 table in the repo (`zodIssuesToFields`, `@objectstack/spec`), the same + * call the door makes on the whole body. So the entry's code is whatever the + * door answers for the same value — `required` for an absent key, `min_length` + * for the empty string, `invalid_type` for a `null` an untyped caller slipped + * past `!queryInput.cube` — and its message is the member's own. ⛔ No + * hand-rolled `typeof` split here: a second copy of that table is the drift + * the ADR exists to prevent, and it disagreed with the door on `null` once. + * `analytics-empty-cube-refusal.test.ts` (runtime) pins the door's half, + * `analytics-service.test.ts` this one. * * ⛔ A whitespace-only name is NOT refused here: it is a name, one no cube * carries, and the engine's cube resolution answers it `CUBE_NOT_FOUND` / 404. * Refusing it would be a second rule beside the schema's `.min(1)`. */ export function cubeNameRequiredError(cube: unknown): Error & { code: string; status: number } { - // Only a string can be too short; anything else that `!queryInput.cube` - // let through (`undefined`, or a `null` from an untyped caller) is absent. - const code: FieldErrorCode = typeof cube === 'string' ? 'min_length' : 'required'; - const err = validationFailure(ANALYTICS_CUBE_NAME_REQUIRED, [ - { field: 'cube', code, message: ANALYTICS_CUBE_NAME_REQUIRED }, - ]) as Error & { code: string; status: number }; + const verdict = AnalyticsQueryRequestSchema.shape.cube.safeParse(cube); + const issues = verdict.success + ? [] + : verdict.error.issues.map((issue) => ({ ...issue, path: ['cube', ...issue.path] })); + const err = validationFailure(ANALYTICS_CUBE_NAME_REQUIRED, fieldsFromZodIssues(issues, { cube })) as Error & { + code: string; + status: number; + }; err.status = VALIDATION_FAILED_STATUS; return err; } From a3a06410e187ce9dea4056919a8a97567ecc169e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:43:52 +0000 Subject: [PATCH 07/10] fix(spec,service-analytics): one prescription for every refusal of the cube slot, and the pins the changeset promises The member carries a type-level error beside .min(1), so an absent or null cube reads the same sentence at the door and in-process; the helper memoises the member and treats an accepted value as a programming error; the door test pins the required flip for measures and a bodiless request; runtime moves at minor. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27505-analytics-empty-cube.md | 4 +-- .../src/analytics-empty-cube-refusal.test.ts | 30 ++++++++++++++++--- .../src/__tests__/analytics-service.test.ts | 15 ++++++---- .../src/cube-name-refusal.ts | 24 +++++++++++---- packages/spec/src/api/analytics.test.ts | 7 ++++- packages/spec/src/api/analytics.zod.ts | 28 ++++++++++------- 6 files changed, 79 insertions(+), 29 deletions(-) diff --git a/.changeset/27505-analytics-empty-cube.md b/.changeset/27505-analytics-empty-cube.md index 94cba80af06..1297af3bd78 100644 --- a/.changeset/27505-analytics-empty-cube.md +++ b/.changeset/27505-analytics-empty-cube.md @@ -1,7 +1,7 @@ --- '@objectstack/spec': minor '@objectstack/service-analytics': minor -'@objectstack/runtime': patch +'@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) @@ -15,7 +15,7 @@ Skills-docs: none — `skills/**` names a cube only by a real name (`skills/obje **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().min(1, …)` (JSON Schema `minLength: 1`). 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. +- **Now.** `cube` is `z.string().min(1, …)` (JSON Schema `minLength: 1`). 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`; its field code still says which case it is. - **`@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; it is a card of its own, not this change. diff --git a/packages/runtime/src/analytics-empty-cube-refusal.test.ts b/packages/runtime/src/analytics-empty-cube-refusal.test.ts index 881a60b43ff..bd36475fbd6 100644 --- a/packages/runtime/src/analytics-empty-cube-refusal.test.ts +++ b/packages/runtime/src/analytics-empty-cube-refusal.test.ts @@ -120,13 +120,35 @@ describe.each(['/analytics/query', '/analytics/sql'])('%s — an empty cub // 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. The empty-name sentence is not - // borrowed for the missing case. - expect(res.body.error.details.fields[0]).toMatchObject({ field: 'cube', code: 'required' }); - expect(res.body.error.details.fields[0].message).not.toBe(ANALYTICS_CUBE_NAME_REQUIRED); + // 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 covers every absent required key of the body, and an absent body', async () => { + // The door hands the body to the mapper, so the D3 split reports every + // missing key as `required` (it used to read `invalid_type`): pinned + // beyond `cube` because the changeset says so for `measures` and for + // a bodiless request. + 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); diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index 45f1b1ff71f..6df8df600c8 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -488,7 +488,7 @@ describe('AnalyticsService', () => { // 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' }] }; + 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 () => { @@ -511,7 +511,7 @@ describe('AnalyticsService', () => { 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' }], + fields: [{ field: 'cube', code: 'invalid_type', message: ANALYTICS_CUBE_NAME_REQUIRED }], }); }); @@ -527,10 +527,13 @@ describe('AnalyticsService', () => { // 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 query = () => service.query({ cube: ' ', measures: ['count'] }); - await expect(query()).rejects.toThrow(); - await expect(query()).rejects.not.toMatchObject({ code: 'VALIDATION_FAILED' }); - await expect(query()).rejects.not.toMatchObject({ message: expect.stringMatching(/^Name the cube to query/) }); + 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/) }); }); }); diff --git a/packages/services/service-analytics/src/cube-name-refusal.ts b/packages/services/service-analytics/src/cube-name-refusal.ts index b3158eed8a2..9189c7918e7 100644 --- a/packages/services/service-analytics/src/cube-name-refusal.ts +++ b/packages/services/service-analytics/src/cube-name-refusal.ts @@ -36,7 +36,9 @@ import { fieldsFromZodIssues, validationFailure, VALIDATION_FAILED_STATUS } from * call the door makes on the whole body. So the entry's code is whatever the * door answers for the same value — `required` for an absent key, `min_length` * for the empty string, `invalid_type` for a `null` an untyped caller slipped - * past `!queryInput.cube` — and its message is the member's own. ⛔ No + * past `!queryInput.cube` — and its message is the member's own, which is the + * one prescription for every arm (the member carries a type-level `error` + * beside its `.min(1)`). ⛔ No * hand-rolled `typeof` split here: a second copy of that table is the drift * the ADR exists to prevent, and it disagreed with the door on `null` once. * `analytics-empty-cube-refusal.test.ts` (runtime) pins the door's half, @@ -46,11 +48,23 @@ import { fieldsFromZodIssues, validationFailure, VALIDATION_FAILED_STATUS } from * carries, and the engine's cube resolution answers it `CUBE_NOT_FOUND` / 404. * Refusing it would be a second rule beside the schema's `.min(1)`. */ +/** + * The schema's `cube` member, resolved on the first refusal and kept: reading + * it at module load would resolve the lazy request schema for every importer + * of the service, which is the allocation `lazySchema` exists to defer. + */ +let cubeMember: typeof AnalyticsQueryRequestSchema.shape.cube | undefined; + export function cubeNameRequiredError(cube: unknown): Error & { code: string; status: number } { - const verdict = AnalyticsQueryRequestSchema.shape.cube.safeParse(cube); - const issues = verdict.success - ? [] - : verdict.error.issues.map((issue) => ({ ...issue, path: ['cube', ...issue.path] })); + cubeMember ??= AnalyticsQueryRequestSchema.shape.cube; + const verdict = cubeMember.safeParse(cube); + if (verdict.success) { + // Both call sites guard with `!queryInput.cube`, and every falsy value + // fails `z.string().min(1)`; a caller reaching here with an accepted name + // would mint a refusal naming no field and prescribing a fix that is false. + throw new Error('cubeNameRequiredError: called with a cube name the schema accepts'); + } + const issues = verdict.error.issues.map((issue) => ({ ...issue, path: ['cube', ...issue.path] })); const err = validationFailure(ANALYTICS_CUBE_NAME_REQUIRED, fieldsFromZodIssues(issues, { cube })) as Error & { code: string; status: number; diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 91c7e553750..cf919943de4 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -117,13 +117,18 @@ describe('AnalyticsQueryRequestSchema — the BARE AnalyticsQuery shape', () => expect(ANALYTICS_CUBE_NAME_REQUIRED).toMatch(/^Name the cube to query/); }); - it('a missing cube keeps its own refusal: invalid_type at `cube`, not the empty-name sentence', () => { + it('a missing cube keeps its own issue code (invalid_type at `cube`) and reads the same prescription', () => { + // The member carries a type-level `error` beside its `.min(1)`: the key + // left out, the most common client mistake, gets the one sentence too, + // while the issue code (and so the ADR-0114 field code, `required` once + // the mapper sees the input) still tells the cases apart. const parsed = AnalyticsQueryRequestSchema.safeParse({ measures: ['x'] }); expect(parsed.success).toBe(false); if (parsed.success) return; const cubeIssues = parsed.error.issues.filter((issue) => issue.path[0] === 'cube'); expect(cubeIssues).toHaveLength(1); expect(cubeIssues[0].code).toBe('invalid_type'); + expect(cubeIssues[0].message).toBe(ANALYTICS_CUBE_NAME_REQUIRED); }); it('a one-character name and a whitespace-only name still parse: the rule is minLength 1, nothing wider', () => { diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 9cae20d9a53..73d6676181c 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -31,10 +31,11 @@ export const AnalyticsEndpoint = z.enum([ // ========================================== /** - * The one sentence an empty `cube` is refused with — by - * {@link AnalyticsQueryRequestSchema} at the HTTP doors (`POST /analytics/query`, - * `POST /analytics/sql`) and by the engine's in-process guard, which imports it - * rather than wording a second rule. Exported so the two cannot drift. + * The one sentence a `cube` that names no cube — absent, `null`, not a string, + * or the empty string — is refused with: by {@link AnalyticsQueryRequestSchema} + * at the HTTP doors (`POST /analytics/query`, `POST /analytics/sql`) and by the + * engine's in-process guard, which parses through the same member rather than + * wording a second rule. Exported so the two cannot drift. * * Declared ahead of the schema on purpose: under `OS_EAGER_SCHEMAS=1` * (`gen:schema`) the factory runs at module evaluation, and a constant below @@ -68,16 +69,21 @@ export const ANALYTICS_CUBE_NAME_REQUIRED = * empty string used to pass this schema as "a string" and reach the engine, * whose own guard threw a plain Error — a client input mistake answered as a * 500. The schema refuses it now with one prescriptive sentence, - * {@link ANALYTICS_CUBE_NAME_REQUIRED}, which the engine's in-process guard - * (`AnalyticsService.query` / `generateSql`, `@objectstack/service-analytics`) - * throws as the same `VALIDATION_FAILED` refusal, so a caller inside the - * process and one at the HTTP door read the same answer. A whitespace-only - * name is a NAME, just one no cube carries, and keeps its `CUBE_NOT_FOUND` - * answer from the engine. + * {@link ANALYTICS_CUBE_NAME_REQUIRED}, carried by every refusal of the slot + * — the type-level `error` for an absent, `null` or non-string `cube`, the + * `.min(1)` for the empty string — so the most common client mistake, the key + * left out, reads the prescription too and not Zod's generic words. The + * ADR-0114 field code still tells the cases apart (`required` / `invalid_type` + * / `min_length`). The engine's in-process guard (`AnalyticsService.query` / + * `generateSql`, `@objectstack/service-analytics`) runs a value through this + * same member and throws the same `VALIDATION_FAILED` refusal, so a caller + * inside the process and one at the HTTP door read the same answer. A + * whitespace-only name is a NAME, just one no cube carries, and keeps its + * `CUBE_NOT_FOUND` answer from the engine. */ export const AnalyticsQueryRequestSchema = lazySchema(() => AnalyticsQuerySchema.extend({ - cube: z.string().min(1, ANALYTICS_CUBE_NAME_REQUIRED).describe( + cube: z.string({ error: ANALYTICS_CUBE_NAME_REQUIRED }).min(1, ANALYTICS_CUBE_NAME_REQUIRED).describe( '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.', From 745bb21d020bc2ec51445ef7c8d984a3f47a14a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:49:47 +0000 Subject: [PATCH 08/10] chore(service-analytics): keep the refusal helper's docblock on the function Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../service-analytics/src/cube-name-refusal.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/services/service-analytics/src/cube-name-refusal.ts b/packages/services/service-analytics/src/cube-name-refusal.ts index 9189c7918e7..0e4b6099860 100644 --- a/packages/services/service-analytics/src/cube-name-refusal.ts +++ b/packages/services/service-analytics/src/cube-name-refusal.ts @@ -3,6 +3,13 @@ import { ANALYTICS_CUBE_NAME_REQUIRED, AnalyticsQueryRequestSchema } from '@objectstack/spec/api'; import { fieldsFromZodIssues, validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types'; +/** + * The schema's `cube` member, resolved on the first refusal and kept: reading + * it at module load would resolve the lazy request schema for every importer + * of the service, which is the allocation `lazySchema` exists to defer. + */ +let cubeMember: typeof AnalyticsQueryRequestSchema.shape.cube | undefined; + /** * The ONE refusal for an analytics query that names no cube — `cube` absent or * the empty string — thrown by `AnalyticsService.query` (through `queryIn`) and @@ -48,13 +55,6 @@ import { fieldsFromZodIssues, validationFailure, VALIDATION_FAILED_STATUS } from * carries, and the engine's cube resolution answers it `CUBE_NOT_FOUND` / 404. * Refusing it would be a second rule beside the schema's `.min(1)`. */ -/** - * The schema's `cube` member, resolved on the first refusal and kept: reading - * it at module load would resolve the lazy request schema for every importer - * of the service, which is the allocation `lazySchema` exists to defer. - */ -let cubeMember: typeof AnalyticsQueryRequestSchema.shape.cube | undefined; - export function cubeNameRequiredError(cube: unknown): Error & { code: string; status: number } { cubeMember ??= AnalyticsQueryRequestSchema.shape.cube; const verdict = cubeMember.safeParse(cube); From a3f9eccad5ce0be7ca8d2df7fc1841925bdb7023 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:58:07 +0000 Subject: [PATCH 09/10] chore(service-analytics): drop the redundant member memo and align the prose with the member's shape Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27505-analytics-empty-cube.md | 2 +- .../src/analytics-empty-cube-refusal.test.ts | 10 +++---- .../src/cube-name-refusal.ts | 29 +++++++++---------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.changeset/27505-analytics-empty-cube.md b/.changeset/27505-analytics-empty-cube.md index 1297af3bd78..0478b0e8d6b 100644 --- a/.changeset/27505-analytics-empty-cube.md +++ b/.changeset/27505-analytics-empty-cube.md @@ -15,7 +15,7 @@ Skills-docs: none — `skills/**` names a cube only by a real name (`skills/obje **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().min(1, …)` (JSON Schema `minLength: 1`). 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`; its field code still says which case it is. +- **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; it is a card of its own, not this change. diff --git a/packages/runtime/src/analytics-empty-cube-refusal.test.ts b/packages/runtime/src/analytics-empty-cube-refusal.test.ts index bd36475fbd6..6155d96b037 100644 --- a/packages/runtime/src/analytics-empty-cube-refusal.test.ts +++ b/packages/runtime/src/analytics-empty-cube-refusal.test.ts @@ -127,11 +127,11 @@ describe.each(['/analytics/query', '/analytics/sql'])('%s — an empty cub expect(serviceCalls(calls)).toEqual([]); }); - it('the `required` code covers every absent required key of the body, and an absent body', async () => { - // The door hands the body to the mapper, so the D3 split reports every - // missing key as `required` (it used to read `invalid_type`): pinned - // beyond `cube` because the changeset says so for `measures` and for - // a bodiless request. + 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'); diff --git a/packages/services/service-analytics/src/cube-name-refusal.ts b/packages/services/service-analytics/src/cube-name-refusal.ts index 0e4b6099860..eb5a26e54c3 100644 --- a/packages/services/service-analytics/src/cube-name-refusal.ts +++ b/packages/services/service-analytics/src/cube-name-refusal.ts @@ -4,22 +4,17 @@ import { ANALYTICS_CUBE_NAME_REQUIRED, AnalyticsQueryRequestSchema } from '@obje import { fieldsFromZodIssues, validationFailure, VALIDATION_FAILED_STATUS } from '@objectstack/types'; /** - * The schema's `cube` member, resolved on the first refusal and kept: reading - * it at module load would resolve the lazy request schema for every importer - * of the service, which is the allocation `lazySchema` exists to defer. - */ -let cubeMember: typeof AnalyticsQueryRequestSchema.shape.cube | undefined; - -/** - * The ONE refusal for an analytics query that names no cube — `cube` absent or - * the empty string — thrown by `AnalyticsService.query` (through `queryIn`) and + * The ONE refusal for an analytics query that names no cube — `cube` absent, + * the empty string, or a `null` an untyped caller slipped past the guard — + * thrown by `AnalyticsService.query` (through `queryIn`) and * `AnalyticsService.generateSql` before any other gate runs. * * ## Why a typed refusal, and why its words are the schema's * * The HTTP doors (`POST /analytics/query`, `POST /analytics/sql`) parse the body - * with `AnalyticsQueryRequestSchema`, whose `cube` is `z.string().min(1, …)`: - * a missing or empty name is refused there, `400 VALIDATION_FAILED` with a + * with `AnalyticsQueryRequestSchema`, whose `cube` is a string with a + * type-level `error` and a `.min(1)`, both carrying one sentence: a missing, + * non-string or empty name is refused there, `400 VALIDATION_FAILED` with a * `fields[]` entry at `cube`, and the engine is never reached. The engine's * own guard stays for the callers that do not pass through that schema — a * plugin, a flow, a test, anything that holds the service — and the base @@ -56,13 +51,15 @@ let cubeMember: typeof AnalyticsQueryRequestSchema.shape.cube | undefined; * Refusing it would be a second rule beside the schema's `.min(1)`. */ export function cubeNameRequiredError(cube: unknown): Error & { code: string; status: number } { - cubeMember ??= AnalyticsQueryRequestSchema.shape.cube; - const verdict = cubeMember.safeParse(cube); + // `lazySchema` caches the resolved request schema and `.shape` is itself + // cached, so this read costs nothing after the first refusal in a process. + const verdict = AnalyticsQueryRequestSchema.shape.cube.safeParse(cube); if (verdict.success) { // Both call sites guard with `!queryInput.cube`, and every falsy value - // fails `z.string().min(1)`; a caller reaching here with an accepted name - // would mint a refusal naming no field and prescribing a fix that is false. - throw new Error('cubeNameRequiredError: called with a cube name the schema accepts'); + // fails the member; a caller reaching here with an accepted name would + // mint a refusal naming no field and prescribing a fix that is false. A + // programmer error, which a boundary rightly reports as a server fault. + throw new TypeError('cubeNameRequiredError: called with a cube name the schema accepts'); } const issues = verdict.error.issues.map((issue) => ({ ...issue, path: ['cube', ...issue.path] })); const err = validationFailure(ANALYTICS_CUBE_NAME_REQUIRED, fieldsFromZodIssues(issues, { cube })) as Error & { From a0b621038aad223ccd675c51b20242c18c196040 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:43:16 +0000 Subject: [PATCH 10/10] docs(changeset): state the driver-memory guard as what this change leaves, not as a filed card Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27505-analytics-empty-cube.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27505-analytics-empty-cube.md b/.changeset/27505-analytics-empty-cube.md index 0478b0e8d6b..6b9f6d9223b 100644 --- a/.changeset/27505-analytics-empty-cube.md +++ b/.changeset/27505-analytics-empty-cube.md @@ -18,7 +18,7 @@ Skills-docs: none — `skills/**` names a cube only by a real name (`skills/obje - **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; it is a card of its own, not this change. +- **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.