diff --git a/.changeset/collection-operator-scalar-comparand-400.md b/.changeset/collection-operator-scalar-comparand-400.md new file mode 100644 index 0000000000..02981a8e7f --- /dev/null +++ b/.changeset/collection-operator-scalar-comparand-400.md @@ -0,0 +1,15 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 DATABASE_ERROR + +`FieldOperatorsSchema` 声明 `$in` / `$nin` 的比较值是数组、`$between` 是 `[min, max]` 二元组,但入口处没有任何一层强制这条声明:`isFilterAST` 只看算子,`parseFilterAST` 照单下降,于是 `['status', 'not_in', 'done']` 变成 `{ status: { $nin: 'done' } }` 一路走到驱动。 + +**行为变化(用户可见)**:此前 `driver-sql` 把标量交给 `whereIn(field, scalar)`,答 **500 `DATABASE_ERROR`** —— 用服务端故障码报告一个调用方能自己改好的过滤器,且不说明是哪个算子、哪个字段、该写成什么。现在引擎在唯一收口点拒收,答 **400 `INVALID_FILTER`**,信息点名算子(同时给出 `not_in` / `nin` / `notin` 这类作者实际书写的拼法)、字段、收到的值与位置、以及可直接粘贴的正确形状,并声明该过滤器**未被应用**。 + +覆盖两道门:直接调用引擎(`FilterArray` 下降路径)与 HTTP 面(协议层已自行下降成 `FilterCondition` 对象后再交给引擎)—— 后者正是本问题实测到的那道门。`find` / `findOne` / `count` / `aggregate` / `update` / `delete` 六个入口一致。 + +`$between` 的非二元组比较值一并收在同一处:`driver-sql` 与 `driver-memory` 各自已经拒收(措辞保持逐字一致),`driver-mongodb` 的分支则直接落空、不发射区间谓词 —— 收在收口点后三家答案一致。 + +**不变的**:`$in: []` / `$nin: []` 仍是合法谓词(分别表示「不匹配任何行」与「匹配所有行」);列表**成员**的类型不在此处复判(那是 #5234,另一个面);非集合算子的标量比较值不受影响,包括 `$gt` 的 ISO 日期字符串这类 `FieldOperatorsSchema` 声明更严、而各后端一致接受的形状。 diff --git a/packages/objectql/src/engine-filter-array-lowering.test.ts b/packages/objectql/src/engine-filter-array-lowering.test.ts index 458271c8cd..a671af09e7 100644 --- a/packages/objectql/src/engine-filter-array-lowering.test.ts +++ b/packages/objectql/src/engine-filter-array-lowering.test.ts @@ -26,8 +26,32 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; +import type { + EngineAggregateOptions, + EngineCountOptions, + EngineQueryOptions, +} from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; +/** + * [#4918] `FilterArray` on `where` is off-contract BY DECLARATION, and these + * tests exist to drive it: `EngineQueryOptions.where` is a `FilterCondition` / + * `Record< string, unknown >`, which an array is not assignable to, because + * `FilterArray` is INPUT-ONLY authoring sugar the spec deliberately excludes + * (#5285). So a test that hands the engine one has to say so, and + * `as unknown as EngineQueryOptions` is how: it names the contract being + * bypassed, keeps the rest of the call type-checked, and greps as an + * intentional act — none of which a bare `as any` does. + * + * Deliberately NOT used for the malformed-COMPARAND cases below + * (`{ stage: { $nin: 'won' } }`). Those are ordinary objects that `tsc` + * accepts, because `where` is declared loosely on purpose — which is the whole + * reason the runtime gate this file pins has to exist. Erasing them would hide + * that they are type-legal, which is the point. + */ +const asFilterArrayQuery = (where: unknown): EngineQueryOptions => + ({ where }) as unknown as EngineQueryOptions; + const deal = { name: 'deal', label: 'Deal', @@ -304,6 +328,203 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158) expect(reads).toHaveLength(0); }); + // ── #5869: the list-shaped operators' comparands ────────────────────── + // + // `isFilterAST` vouches for the OPERATOR and nothing else, and + // `parseFilterAST` lowers whatever comparand it is handed. So the shapes + // below passed both, reached the driver, and — on driver-sql, via + // `whereIn(field, scalar)` — came back as `500 DATABASE_ERROR`: a + // server-fault code for a filter the caller can fix, naming neither the + // operator nor the field. Same collection point, same envelope as the + // refusals above. + + it.each([ + ['not_in', [['stage', 'not_in', 'won']]], + ['nin', [['stage', 'nin', 'won']]], + ['notin', [['stage', 'notin', 'won']]], + ['in', [['stage', 'in', 'won']]], + ])('refuses a scalar comparand on the collection operator %s', async (_op, where) => { + await expect(engine.find('deal', asFilterArrayQuery(where))) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + // Nothing ran: a refused filter must not reach the driver at all, or the + // 400 would be describing a query that already returned rows. + expect(reads).toHaveLength(0); + }); + + it('the refusal NAMES the operator, the field and the expected shape (#5346/#5348 wording)', async () => { + const err = await engine.find('deal', asFilterArrayQuery([['stage', 'not_in', 'won']])) + .then(() => null, (e: any) => e); + + expect(err).not.toBeNull(); + // The entry point that refused, matching the sibling refusals above. + expect(err.message).toMatch(/^find\('deal'\): /); + // The operator, in the lowered spelling… + expect(err.message).toMatch(/Operator "\$nin"/); + // …and in the spellings an author actually types on a ViewFilterRule — + // nobody writes `$nin` into metadata, so a refusal naming only the lowered + // form sends them looking for a key their file does not contain. + expect(err.message).toMatch(/not_in/); + // The member. + expect(err.message).toMatch(/field "stage"/); + // What was received, and where. + expect(err.message).toMatch(/Received string \("won"\)/); + expect(err.message).toMatch(/where\.stage\.\$nin/); + // The expected shape, as a value the caller can paste. + expect(err.message).toMatch(/\["won"\]/); + // The alternative, for the caller who meant a scalar comparison. + expect(err.message).toMatch(/"!=" \(\$ne\)/); + // And the part a status code cannot carry. + expect(err.message).toMatch(/NOT applied/); + expect(err.message).toMatch(/UNFILTERED result set/); + }); + + it('the whole refusal survives the REST boundary — it fits under CLIENT_MESSAGE_MAX', async () => { + // `rest-server.ts` truncates a declared-4xx message at 500 chars before it + // reaches the client (#5423 made it a truncation rather than a swap). The + // "NOT applied" sentence is the part a caller cannot infer from a status + // code, and it sits at the END — so a message that overflows loses exactly + // the sentence the refusal exists to deliver. Pinned here rather than + // trusted, because the bound lives in another package. + const CLIENT_MESSAGE_MAX = 500; + for (const where of [ + [['stage', 'not_in', 'won']], + [['stage', 'in', 'won']], + [['amount', 'between', 5]], + ]) { + const err = await engine.find('deal', asFilterArrayQuery(where)) + .then(() => null, (e: any) => e); + expect(err.message.length, JSON.stringify(where)).toBeLessThan(CLIENT_MESSAGE_MAX); + expect(err.message, JSON.stringify(where)).toMatch(/UNFILTERED result set/); + } + }); + + it('refuses through the OBJECT door too — the door #5869 was measured through', async () => { + // The protocol/HTTP face runs its own `isFilterAST` → `parseFilterAST` and + // hands the engine an already-lowered FilterCondition, so the array branch + // above never sees a wire query. This is that shape, arriving as an object. + // NOT erased: `where` is declared `Record< string, unknown >`, so `tsc` + // accepts a malformed comparand. That it type-checks and still has to be + // refused at runtime is exactly why this gate exists. + await expect(engine.find('deal', { where: { stage: { $nin: 'won' } } })) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + await expect(engine.find('deal', { where: { stage: { $in: 'won' } } })) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + }); + + it.each([ + ['null', { stage: { $in: null } }], + ['a number', { amount: { $in: 10 } }], + ['an object', { stage: { $in: { a: 1 } } }], + ])('refuses a comparand that is %s — every non-list, not just strings', async (_l, where) => { + await expect(engine.find('deal', { where })) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + }); + + it('walks into $and / $or / $not — a nested scalar is refused with its own path', async () => { + const err = await engine.find( + 'deal', + asFilterArrayQuery(['and', ['amount', '>', 5], ['stage', 'not_in', 'won']]), + ).then(() => null, (e: any) => e); + expect(err?.status).toBe(400); + expect(err.message).toMatch(/where\.\$and\[1\]\.stage\.\$nin/); + + await expect(engine.find('deal', { where: { $not: { stage: { $in: 'won' } } } })) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + }); + + it('every engine entry point refuses it, not just find()', async () => { + const where = [['stage', 'not_in', 'won']]; + await expect(engine.findOne('deal', asFilterArrayQuery(where))) + .rejects.toMatchObject({ status: 400 }); + await expect(engine.count('deal', { where } as unknown as EngineCountOptions)) + .rejects.toMatchObject({ status: 400 }); + await expect(engine.aggregate('deal', { + where, groupBy: ['stage'], aggregations: [{ function: 'count', field: 'id', alias: 'n' }], + } as unknown as EngineAggregateOptions)).rejects.toMatchObject({ status: 400 }); + await expect(engine.update('deal', { amount: 1 }, { where, multi: true } as any)) + .rejects.toMatchObject({ status: 400 }); + await expect(engine.delete('deal', { where, multi: true } as any)) + .rejects.toMatchObject({ status: 400 }); + // Refused before any of them touched the store. + expect(reads).toHaveLength(0); + expect(writes).toHaveLength(0); + expect(await engine.count('deal')).toBe(3); + }); + + // `$between`'s arity, hoisted to the same seam. driver-sql and driver-memory + // each already refuse this (#5328); driver-mongodb's arm falls through + // without emitting a range predicate. Checking here is what makes the three + // agree — the same reason the collection point exists. + it.each([ + ['a scalar', [['amount', 'between', 5]]], + ['a 1-tuple', [['amount', 'between', [1]]]], + ['a 3-tuple', [['amount', 'between', [1, 2, 3]]]], + ])('refuses a $between comparand that is %s', async (_l, where) => { + await expect(engine.find('deal', asFilterArrayQuery(where))) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' }); + }); + + it('the $between refusal keeps the platform-wide wording and names the field', async () => { + const err = await engine.find('deal', asFilterArrayQuery([['amount', 'between', 5]])) + .then(() => null, (e: any) => e); + // Verbatim leading sentence from driver-sql / driver-memory: one condition, + // one wording, wherever the caller meets it. + expect(err.message).toMatch( + /Operator "\$between" on field "amount" requires a \[min, max\] value array\./, + ); + expect(err.message).toMatch(/where\.amount\.\$between/); + }); + + // ── what must KEEP working: the declared list shapes ─────────────────── + + it('a proper list comparand still reaches the driver untouched', async () => { + await engine.find('deal', asFilterArrayQuery([['stage', 'in', ['won', 'lost']]])); + expect(lastWhere()).toEqual({ stage: { $in: ['won', 'lost'] } }); + + await engine.find('deal', asFilterArrayQuery([['stage', 'not_in', ['lost']]])); + expect(lastWhere()).toEqual({ stage: { $nin: ['lost'] } }); + + await engine.find('deal', asFilterArrayQuery([['amount', 'between', [5, 25]]])); + expect(lastWhere()).toEqual({ amount: { $between: [5, 25] } }); + }); + + it('an EMPTY list is a declared predicate, not a malformed one', async () => { + // `$in: []` matches nothing and `$nin: []` matches everything — both + // drivers say so in as many words. Arity is not this gate's business. + await engine.find('deal', { where: { stage: { $in: [] } } }); + expect(lastWhere()).toEqual({ stage: { $in: [] } }); + await engine.find('deal', { where: { stage: { $nin: [] } } }); + expect(lastWhere()).toEqual({ stage: { $nin: [] } }); + }); + + it('the gate does not re-judge list MEMBERS — that is #5234, on another face', async () => { + // A `$field` reference and a plain object are both legitimate members here; + // this gate asks only whether the comparand is a list at all. + const where = { stage: { $in: [{ $field: 'other' }, 'won'] } }; + await engine.find('deal', { where }); + expect(lastWhere()).toEqual(where); + }); + + it('does not descend into a deep-equality comparand that merely LOOKS like an operator map', async () => { + // `{ $eq: {...} }` holds DATA. A gate that walked into it would refuse a + // stored document whose own key happens to be `$in` — a stricter contract + // than any backend applies. + const where = { stage: { $eq: { $in: 'not-an-operator-here' } } }; + await engine.find('deal', { where }); + expect(lastWhere()).toEqual(where); + }); + + it('a scalar on a NON-collection operator is untouched', async () => { + await engine.find('deal', asFilterArrayQuery([['stage', '!=', 'won']])); + expect(lastWhere()).toEqual({ stage: { $ne: 'won' } }); + // String bounds on a range comparison stay legal — `FieldOperatorsSchema` + // declares `$gt` as number|Date|FieldReference, but ISO strings are what the + // showcase apps send and every backend accepts. This gate enforces the + // three list declarations, not the whole schema. + await engine.find('deal', asFilterArrayQuery([['stage', '>', '2026-01-01']])); + expect(lastWhere()).toEqual({ stage: { $gt: '2026-01-01' } }); + }); + // ── the object form is untouched ────────────────────────────────────── it('a FilterCondition object passes through byte-for-byte', async () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1c99bdc1e2..40f08541ae 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -21,6 +21,7 @@ import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyD // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from '@objectstack/spec/data'; +import { assertListComparandShapes } from './filter-comparand-shape.js'; import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, @@ -451,7 +452,16 @@ function lowerWhereFilterArray( ): T { if (!bag) return bag; const where = (bag as Record).where; - if (!Array.isArray(where)) return bag; + if (!Array.isArray(where)) { + // [#5869] Door 1 lands HERE, not below: the protocol face runs its own + // `isFilterAST` → `parseFilterAST` and hands the engine an already-lowered + // `FilterCondition` object, so a gate on the array branch alone would miss + // every query that arrived over the wire. The comparand check is the same + // one either way — it reads the lowered condition, which is what both doors + // produce. + assertListComparandShapes(object, operation, where); + return bag; + } const lowered: Record = { ...bag }; @@ -488,6 +498,11 @@ function lowerWhereFilterArray( `unfiltered (#5158).`, ); } + // [#5869] Door 2's half of the same check. `isFilterAST` vouched for the + // OPERATOR and `parseFilterAST` lowered it, but neither looks at the + // comparand — `['status', 'not_in', 'done']` lowers to `{status: {$nin: + // 'done'}}` and a scalar `$nin` is what reached the driver as a 500. + assertListComparandShapes(object, operation, condition); lowered.where = condition; return lowered as T; } diff --git a/packages/objectql/src/filter-comparand-shape.ts b/packages/objectql/src/filter-comparand-shape.ts new file mode 100644 index 0000000000..38b8578aad --- /dev/null +++ b/packages/objectql/src/filter-comparand-shape.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5869] The comparand-shape gate for the LIST-SHAPED filter operators, at the + * engine's single filter collection point. + * + * `FieldOperatorsSchema` (`spec/data/filter.zod.ts`) declares three operators + * whose comparand is a LIST rather than a scalar: + * + * ``` + * $in: z.array(z.any()) + * $nin: z.array(z.any()) + * $between: z.tuple([min, max]) + * ``` + * + * Nothing enforced that declaration on the way in. `isFilterAST` checks the + * OPERATOR and the tuple's arity, never the comparand's shape, and + * `parseFilterAST` lowers `['status', 'not_in', 'done']` to + * `{ status: { $nin: 'done' } }` without complaint — so a scalar reached the + * driver, where each backend answered differently: + * + * | comparand | driver-sql | driver-memory | driver-mongodb | + * |:-----------------------|:----------------------|:---------------------|:------------------| + * | `$in` / `$nin` scalar | `whereIn(f, scalar)` → **500 DATABASE_ERROR** | evaluated as-is | emitted as-is | + * | `$between` non-2-tuple | refused, 400 | refused, 400 (#5328) | arm falls through, no range predicate | + * + * The 500 is the reported defect (#5869): a server-fault code for a filter the + * CALLER can fix, with no word about which operator, which field, or what shape + * was expected. It is also reachable from spec-VALID authoring — + * `ViewFilterRuleSchema.value` is `string | number | boolean | null | (string | + * number)[]` and does not constrain the value by operator, so + * `{ field: 'status', operator: 'not_in', value: 'done' }` publishes cleanly and + * 500s on first render. + * + * ## Why here and not in each driver + * + * The table above IS the argument: three backends, three answers, one declared + * contract. `driver-memory` already carries a shape gate + * (`filter-refusal.ts`), but it is that package's own and no other driver reads + * it — and both driver families are under a maintainer investment freeze + * (#5499). The engine's lowering seam is the ONE place every query passes + * through regardless of which door it came in by or which driver it lands on, + * so the gate belongs here and every backend inherits one answer. + * + * ## Both doors, because only one of them carries an array + * + * The refusal that already lives at this seam only inspects `where` when it + * arrives as a `FilterArray`. That is Door 2 (a direct in-process engine call). + * Door 1 — the protocol/HTTP face, and the door #5869 was actually measured + * through — runs its own `isFilterAST` → `parseFilterAST` in + * `metadata-protocol/protocol.ts` and hands the engine an already-lowered + * `FilterCondition` OBJECT. A guard on the array branch alone would therefore + * have left the reported defect exactly where it was. This gate runs on the + * lowered condition, so both doors are covered by one check. + * + * ## Deliberately NOT refused + * + * - **`$in: []` / `$nin: []`.** An empty list is a legitimate, declared + * predicate — "matches nothing" and "matches everything" respectively — and + * both drivers say so in as many words. Arity is not this gate's business; + * only "is it a list at all". + * - **The MEMBER types of any list.** `$between`'s members are checked by + * nobody (`driver-sql` checks arity and nothing else, and #5041 measured the + * member case and deliberately left it — ISO date strings are a legitimate + * range on every backend); `$in`/`$nin` members are #5234's subject, on the + * `driver-sql` object-syntax face, and are not re-judged here. + * - **A field spec with no `$` keys** (`{ author: { name: 'x' } }`) — a + * deep-equality comparand to `driver-memory` and `driver-mongodb` alike. This + * gate does not descend into one, for the same reason `filter-refusal.ts` + * does not: a comparand is data, and a stricter reading here would invent a + * contract no backend agrees with. + */ + +import { StandardErrorCode } from '@objectstack/spec/api'; + +/** + * The operators whose comparand `FieldOperatorsSchema` declares as a list, with + * the authoring spellings that lower to each. + * + * The spellings matter to the message and not to the check: an author writes + * `not_in` on a `ViewFilterRule` and never types `$nin`, so a refusal naming + * only the lowered form sends them looking for a key that is not in their + * metadata. Values are the `AST_OPERATOR_MAP` keys (`spec/data/filter.zod.ts`) + * that map to each `$` operator. + */ +const LIST_COMPARAND_OPERATORS: ReadonlyMap = new Map([ + ['$in', ['in']], + ['$nin', ['nin', 'not_in', 'notin']], + ['$between', ['between']], +]); + +/** What a caller most likely meant when they wrote a scalar. */ +const SCALAR_ALTERNATIVE: ReadonlyMap = new Map([ + ['$in', '"=" ($eq)'], + ['$nin', '"!=" ($ne)'], +]); + +/** + * A plain object — filter STRUCTURE rather than a comparand. + * + * `Date` and other class instances are comparands even though `typeof` calls + * them objects, and an array is a comparand at this position too (it is what a + * list operator is FOR). Same classification `driver-memory`'s gate makes. + */ +function isFilterNode(value: unknown): value is Record { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + && !(value instanceof Date) + ); +} + +/** `string` / `number` / `null` / `object` … — the word the message uses. */ +function describeOperand(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return 'array'; + if (value instanceof Date) return 'Date'; + return typeof value; +} + +/** + * A short, bounded rendering of the offending value. + * + * Bounded because the value came off the wire and a filter comparand can be + * arbitrarily large; the message is for a human reading a 400, not a dump. + * + * The whole message has a second, harder bound: `rest-server.ts` TRUNCATES a + * declared-4xx message at `CLIENT_MESSAGE_MAX` (500) before it reaches the + * client (#5423). Everything a caller needs in order to act — operator, field, + * received value, position, corrected shape — is therefore front-loaded, and + * the test file pins the assembled length under that bound so a later edit + * cannot silently push the tail off the wire. + */ +function shapePreview(value: unknown): string { + let text: string; + try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + return text.length > 60 ? `${text.slice(0, 59)}…` : text; +} + +/** The wire envelope every filter refusal in the platform already uses. */ +function invalidFilterError(message: string): Error { + const err = new Error(message) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_FILTER; + err.status = 400; + return err; +} + +/** + * `$in` / `$nin` whose comparand is not a list at all. + * + * Names the operator (in both the lowered and the authoring spelling), the + * field, the received shape, the position, and the expected shape — the #5346 / + * #5348 wording contract. The closing sentence is the one a caller cannot infer + * from a status code: the query did not run. + */ +function nonListComparandError( + object: string, + operation: string, + op: string, + field: string, + value: unknown, + path: string, +): Error { + const spellings = LIST_COMPARAND_OPERATORS.get(op) ?? []; + const alternative = SCALAR_ALTERNATIVE.get(op); + return invalidFilterError( + `${operation}('${object}'): Operator "${op}" on field "${field}" requires an ARRAY of ` + + `values. Received ${describeOperand(value)} (${shapePreview(value)}) at ${path}. ` + + `"${op}" tests membership of a list — write ${shapePreview([value])} for a single value` + + (alternative ? `, or use ${alternative} to compare against it` : '') + + `. Authoring spellings: ${spellings.join(', ')}. The filter was NOT applied, and an ` + + `unapplied filter would have returned the UNFILTERED result set (#5869).`, + ); +} + +/** + * `$between` whose comparand is not a two-element `[min, max]` array. + * + * Arity only — the exact condition `driver-sql`'s `$between` arm and + * `driver-memory`'s `isBetweenComparand` already apply, hoisted so that the + * backends which check NEITHER stop answering silently. The leading sentence is + * kept verbatim from those two so one condition keeps one wording across the + * platform (#5240's rule, applied across packages rather than within one). + */ +function malformedRangeComparandError( + object: string, + operation: string, + field: string, + value: unknown, + path: string, +): Error { + return invalidFilterError( + `${operation}('${object}'): Operator "$between" on field "${field}" requires a [min, max] ` + + `value array. Received ${describeOperand(value)} (${shapePreview(value)}) at ${path}. ` + + `A range needs exactly two bounds, in order; the authoring spelling that lowers to ` + + `"$between" is "between". The filter was NOT applied, and an unapplied filter would have ` + + `returned the UNFILTERED result set (#5869).`, + ); +} + +/** + * Walk one `FilterCondition` and refuse every list-shaped operator whose + * comparand cannot be one. + * + * Read-only and allocation-free on the overwhelmingly common path (a filter + * with no list operator walks its own keys and returns). Runs on every engine + * read and write, so it stays a walk rather than a schema parse: + * `FieldOperatorsSchema` cannot be used as the gate directly because it is + * stricter than the runtime in ways the runtime deliberately allows — `$gt` is + * declared `number | Date | FieldReference`, while `['created_at', '>', + * '2026-01-01']` lowers to a STRING bound that every backend accepts and that + * the showcase apps rely on. Enforcing the whole schema here would refuse + * working queries; this gate enforces the three declarations that the drivers + * genuinely cannot agree on. + */ +export function assertListComparandShapes( + object: string, + operation: string, + node: unknown, + path = 'where', +): void { + if (!isFilterNode(node)) return; + for (const [key, value] of Object.entries(node)) { + const here = `${path}.${key}`; + if (key === '$and' || key === '$or') { + // A non-array operand is a different defect, owned by the drivers' + // combinator checks; this gate only walks what it can. + if (Array.isArray(value)) { + value.forEach((child, index) => + assertListComparandShapes(object, operation, child, `${here}[${index}]`)); + } + continue; + } + if (key === '$not') { + assertListComparandShapes(object, operation, value, here); + continue; + } + // Any other `$` key at node level is a logical operator this gate does not + // judge — an unknown one is already refused downstream, by name. + if (key.startsWith('$')) continue; + assertFieldListComparands(object, operation, key, value, here); + } +} + +/** One field constraint: `{ field: }`. */ +function assertFieldListComparands( + object: string, + operation: string, + field: string, + spec: unknown, + path: string, +): void { + // A spec that is not a plain object is a comparand (implicit equality) and + // carries no operator to check. + if (!isFilterNode(spec)) return; + const keys = Object.keys(spec); + // No `$` key at all → a deep-equality comparand or a nested-relation + // condition. Not descended into; see the module note. + if (!keys.some((key) => key.startsWith('$'))) return; + for (const op of keys) { + if (!LIST_COMPARAND_OPERATORS.has(op)) continue; + const comparand = spec[op]; + if (op === '$between') { + if (!Array.isArray(comparand) || comparand.length !== 2) { + throw malformedRangeComparandError(object, operation, field, comparand, `${path}.${op}`); + } + continue; + } + if (!Array.isArray(comparand)) { + throw nonListComparandError(object, operation, op, field, comparand, `${path}.${op}`); + } + } +}