Skip to content

Commit 2f6516e

Browse files
os-zhuangclaude
andauthored
fix(analytics,rest): an analytics filter refusal reaches the caller as 400 INVALID_FILTER (#5352) (#5366)
A misspelled operator in a dashboard widget's filter is refused by `filter-normalizer.ts` — correctly — but the refusal never reached the author: it landed as `500 ANALYTICS_QUERY_FAILED`, read as "the platform is broken" rather than "your filter has a typo", and counted by ops alerting as a 5xx. The identical mistake on `find()` has answered `400 INVALID_FILTER` since #3948. One defect with two halves; either alone leaves it unfixed. Producer — `filter-normalizer.ts`: seven of its nine refusals were bare `throw new Error(…)` with no `code`/`status`, so the REST face had nothing to read. All nine now go through the `invalidFilterError` helper #5334 introduced (INVALID_FILTER / 400), which becomes the module's only way to refuse. Two of the seven (`{$not: <non-object>}`, an unsupported TOP-LEVEL operator) were not among the issue's four bullets; enveloping only the listed five would have left two spellings of the same authoring mistake answering 500 next to neighbours answering 400. Consumer — `rest-server.ts`, `POST /analytics/dataset/query`: the catch discarded `error.code`/`error.status` and re-derived the classification from a hardcoded list of message substrings. It now reads the envelope first, and the substring list is demoted to a documented transitional fallback. All six of its entries were re-verified as bare `Error`s, so none could be deleted. The passthrough is 4xx-only and requires both `code` and `status`: an internal fault can never be re-labelled as the caller's fault, and this route invents no code a producer failed to supply. Which inputs are refused did not change — only the shape of the error. Pinned input-by-input, refusals and accepted inputs (with their compiled trees) alike, in `filter-refusal-envelope.test.ts`, which is green both before and after. The REST-side test drives the real `AnalyticsService` rather than a mock, because the defect lives at the seam: a mock on either side makes the other half's correctness an assumption. Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b4ad984 commit 2f6516e

7 files changed

Lines changed: 719 additions & 23 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(analytics,rest): an analytics filter refusal reaches the caller as `400 INVALID_FILTER`, not `500 ANALYTICS_QUERY_FAILED` (#5352)
7+
8+
Misspell an operator in a dashboard widget's filter and analytics refuses it —
9+
correctly, and loudly, which is the posture #3948 / #5240 / #5325 / #5334 each
10+
argued for one refusal at a time: dropping a predicate the compiler cannot
11+
express does not narrow the query, it **widens** it to rows the author excluded,
12+
and a chart drawn over the whole dataset looks like a working chart.
13+
14+
The refusal never reached the author. It landed as `500 ANALYTICS_QUERY_FAILED`
15+
— read as "the platform is broken" rather than "your filter has a typo", and
16+
counted by ops alerting as a 5xx. The identical mistake on `find()` has answered
17+
`400 INVALID_FILTER` since #3948, so one authoring error had two wire shapes,
18+
chosen by which face happened to catch it.
19+
20+
**One defect, two halves — either alone leaves it unfixed.**
21+
22+
- **Producer** (`filter-normalizer.ts`): seven of its nine refusals were bare
23+
`throw new Error(…)` carrying no `code`/`status`. All nine now go through the
24+
`invalidFilterError` helper #5334 introduced (`INVALID_FILTER` / 400), which
25+
becomes the module's only way to refuse.
26+
- **Consumer** (`rest-server.ts`, `POST /analytics/dataset/query`): the catch
27+
discarded `error.code` / `error.status` and re-derived the classification from
28+
a hardcoded list of message substrings — so a producer that took ADR-0112
29+
seriously was punished for it. It now reads the envelope **first**; the
30+
substring list is demoted to a fallback for the families that still carry no
31+
envelope.
32+
33+
**Observable behaviour change — read this if you alert or retry on status.**
34+
The same request that returned `500 ANALYTICS_QUERY_FAILED` now returns
35+
`400 INVALID_FILTER` (and, for two neighbouring conditions whose producers
36+
already declared an envelope this route was discarding, `400 INVALID_FIELD` for
37+
a measure over a field the object does not have, `404 CUBE_NOT_FOUND` for an
38+
unregistered cube). Monitoring that counted these as server faults will see the
39+
5xx rate drop and a 4xx rate appear; a client that retries on 5xx will stop
40+
retrying a request that could only ever fail the same way. Both are the intended
41+
correction — the condition was always the caller's mistake — but they are
42+
visible, so they are stated rather than buried.
43+
44+
**Which inputs are refused did not change.** This changes the SHAPE of the
45+
error and nothing about the judgement that produced it: no refusal condition
46+
was touched, no input that used to compile now refuses, and no input that used
47+
to refuse now compiles. That claim is pinned input-by-input (refusals *and*
48+
accepted inputs with their compiled trees) in
49+
`filter-refusal-envelope.test.ts`, which is green both before and after the
50+
change — only the envelope assertions move.
51+
52+
The message-substring list survives on purpose. All six of its entries were
53+
re-verified as bare `Error`s (`dataset-compiler.ts`, `native-sql-strategy.ts`,
54+
`dataset-executor.ts`, `read-scope-sql.ts`), so deleting it would regress those
55+
families from `400 DATASET_INVALID` to 500. It is a placeholder for their
56+
enveloping, not a second classification mechanism, and it is now documented as
57+
such: a new refusal should carry a `code`/`status` and be served by the
58+
envelope branch for free. The passthrough is deliberately **4xx-only** and
59+
requires **both** `code` and `status`, so an internal fault can never be
60+
re-labelled as the caller's fault, and this route never invents a code a
61+
producer failed to supply.

packages/rest/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"devDependencies": {
3232
"@objectstack/metadata-protocol": "workspace:*",
3333
"@objectstack/objectql": "workspace:*",
34+
"@objectstack/service-analytics": "workspace:*",
3435
"@types/node": "^26.1.2",
3536
"typescript": "^6.0.3",
3637
"vitest": "^4.1.10"
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5352] `/analytics/dataset/query` answers a filter refusal as the caller's
5+
* mistake (`400 INVALID_FILTER`), not as a platform fault
6+
* (`500 ANALYTICS_QUERY_FAILED`).
7+
*
8+
* ## The seam, and why this file boots the REAL analytics service
9+
*
10+
* The defect had two halves and either one alone reads as fixed:
11+
*
12+
* - **B** — `filter-normalizer.ts` refused a malformed filter with a bare
13+
* `throw new Error(…)`, carrying no `code`/`status`.
14+
* - **A** — this route's catch discarded `error.code` / `error.status` and
15+
* re-derived the classification from a hardcoded list of message
16+
* substrings, which no filter refusal matched.
17+
*
18+
* So a unit test on either side can be green while an author still sees a 500:
19+
* mock the service and half B is assumed; assert on the thrown error and half A
20+
* is assumed. `analytics-routes.test.ts` next door mocks `queryDataset` because
21+
* its subjects (dataset resolution, decoration stripping, schema validation)
22+
* live entirely on this side of the seam. This file's subject IS the seam, so
23+
* the provider is a real `AnalyticsService` and the error crossing into the
24+
* catch is the real one `normalizeAnalyticsFilterTree` throws — nothing here
25+
* asserts a shape it also constructs.
26+
*
27+
* `runtimeFilter` is the load-bearing input: it is the presentation-scope
28+
* filter a dashboard widget carries, i.e. exactly the field an author typos.
29+
*
30+
* ## What must NOT change
31+
*
32+
* Reading the envelope makes this route classify on what the error SAYS about
33+
* itself. Three regressions would each be worse than the bug:
34+
*
35+
* 1. The message list still classifies the families that remain bare `Error`s
36+
* (the dataset compiler, `read-scope-sql`, the executor) — all six of its
37+
* entries were re-verified unenveloped at the time of #5352, so deleting
38+
* it would regress them from `400 DATASET_INVALID` to 500.
39+
* 2. A genuine internal fault must still be a 500 with its `logError` line —
40+
* "read the envelope" must not become "call everything a 400".
41+
* 3. A 5xx-status error is NOT passed through, so an internal fault can never
42+
* be re-labelled with a code of its own choosing.
43+
*/
44+
45+
import { describe, it, expect, vi } from 'vitest';
46+
import type { Logger } from '@objectstack/spec/contracts';
47+
import { AnalyticsService } from '@objectstack/service-analytics';
48+
import { RestServer } from './rest-server';
49+
50+
// ── harness ──────────────────────────────────────────────────────────────────
51+
52+
function mockServer() {
53+
return {
54+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
55+
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
56+
};
57+
}
58+
function mockProtocol() {
59+
return {
60+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
61+
getMetaTypes: vi.fn().mockResolvedValue([]),
62+
getMetaItems: vi.fn().mockResolvedValue([]),
63+
};
64+
}
65+
function mockRes() {
66+
const res: any = { statusCode: 200, body: undefined };
67+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
68+
res.json = vi.fn((b: any) => { res.body = b; return res; });
69+
res.end = vi.fn(() => res);
70+
return res;
71+
}
72+
73+
/** A single-object dataset — no `include`, so nothing here needs a join. */
74+
const dataset = {
75+
name: 'pipeline',
76+
label: 'Pipeline',
77+
object: 'crm_opportunity',
78+
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
79+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
80+
};
81+
const selection = { dimensions: ['stage'], measures: ['revenue'] };
82+
83+
/** Build a RestServer over an analytics provider (positional arg #15). */
84+
function buildRoute(analyticsProvider?: any) {
85+
const rest = new RestServer(
86+
mockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any,
87+
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
88+
undefined, undefined, undefined, undefined,
89+
analyticsProvider,
90+
);
91+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
92+
rest.registerRoutes();
93+
return rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query'))!;
94+
}
95+
96+
/**
97+
* A REAL `AnalyticsService` on the ObjectQL aggregate path.
98+
*
99+
* `executeAggregate` returns a fixed bucket, so a query that gets far enough to
100+
* touch data succeeds — which is what makes the refusal cases meaningful: they
101+
* fail on the FILTER, on a route that demonstrably answers 200 otherwise.
102+
*/
103+
function realAnalytics(): AnalyticsService {
104+
const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} };
105+
return new AnalyticsService({
106+
logger: silent,
107+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
108+
executeAggregate: async () => [{ stage: 'won', revenue: 100 }],
109+
isRegisteredObject: () => true,
110+
});
111+
}
112+
113+
/** POST a body at the route and return the recorded response. */
114+
async function post(route: any, body: unknown) {
115+
const res = mockRes();
116+
await route.handler({ method: 'POST', params: {}, headers: {}, body } as any, res);
117+
return res;
118+
}
119+
120+
// ─────────────────────────────────────────────────────────────────────────────
121+
122+
describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the caller as 400', () => {
123+
it('a misspelled operator in a widget filter → 400 INVALID_FILTER (was 500 ANALYTICS_QUERY_FAILED)', async () => {
124+
const route = buildRoute(async () => realAnalytics());
125+
const res = await post(route, {
126+
dataset,
127+
selection: { ...selection, runtimeFilter: { stage: { $sortOf: 'won' } } },
128+
});
129+
130+
expect(res.statusCode).toBe(400);
131+
expect(res.body.code).toBe('INVALID_FILTER');
132+
// The two halves of the defect, asserted as the defect rather than as the fix.
133+
expect(res.statusCode).not.toBe(500);
134+
expect(res.body.code).not.toBe('ANALYTICS_QUERY_FAILED');
135+
// The message still names the operator, so the author can act on it.
136+
expect(String(res.body.message)).toMatch(/Unsupported filter operator "\$sortOf" on "stage"/);
137+
});
138+
139+
it('a POSITIVE control: the same wiring, a valid filter → 200 with rows', async () => {
140+
// Without this, the case above could pass for any reason that makes the
141+
// route 400 — including the pipeline never reaching the filter normalizer.
142+
const route = buildRoute(async () => realAnalytics());
143+
const res = await post(route, {
144+
dataset,
145+
selection: { ...selection, runtimeFilter: { stage: { $eq: 'won' } } },
146+
});
147+
148+
expect(res.statusCode).toBe(200);
149+
expect(res.body.rows).toEqual([{ stage: 'won', revenue: 100 }]);
150+
});
151+
152+
// The other refusal spellings an author reaches through the same field. Each
153+
// is a real refusal from the real normalizer, crossing the real seam.
154+
const REFUSALS: Array<{ name: string; runtimeFilter: unknown; message: RegExp }> = [
155+
{
156+
name: 'a field constraint with zero operators (#5240)',
157+
runtimeFilter: { stage: {} },
158+
message: /carries a field constraint with zero operators/,
159+
},
160+
{
161+
name: 'a $between with one bound',
162+
runtimeFilter: { amount: { $between: [10] } },
163+
message: /needs a two-element \[min, max\] array/,
164+
},
165+
{
166+
name: 'an empty $or',
167+
runtimeFilter: { $or: [] },
168+
message: /"\$or" requires a non-empty array/,
169+
},
170+
{
171+
name: 'an $or branch that is not a filter object',
172+
runtimeFilter: { $or: [{ stage: 'won' }, 'nope'] },
173+
message: /branches must be filter objects/,
174+
},
175+
{
176+
name: 'a $not of a non-object',
177+
runtimeFilter: { $not: 5 },
178+
message: /"\$not" requires a filter object/,
179+
},
180+
{
181+
name: 'an unsupported top-level operator',
182+
runtimeFilter: { $nor: [{ stage: 'won' }] },
183+
message: /Unsupported top-level filter operator "\$nor"/,
184+
},
185+
];
186+
187+
for (const c of REFUSALS) {
188+
it(`${c.name} → 400 INVALID_FILTER`, async () => {
189+
const route = buildRoute(async () => realAnalytics());
190+
const res = await post(route, { dataset, selection: { ...selection, runtimeFilter: c.runtimeFilter } });
191+
expect(res.statusCode).toBe(400);
192+
expect(res.body.code).toBe('INVALID_FILTER');
193+
expect(String(res.body.message)).toMatch(c.message);
194+
});
195+
}
196+
});
197+
198+
describe('[#5352] the message-sniffing fallback still classifies the families that carry no envelope', () => {
199+
// Every entry of the route's regex list, produced as its owner produces it:
200+
// a bare `Error`. Re-verified unenveloped while #5352 was implemented —
201+
// `dataset-compiler.ts`, `native-sql-strategy.ts`, `dataset-executor.ts` and
202+
// `read-scope-sql.ts` all `throw new Error(…)` with no `code`/`status` — so
203+
// the list is the only thing standing between them and a 500.
204+
const FALLBACK: Array<{ name: string; message: string }> = [
205+
{
206+
name: 'dataset-compiler: undeclared relationship path',
207+
message: 'dimension "region" references relationship path "account" via "account.region", but "account" is not declared in the dataset\'s `include`.',
208+
},
209+
{
210+
name: 'native-sql-strategy: join outside the allowlist',
211+
message: '[NativeSQLStrategy] join "account" is not backed by a declared relationship on cube "pipeline".',
212+
},
213+
{
214+
name: 'dataset-compiler: aggregate outside the v1 runtime',
215+
message: '[dataset-compiler] measure "x" uses aggregate "median" which is not supported by the v1 dataset runtime (supported: sum, avg).',
216+
},
217+
{
218+
name: 'read-scope-sql: fail-closed read scope',
219+
message: '[read-scope-sql] unsupported operator "$regex" on "owner" (fail-closed).',
220+
},
221+
{
222+
name: 'dataset-executor: order key that is not selected',
223+
message: '[dataset-executor] order key(s) "profit" — not a selected dimension or measure. Selectable here: stage, revenue.',
224+
},
225+
{
226+
name: 'dataset-executor: totals grouping outside the selection',
227+
message: '[dataset-executor] totals grouping [region] is not a subset of the selected dimensions — unknown: region.',
228+
},
229+
];
230+
231+
for (const c of FALLBACK) {
232+
it(`${c.name} → still 400 DATASET_INVALID`, async () => {
233+
const route = buildRoute(async () => ({ queryDataset: vi.fn().mockRejectedValue(new Error(c.message)) }));
234+
const res = await post(route, { dataset, selection });
235+
expect(res.statusCode).toBe(400);
236+
expect(res.body.code).toBe('DATASET_INVALID');
237+
});
238+
}
239+
});
240+
241+
describe('[#5352] reading the envelope did not turn every failure into a 400', () => {
242+
it('a genuine internal fault is still 500 ANALYTICS_QUERY_FAILED', async () => {
243+
// Nothing filter-shaped, no envelope, no message the list matches — the
244+
// class the 500 exists for.
245+
const route = buildRoute(async () => ({
246+
queryDataset: vi.fn().mockRejectedValue(new Error('ECONNRESET: socket hang up while reading from the analytics datasource')),
247+
}));
248+
const res = await post(route, { dataset, selection });
249+
250+
expect(res.statusCode).toBe(500);
251+
expect(res.body.code).toBe('ANALYTICS_QUERY_FAILED');
252+
});
253+
254+
it('a 5xx-status error is NOT passed through — an internal fault keeps the 500 envelope', async () => {
255+
// Deliberate asymmetry: the passthrough is 4xx-only, so a producer cannot
256+
// re-label a server fault with a code of its own and slip past the
257+
// `logError` line that makes it visible to operators.
258+
const err = Object.assign(new Error('upstream analytics warehouse is unavailable'), {
259+
code: 'WAREHOUSE_UNAVAILABLE',
260+
status: 503,
261+
});
262+
const route = buildRoute(async () => ({ queryDataset: vi.fn().mockRejectedValue(err) }));
263+
const res = await post(route, { dataset, selection });
264+
265+
expect(res.statusCode).toBe(500);
266+
expect(res.body.code).toBe('ANALYTICS_QUERY_FAILED');
267+
});
268+
269+
it('a HALF envelope (4xx status, no code) is not honoured — this route invents no code', async () => {
270+
// ADR-0112's point is that the PRODUCER names the condition. A status with
271+
// no code is a producer bug; answering it with a code chosen here would be
272+
// the consumer-side leniency the ADR exists to remove, and would hide the
273+
// bug behind a plausible wire shape.
274+
const err = Object.assign(new Error('something was rejected, unspecified'), { status: 400 });
275+
const route = buildRoute(async () => ({ queryDataset: vi.fn().mockRejectedValue(err) }));
276+
const res = await post(route, { dataset, selection });
277+
278+
expect(res.statusCode).toBe(500);
279+
expect(res.body.code).toBe('ANALYTICS_QUERY_FAILED');
280+
});
281+
});
282+
283+
describe('[#5352] the envelope is read generically — not by an allowlist of codes', () => {
284+
// A code-specific branch (`if (code === 'INVALID_FILTER')`) would be the
285+
// message-sniffing anti-pattern in new clothes. These two producers already
286+
// DECLARE their answer in their own doc comments — `INVALID_FIELD`/400 so the
287+
// analytics face can answer a typo'd measure the way `/data` does (#4437),
288+
// `CUBE_NOT_FOUND`/404 so "no such cube" does not reach the driver as a table
289+
// (#3867) — and this route was discarding both.
290+
it('a measure over a field the object does not have → 400 INVALID_FIELD (#4437)', async () => {
291+
const err = Object.assign(new Error("Measure 'ghost_sum' on cube 'pipeline' aggregates field 'ghost', which object 'crm_opportunity' does not have."), {
292+
code: 'INVALID_FIELD',
293+
status: 400,
294+
});
295+
const route = buildRoute(async () => ({ queryDataset: vi.fn().mockRejectedValue(err) }));
296+
const res = await post(route, { dataset, selection });
297+
298+
expect(res.statusCode).toBe(400);
299+
expect(res.body.code).toBe('INVALID_FIELD');
300+
});
301+
302+
it('an unregistered cube → 404 CUBE_NOT_FOUND (#3867)', async () => {
303+
const err = Object.assign(new Error("Cube 'nope' not found: no cube is registered under that name."), {
304+
code: 'CUBE_NOT_FOUND',
305+
status: 404,
306+
});
307+
const route = buildRoute(async () => ({ queryDataset: vi.fn().mockRejectedValue(err) }));
308+
const res = await post(route, { dataset, selection });
309+
310+
expect(res.statusCode).toBe(404);
311+
expect(res.body.code).toBe('CUBE_NOT_FOUND');
312+
});
313+
});

0 commit comments

Comments
 (0)