Skip to content

Commit 9cd0e29

Browse files
committed
fix(drivers): the uncompilable-filter refusal speaks INVALID_FILTER, without the driver prefix (#4436)
Completes the WIP commit: adds the remaining sql-driver throw sites and the regression tests for both backends. #4209/#4029/#3948 settled the POSTURE — a filter carrying an operator the driver cannot compile is refused instead of silently matching every row. What was missing is the refusal's IDENTITY on the wire. The driver threw a bare `Error`, so `mapDataError` fell through to its default branch and served a body whose only key was `error`: GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}} → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"} Two contract breaks in one body — no `error.code` at all on a route whose sibling rejections all speak the ADR-0112 catalogue, and the driver-internal `[sql-driver]` prefix on the wire, which is what the #3867 sanitiser exists to stop. Fixed at the throw site (PD #12), not by teaching the REST layer to guess: both drivers now refuse through an `unsupportedFilterError` helper that stamps `code = StandardErrorCode.enum.INVALID_FILTER` — the constant, so a catalogue rename breaks the compile — and `status = 400`. `INVALID_FILTER` is the same code `metadata-protocol` already emits when a filter fails to parse upstream (`malformedFilterArrayError` / `unusableFilterError`): one condition, one wire code, however the caller reached it. The `status` also puts the rejection on `isExpectedQueryRejection`, so a client mistake stops being logged as an unhandled server error. Applied to every filter-COMPILATION refusal in both backends, not only the one branch the issue names: unsupported operator ($-object, legacy triple), unrecognised logical keyword, unrecognised element type, and a `between` / `$between` operand that is not a two-element array. They are the same envelope defect on adjacent lines, and #3948 made the two drivers agree that an uncompilable filter is a refusal — so their refusal envelopes have to agree too, or the cross-driver parity this repo relies on is false where it matters. Tests: new `sql-driver-filter-refusal-envelope.test.ts` (8) and `memory-filter-refusal-envelope.test.ts` (5) pin `code`, `status`, the absence of the internal prefix, and that the actionable operator/field/vocabulary detail survives. Full suites green: driver-sql 623 passed, driver-memory 286 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
1 parent cb1e01e commit 9cd0e29

3 files changed

Lines changed: 194 additions & 5 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#4436] The memory driver's filter refusals carry the SAME wire identity as
5+
* driver-sql's.
6+
*
7+
* #3948 made the two backends agree that an uncompilable filter is a refusal
8+
* rather than a silent match-everything. The refusal's ENVELOPE has to agree
9+
* too, or a suite that swaps the memory driver for SQLite sees a coded 400 on
10+
* one backend and a bare `{ error }` on the other — and the cross-driver parity
11+
* this driver exists to provide would be false exactly where it is load-bearing.
12+
*
13+
* Twin of `driver-sql/src/sql-driver-filter-refusal-envelope.test.ts`; the
14+
* rationale lives there.
15+
*/
16+
17+
import { describe, it, expect, beforeEach } from 'vitest';
18+
import { InMemoryDriver } from './memory-driver.js';
19+
import type { FilterCondition } from '@objectstack/spec/data';
20+
21+
interface WireBearingError extends Error {
22+
code?: string;
23+
status?: number;
24+
}
25+
26+
async function refusalOf(run: () => Promise<unknown>): Promise<WireBearingError> {
27+
try {
28+
await run();
29+
} catch (e) {
30+
return e as WireBearingError;
31+
}
32+
throw new Error('expected the driver to refuse this filter, but it resolved');
33+
}
34+
35+
describe('[#4436] InMemoryDriver filter refusals carry INVALID_FILTER and leak no driver prefix', () => {
36+
let driver: InMemoryDriver;
37+
38+
beforeEach(async () => {
39+
driver = new InMemoryDriver();
40+
await driver.syncSchema?.({
41+
name: 'deal',
42+
fields: {
43+
id: { type: 'text', name: 'id' },
44+
stage: { type: 'text', name: 'stage' },
45+
amount: { type: 'number', name: 'amount' },
46+
},
47+
} as any);
48+
await driver.create('deal', { id: '1', stage: 'won', amount: 10 });
49+
});
50+
51+
const find = (where: unknown) =>
52+
driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition });
53+
54+
const cases: Array<[string, unknown, string]> = [
55+
['unsupported operator in a condition array', [['stage', 'sounds_like', 'won']], 'sounds_like'],
56+
['bare comparison triple', ['close_date', 'before', '2024-01-01'], 'close_date'],
57+
['filter element of the wrong type', [42], 'number'],
58+
['`between` with a bad operand', [['amount', 'between', 5]], 'between'],
59+
];
60+
61+
for (const [name, where, needle] of cases) {
62+
it(`${name} → 400 INVALID_FILTER, no prefix`, async () => {
63+
const err = await refusalOf(() => find(where));
64+
expect(err.code).toBe('INVALID_FILTER');
65+
expect(err.status).toBe(400);
66+
expect(err.message).not.toContain('[driver-memory]');
67+
expect(err.message).toContain(needle);
68+
});
69+
}
70+
71+
it('a compilable filter is unaffected', async () => {
72+
const rows = await find({ stage: 'won' });
73+
expect(rows.map((r: any) => r.id)).toEqual(['1']);
74+
});
75+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#4436] The uncompilable-filter refusal must carry an ADR-0112 wire identity.
5+
*
6+
* #4209/#4029/#3948 settled the POSTURE — a filter the driver cannot compile is
7+
* refused instead of silently matching every row. What was still missing is the
8+
* refusal's IDENTITY on the wire.
9+
*
10+
* The driver threw a bare `Error`, so it carried no `code` and no `status`.
11+
* `@objectstack/rest`'s `mapDataError` therefore fell all the way through to its
12+
* default branch — `{ status: 400, body: { error: raw } }` — and served a body
13+
* whose only key was `error`:
14+
*
15+
* ```
16+
* GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}}
17+
* → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"}
18+
* ```
19+
*
20+
* Two contract breaks in one body: no `error.code` at all, on a route whose
21+
* sibling rejections all speak the catalogued vocabulary (`INVALID_FIELD`,
22+
* `INVALID_FILTER`, `RECORD_NOT_FOUND`); and the driver-internal `[sql-driver]`
23+
* prefix on the wire, which is precisely what the #3867 sanitiser exists to
24+
* stop.
25+
*
26+
* These tests pin BOTH halves at the throw site, because that is where the fix
27+
* lives (PD #12 — the producer declares its own refusal; the REST layer is not
28+
* patched to guess).
29+
*/
30+
31+
import { describe, it, expect, beforeEach } from 'vitest';
32+
import { SqlDriver } from '../src/index.js';
33+
import type { FilterCondition } from '@objectstack/spec/data';
34+
35+
/** The shape `mapDataError` / `sendError` read off a thrown driver error. */
36+
interface WireBearingError extends Error {
37+
code?: string;
38+
status?: number;
39+
}
40+
41+
async function refusalOf(run: () => Promise<unknown>): Promise<WireBearingError> {
42+
try {
43+
await run();
44+
} catch (e) {
45+
return e as WireBearingError;
46+
}
47+
throw new Error('expected the driver to refuse this filter, but it resolved');
48+
}
49+
50+
describe('[#4436] SqlDriver filter refusals carry INVALID_FILTER and leak no driver prefix', () => {
51+
let driver: SqlDriver;
52+
53+
beforeEach(async () => {
54+
driver = new SqlDriver({
55+
client: 'better-sqlite3',
56+
connection: { filename: ':memory:' },
57+
useNullAsDefault: true,
58+
});
59+
await driver.initObjects([
60+
{
61+
name: 'deal',
62+
fields: {
63+
id: { type: 'text', name: 'id' },
64+
stage: { type: 'text', name: 'stage' },
65+
amount: { type: 'number', name: 'amount' },
66+
},
67+
} as any,
68+
]);
69+
await driver.create('deal', { id: '1', stage: 'won', amount: 10 });
70+
});
71+
72+
const find = (where: unknown) =>
73+
driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition });
74+
75+
// The issue's own repro, at the layer that produces the envelope.
76+
it('the $-object unsupported-operator branch — the exact shape #4436 reported', async () => {
77+
const err = await refusalOf(() => find({ stage: { $bogusop: 'x' } }));
78+
expect(err.code).toBe('INVALID_FILTER');
79+
expect(err.status).toBe(400);
80+
expect(err.message).not.toContain('[sql-driver]');
81+
// The actionable half survives the sanitisation — a caller must still be
82+
// able to see WHICH operator on WHICH field, and what is accepted instead.
83+
expect(err.message).toContain('$bogusop');
84+
expect(err.message).toContain('stage');
85+
expect(err.message).toContain('$startsWith');
86+
});
87+
88+
// Every filter-COMPILATION refusal in this driver answers the same way. They
89+
// are one condition — "this filter cannot run" — and ADR-0112's rule is one
90+
// condition, one wire code, however the caller reached it.
91+
const cases: Array<[string, unknown, string]> = [
92+
['legacy triple, unsupported operator', [['stage', 'sounds_like', 'won']], 'sounds_like'],
93+
['bare comparison triple', ['close_date', 'before', '2024-01-01'], 'close_date'],
94+
['filter element of the wrong type', [42], 'number'],
95+
['null filter element', [null], 'null'],
96+
['legacy `between` with a bad operand', [['amount', 'between', 5]], 'between'],
97+
['$between with a bad operand', { amount: { $between: 5 } }, '$between'],
98+
];
99+
100+
for (const [name, where, needle] of cases) {
101+
it(`${name} → 400 INVALID_FILTER, no prefix`, async () => {
102+
const err = await refusalOf(() => find(where));
103+
expect(err.code).toBe('INVALID_FILTER');
104+
expect(err.status).toBe(400);
105+
expect(err.message).not.toContain('[sql-driver]');
106+
expect(err.message).toContain(needle);
107+
});
108+
}
109+
110+
it('a compilable filter is unaffected', async () => {
111+
const rows = await find({ stage: 'won' });
112+
expect(rows.map((r: any) => r.id)).toEqual(['1']);
113+
});
114+
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5115,8 +5115,8 @@ export class SqlDriver implements IDataDriver {
51155115
// never converted it and the raw array arrived as `where`. Skipping it
51165116
// (the old behaviour) emitted NO predicate at all: the caller asked to
51175117
// filter and silently got every row. Fail loudly instead. #3948.
5118-
throw new Error(
5119-
`[sql-driver] Unrecognized filter operator "${item}" in a comparison triple. ` +
5118+
throw unsupportedFilterError(
5119+
`Unrecognized filter operator "${item}" in a comparison triple. ` +
51205120
`A filter array is either a logical node (["and"|"or", …]) or nested ` +
51215121
`conditions ([[field, op, value], …]); a bare [field, op, value] only ` +
51225122
`reaches the driver when its operator is outside @objectstack/spec ` +
@@ -5171,8 +5171,8 @@ export class SqlDriver implements IDataDriver {
51715171
// branches and was dropped, so a malformed element silently narrowed
51725172
// nothing. Same reasoning as above: an unapplied filter must not look
51735173
// like a satisfied one. #3948.
5174-
throw new Error(
5175-
`[sql-driver] Unrecognized filter element of type "${item === null ? 'null' : typeof item}" — ` +
5174+
throw unsupportedFilterError(
5175+
`Unrecognized filter element of type "${item === null ? 'null' : typeof item}" — ` +
51765176
`expected a logical keyword ("and"/"or") or a condition array. ` +
51775177
`Filter was: ${JSON.stringify(filters)}`,
51785178
);
@@ -5485,7 +5485,7 @@ export class SqlDriver implements IDataDriver {
54855485
case '$between': {
54865486
const arr = Array.isArray(coerced) ? coerced : [];
54875487
if (arr.length !== 2) {
5488-
throw new Error(`[sql-driver] operator "$between" on field "${field}" requires a [min, max] value array.`);
5488+
throw unsupportedFilterError(`Operator "$between" on field "${field}" requires a [min, max] value array.`);
54895489
}
54905490
(builder as any)[logicalOp === 'or' ? 'orWhereBetween' : 'whereBetween'](field, arr as [any, any]);
54915491
break;

0 commit comments

Comments
 (0)