From 4e1cd13aac260444f9dc069baeade3568fa65dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:15:34 +0000 Subject: [PATCH 1/8] chore(service-analytics): record the where-door comparand-TYPE measurement before the fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurement only; no source change. Taken on this branch at its base (origin/main 246314dffe) before any fix, with a throwaway harness that is not committed: a real sql.js engine (driver-sqlite-wasm) over rows d1 amt 1 'won', d2 amt 5 'lost', d3 amt 10 'open', d4 NULL NULL, d5 amt 3 ''. Faces: normalizeAnalyticsFilterTree (tree); NativeSQLStrategy.execute (native, the statement that runs); ObjectQLStrategy.generateSql then run (echo, /analytics/sql); ObjectQLStrategy.execute over a REAL ObjectQL engine.aggregate (engine path); engine.find on the same object where (engine seam); evaluateAnalyticsQueryOverRows (preview). The FilterArray spelling was run through tree, native and engine.find. Shared comparand-TYPE face (normalizeFilterComparandTypes, #7872) ----------------------------------------------------------------- cell | object spelling | FilterArray spelling | engine seam (object) 1. plain object $ne {stage:{$ne:{a:1}}} | native binds JSON text '{"a":1}': EVERY row d1..d5; echo DATABASE_ERROR/500 (raw object bound); engine path 400 (type face, on the laundered $and/$or/$ne); preview EVERY row | 400 type face at where.stage.$ne | 400 2. plain object $gt {amt:{$gt:{a:1}}} | native JSON text: no row; echo 500; engine path 400; preview no row | 400 | 400 3. plain object $eq {stage:{$eq:{a:1}}} | native JSON text: no row; echo 500; the engine path receives {stage:{a:1}} (implicit) and driver-sql refuses in its own words | no $eq spelling: '=' lowers to {stage:{a:1}}, a nested relation (stage.a = 1; native 500) | 400 4. plain-object $between endpoint [{a:1},5] | native `amt >= '{"a":1}' AND amt <= 5`: no row; echo 500; engine path 400 at $gte (an operator the author never wrote); preview no row | 400 at where.amt.$between[0] | 400 5. plain object $in member | refused 400, #5234 wording ("cannot be bound as a SQL parameter") | 400 type face | 400 6. plain object $contains | refused 400, #5234 wording ("TEXT of a pattern") | 400 type face | 400 7. non-string reference {$gt:{$field:5}} | native JSON text: no row; echo 500; engine path 400 | 400 type face (a plain object) | 400 8. binary (Uint8Array [1,2]) $eq | native binds JSON TEXT '{"0":1,"1":2}', not a blob: no row; echo binds the raw Uint8Array: no row; engine path 400; preview no row | (no $eq spelling) | 400 9. binary $ne | native, echo and preview: EVERY row; engine path 400 | 400 | 400 10. binary $in member | native JSON text: no row; echo raw: no row; engine path 400; preview no row | 400 | 400 11. binary implicit {stage: Uint8Array} | compiled as a NESTED RELATION `stage.0 = 1 AND stage.1 = 2`: native DATABASE_ERROR/500; echo and engine path INVALID_FIELD/400 (cross-object "stage.0"); preview refuses operator "0" | 400 type face at where.stage | 400 12. Map $eq | native binds '{}': no row; echo 500; engine path 400; preview no row | (no $eq spelling) | 400 13. Map implicit {stage: Map} | refused 400 as #5240's zero-operator wrapper on native/echo/engine path; preview EVERY row | 400 type face (a Map instance) | 400 14. bigint within 2^53 $gt 2n | native/echo/engine path d2,d3,d5 (bigint bound as-is); preview d2,d5 (d3 lost: the preview orders a bigint as TEXT, '10' < '2') | narrowed to 2: d2,d3,d5 | d2,d3,d5 15. bigint within 2^53 implicit 5n / $in [5n,10n] | same rows as narrowed on every face (d2 / d2,d3), kept as bigint | narrowed | same rows 16. bigint beyond 2^53 $gt 2n**60n | native/echo bind it: no row; engine path 400; preview d2,d5 | 400 | 400 17. bigint beyond 2^53 $in member | native/echo: no row; engine path 400; preview no row | 400 | 400 18. undefined implicit / $gt / $in member | refused 400 on native, echo and engine path, #6386 wording ('[analytics] comparand at "amt".$gt is undefined'); preview NO ROW, no refusal | 400, the type face's undefined sentence | 400 19. {$null: undefined} / {$null: {a:1}} | lowered to `set` (IS NOT NULL): d1,d2,d3,d5 on native, echo and engine path (which receives $ne: null); preview refuses $null as an unevaluable operator | no FilterArray spelling | 400 type face 20. nested relation {acct:{amt:{$gt:{a:1}}}} | tree `acct.amt gt [{a:1}]`; native no row; echo 500; engine path 400 | dotted ['acct.amt','>',{a:1}] 400 at where.acct.amt.$gt | 400 (driver-sql) 21. under $or {$or:[{id:'d3'},{stage:{$ne:{a:1}}}]} | native EVERY row; echo 500; preview EVERY row | 400 at where.$or[1].stage.$ne | 400 CONTROLS | {amt:{$gt:2}} d2,d3,d5 and {stage:null} d4 on every face and both spellings. Governing record: the #7872 ruling (2026-08-12), 「refuses everything else loudly at the compile face」. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude From 86907678c0986ce59c0e1d529ed66aaebecd6646 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:22:09 +0000 Subject: [PATCH 2/8] fix(service-analytics)!: the analytics where door runs the shared comparand-TYPE face on the object spelling lowerAnalyticsWhere now hands every field entry of an object-form where, nested-relation entries included, to normalizeFilterComparandTypes after the comparand-shape face and before any node is built, the order parseFilterAST and the engine seam use, and lowers the face's RETURN value (a bigint within 2^53 narrowed to its number, copy-on-write). The draft preview calls the same gate and evaluates the narrowed condition. WIP: pins not yet re-judged. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/preview-evaluator.ts | 20 +- .../src/strategies/filter-normalizer.ts | 249 +++++++++++++++--- 2 files changed, 221 insertions(+), 48 deletions(-) diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 42209acbb5e..126bb1dbb31 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -27,6 +27,8 @@ // [#19888] And a list in the equality slot (`{ stage: ['won', 'lost'] }`, // `{ stage: { $eq: [...] } }`), through the published door's own gate. // [#20010] Which now carries every arm of the shared comparand-shape face. +// [#20035] And the shared comparand-TYPE face after it, whose narrowed +// condition is the one this face evaluates. import { calendarPartsInTzOrUtc, @@ -41,8 +43,9 @@ import { explicitDateRangeWindow } from './date-range-array-arm.js'; // refusal in every respect that matters: the caller authored the predicate and // the repair is theirs. [#19888] So is the equality-slot list gate, for the // same reason: one rule, one spelling, on both faces. [#20010] And, through -// the same gate, every other arm of the shared comparand-shape face. -import { assertWhereComparandShapes, invalidFilterError } from './strategies/filter-normalizer.js'; +// the same gate, every other arm of the shared comparand-shape face. [#20035] +// And the comparand-TYPE face, through the same gate again. +import { invalidFilterError, normalizeWhereComparands } from './strategies/filter-normalizer.js'; import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; import { emptyGroupValueFor, type Cube } from '@objectstack/spec/data'; @@ -643,9 +646,16 @@ export function evaluateAnalyticsQueryOverRows( // a scalar `$in` / `$nin`. Measured before, {@link matchesWhere} answered // them (`{ amt: { $lt: null } }` charted every non-NULL row) for filters // publish now refuses. - assertWhereComparandShapes(query.where); - assertPreviewCanEvaluate(query.where); - let filtered = rows.filter((r) => matchesWhere(r, query.where)); + // [#20035] Then the shared comparand-TYPE face, through the same gate, and + // the rows are matched against what it RETURNS — the condition every + // published face lowers. Measured before: an `undefined` comparand answered + // NO row here while publish refused it 400; a plain-object or binary `$ne` + // answered EVERY row; and a bigint within 2^53 was ordered as text + // (`{ amt: { $gt: 2n } }` lost `amt = 10`), where publish narrows it to its + // number and serves the right rows. + const where = normalizeWhereComparands(query.where); + assertPreviewCanEvaluate(where); + let filtered = rows.filter((r) => matchesWhere(r, where)); const timeDims = query.timeDimensions ?? []; for (const td of timeDims) { const dim = cube.dimensions?.[td.dimension]; diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index 37fe0eda40e..5f9218552fe 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -387,6 +387,7 @@ import { assertListComparandShapes, isFilterAST, + normalizeFilterComparandTypes, parseFilterAST, VALID_AST_OPERATORS, } from '@objectstack/spec/data'; @@ -1543,54 +1544,111 @@ function filterArrayNotLowerableError(where: unknown[]): Error { ); } -// ── [#19888 / #20010] The shared comparand-shape face, on the object spelling ── +// ── [#19888 / #20010 / #20035] The shared comparand faces, on the object spelling ── /** - * [#20010] Visit every FIELD ENTRY of an object-form `where` — `{ key: spec }` - * with the `path` of the node that holds it — the way the shared - * comparand-shape face walks a condition, plus one step the face does not take. + * [#20035] A NESTED-RELATION object in a field's value position — the one kind + * of field spec {@link mapWhereFieldEntries} descends instead of visiting. + * + * Two conditions, both the shared faces' own. No `$` key (a `$` key makes the + * object an operator spec). And a PLAIN object, prototype `Object.prototype` + * or `null` — the structure test the comparand-TYPE face applies + * (`normalizeFilterComparandTypes`' `isFilterNode`, the convention `driver-sql` + * shares since #5134). A `Map`, a `Uint8Array` or a class instance passes + * `typeof x === 'object'` while being DATA, so it is visited as the comparand + * it is, and the type face judges it. This walk used to test + * {@link isFilterObject} alone, which let such a value through as a nested + * relation: `{ stage: new Uint8Array([1, 2]) }` compiled to + * `stage.0 = 1 AND stage.1 = 2`, and `{ stage: new Map() }` was refused as + * #5240's zero-operator wrapper, while the `FilterArray` spelling of each was + * refused by the type face in its own words. + */ +function isNestedRelationSpec(spec: unknown): spec is Record { + if (!isFilterObject(spec)) return false; + const proto = Object.getPrototypeOf(spec); + if (proto !== Object.prototype && proto !== null) return false; + return !Object.keys(spec).some((k) => k.startsWith('$')); +} + +/** + * [#20010, copy-on-write since #20035] Visit every FIELD ENTRY of an + * object-form `where` — `{ key: spec }` with the `path` of the node that holds + * it — the way the shared comparand faces walk a condition, plus one step the + * faces do not take, and rebuild the condition around whatever `visit` returns. * - * The face's traversal: `$and` / `$or` arrays, `$not`, and field entries; any - * other `$` key at node level is a logical operator the face does not judge + * The faces' traversal: `$and` / `$or` arrays, `$not`, and field entries; any + * other `$` key at node level is a logical operator the faces do not judge * (an unknown one is refused by {@link buildNode} by name), and a non-array * `$and` / `$or` operand is {@link buildNode}'s to refuse with its own message. * - * The extra step: a NESTED-RELATION object (`{ acct: { region: … } }`, no `$` - * key) is descended, where the face leaves one alone because a driver reads it - * as a deep-equality comparand or another object's condition. This compiler - * reads it as neither: {@link fieldLeaves} flattens it to the dotted member - * `acct.region`, so its entries are comparisons in their own right. The entry - * is visited as `{ region: … }` at path `where.acct`, which is how the #19888 - * gate has named that position since it landed. - * - * One traversal for both passes of {@link assertWhereComparandShapes}, so the - * two cannot disagree about which positions a `where` has. + * The extra step: a NESTED-RELATION object (`{ acct: { region: … } }`, see + * {@link isNestedRelationSpec}) is descended, where the faces leave one alone + * because a driver reads it as a deep-equality comparand or another object's + * condition. This compiler reads it as neither: {@link fieldLeaves} flattens it + * to the dotted member `acct.region`, so its entries are comparisons in their + * own right. The entry is visited as `{ region: … }` at path `where.acct`, + * which is how the #19888 gate has named that position since it landed, and + * which the type face renders `where.acct.region` — the path the dotted + * spelling `{ 'acct.region': … }` gets. + * + * Copy-on-write, the contract `normalizeFilterComparandTypes` has: when every + * `visit` returns the spec it was handed, the SAME reference comes back and + * nothing is allocated. The type pass returns a narrowed spec (a bigint within + * 2^53, as its number) and only the nodes on that spec's path are copied, so + * the caller's object is never edited. + * + * One traversal for all three passes of {@link normalizeWhereComparands}, so + * the passes cannot disagree about which positions a `where` has. */ -function forEachWhereFieldEntry( +function mapWhereFieldEntries( node: unknown, path: string, - visit: (key: string, spec: unknown, path: string) => void, -): void { - if (!isFilterObject(node)) return; + visit: (key: string, spec: unknown, path: string) => unknown, +): unknown { + if (!isFilterObject(node)) return node; + let out: Record | undefined; for (const [key, spec] of Object.entries(node)) { const here = `${path}.${key}`; + let next: unknown = spec; if (key === '$and' || key === '$or') { if (Array.isArray(spec)) { - spec.forEach((child, index) => forEachWhereFieldEntry(child, `${here}[${index}]`, visit)); + let copy: unknown[] | undefined; + spec.forEach((child, index) => { + const mapped = mapWhereFieldEntries(child, `${here}[${index}]`, visit); + if (mapped !== child) { + copy ??= [...spec]; + copy[index] = mapped; + } + }); + if (copy) next = copy; } - continue; - } - if (key === '$not') { - forEachWhereFieldEntry(spec, here, visit); - continue; + } else if (key === '$not') { + next = mapWhereFieldEntries(spec, here, visit); + } else if (key.startsWith('$')) { + // A logical operator the faces do not judge — see above. + } else if (isNestedRelationSpec(spec)) { + next = mapWhereFieldEntries(spec, here, visit); + } else { + next = visit(key, spec, path); } - if (key.startsWith('$')) continue; - if (isFilterObject(spec) && !Object.keys(spec).some((k) => k.startsWith('$'))) { - forEachWhereFieldEntry(spec, here, visit); - continue; + if (next !== spec) { + out ??= { ...node }; + out[key] = next; } - visit(key, spec, path); } + return out ?? node; +} + +/** {@link mapWhereFieldEntries} for a pass that judges and rewrites nothing. */ +function forEachWhereFieldEntry( + node: unknown, + path: string, + visit: (key: string, spec: unknown, path: string) => void, +): void { + mapWhereFieldEntries(node, path, (key, spec, at) => { + visit(key, spec, at); + return spec; + }); } /** @@ -1650,7 +1708,8 @@ function forEachWhereFieldEntry( * passes exactly as before. * * [#20010] Module-private since the draft-data preview calls - * {@link assertWhereComparandShapes}, which runs this first. + * {@link assertWhereComparandShapes}, which runs this first. [#20035] The + * preview now reaches both through {@link normalizeWhereComparands}. */ function assertNoListInEqualitySlot(node: unknown, path = 'where'): void { forEachWhereFieldEntry(node, path, (key, spec, at) => { @@ -1723,25 +1782,127 @@ function assertNoListInEqualitySlot(node: unknown, path = 'where'): void { * - `$ne` with a list. The face does not judge it yet; #19886's stage 2 puts * that refusal on the face, and this gate carries it the day it does, with no * change here. - * - The comparand-TYPE face (`normalizeFilterComparandTypes`). An `undefined` - * comparand outside a `$between` endpoint is still refused by - * {@link assertDefinedComparands}, in this door's own words. * - `$in: []` / `$nin: []`, every scalar ordering comparand, a `{ $field }` in * an ordering slot, and `null` in the equality slot (`{ f: null }`, * `$eq: null`, `$ne: null`, the null predicate) all compile as before. * - * EXPORTED for the one other face in this package that evaluates a `where` - * without this door: the draft-data preview (`preview-evaluator.ts`), which - * calls it so a drafted chart refuses what the published one refuses, rather - * than spelling the rule a second time. + * [#20035] The comparand-TYPE face runs right after this, in + * {@link normalizeWhereComparands}; this function stays the SHAPE half of that + * gate and is module-private since the preview calls the whole gate. */ -export function assertWhereComparandShapes(node: unknown, path = 'where'): void { +function assertWhereComparandShapes(node: unknown, path = 'where'): void { assertNoListInEqualitySlot(node, path); forEachWhereFieldEntry(node, path, (key, spec, at) => { assertListComparandShapes({ [key]: spec }, undefined, at); }); } +/** + * [#20035] The comparand-TYPE face (`normalizeFilterComparandTypes`, + * `@objectstack/spec/data`) over every field entry of an object-form `where`, + * nested-relation entries included. Returns the condition with each bigint + * within 2^53 narrowed to its number — copy-on-write, so the SAME reference + * comes back when nothing narrowed — and throws the face's `INVALID_FILTER` / + * 400 on the first comparand outside the accepted set. + * + * Each entry is handed to the face as a one-entry node with the path of the + * node that holds it, the same hand-over the shape pass makes, so the face + * reports exactly the path it reports on the whole condition and the object + * spelling gets the `FilterArray` spelling's refusal byte for byte. A nested + * relation's entries are handed over too ({@link mapWhereFieldEntries}): the + * face leaves such an object alone as filter STRUCTURE, and this compiler + * flattens it to dotted members whose comparands are literals like any other. + */ +function normalizeWhereComparandTypes(node: T, path = 'where'): T { + return mapWhereFieldEntries(node, path, (key, spec, at) => + normalizeFilterComparandTypes>({ [key]: spec }, undefined, at)[key], + ) as T; +} + +/** + * [#20035] The analytics `where` door's comparand gate: the shared + * comparand-SHAPE face, then the shared comparand-TYPE face, on an object-form + * condition, before any node is built. Returns the condition to lower — the + * type face's copy-on-write narrowing applied — and throws on the first + * refused comparand. + * + * ## Why + * + * The maintainer's ruling on #7872 (2026-08-12) defines the accepted comparand + * type set as `string | number | bigint | boolean | null | Date` and + * 「refuses everything else loudly at the compile face」. `parseFilterAST` runs + * the type face on everything it returns, and the engine's seam runs it on + * every object-form `where`; so the `FilterArray` spelling of this door, and + * the ObjectQL engine path behind it, have refused an off-set comparand since + * #7872. The object spelling never met it. Measured on a real engine before + * this gate (recorded on the branch as `4e1cd13aac`): + * + * | object `where` | before | + * |---|---| + * | `{ stage: { $ne: { a: 1 } } }` | native bound the JSON text `'{"a":1}'` and served EVERY row; the `/analytics/sql` echo answered `DATABASE_ERROR` / 500; the draft preview served every row | + * | `{ amt: { $between: [{ a: 1 }, 5] } }` | `amt >= '{"a":1}' AND amt <= 5`; the engine path refused it as a `$gte` the author never wrote | + * | `{ stage: { $in: [Uint8Array] } }` | native bound the JSON text `'{"0":1,"1":2}'`, not a blob: no row | + * | `{ stage: new Uint8Array(…) }` | flattened as a nested relation, `stage.0 = 1 AND stage.1 = 2`: native 500 | + * | `{ amt: { $gt: 2n ** 60n } }` | bound as-is: no row; the engine path refused it | + * | `{ amt: { $gt: 2n } }` | the right rows on every published face, but the draft preview ordered the bigint as TEXT and lost `amt = 10` | + * | `{ stage: { $null: undefined } }` | lowered to `set` (IS NOT NULL) | + * + * while the `FilterArray` spelling and the engine seam refused each of them + * `INVALID_FILTER` / 400 in the type face's words. Every row now answers like + * the `FilterArray` spelling: refused in the face's own envelope, wording and + * path, or — the bigint within 2^53 — narrowed to its number, which is the + * condition every face of this door then lowers. + * + * ## How + * + * The order is `parseFilterAST`'s and the engine seam's: the shape face first + * ({@link assertWhereComparandShapes}, over the whole condition), then the type + * face ({@link normalizeWhereComparandTypes}, over the whole condition), both + * over {@link mapWhereFieldEntries}' one traversal, before {@link buildNode} + * reads anything. So a condition carrying a shape defect and a type defect is + * answered with the shape one, as on the other spelling, and every refusal + * this door gave in its OWN words for a position the type face judges now + * reads in the face's words: + * + * - an `undefined` comparand (#6386's sentence) — implicit, under an operator, + * as a list member, and under the `$null` / `$exists` flags, which the face + * judges as literal comparands (its operator split, reconciled against + * `FieldOperatorsSchema` by the face's own test); + * - a plain object, a `Map`, a binary or a class instance as an `$in` / `$nin` + * member or a LIKE-family comparand (#5234's two sentences); + * - a mixed `$` / non-`$` wrapper whose operator carries a refused comparand + * (#6444's order: the comparand is now diagnosed first). + * + * The door's own gates stay as {@link fieldLeaves}' invariants and keep the + * positions the face does not judge: an array or a `{ $field }` reference as a + * list member or a LIKE comparand (#5234 / #7598 wording), and an `undefined` + * inside an array comparand or under an operator outside the vocabulary + * (#6386 wording). + * + * ## Binary is reconciled, not kept as a local extra + * + * `comparand-shape.ts`' `isBindableComparand` records binary as this package's + * local admission (#8186), and the face's docblock allows a door "its recorded + * driver-local extras — binary bindables … declared at the use site". Measured, + * this door never delivered it: the native path bound a binary as JSON text + * (`toSqlBindValue`), the echo bound the raw buffer against the same text + * column, `$ne` then served every row, the implicit spelling compiled to a + * dotted member no object has, and the engine path and the `FilterArray` + * spelling refused it. No producer can send one over REST (JSON has no binary + * type), and no in-repo caller builds one into an analytics `where`. So the + * door refuses it with the face; the read-scope door's use of that predicate + * is a different door and is not moved here. + * + * EXPORTED for the one other face in this package that evaluates a `where` + * without this door: the draft-data preview (`preview-evaluator.ts`), which + * calls it so a drafted chart refuses what the published one refuses, and + * evaluates the narrowed condition the published one lowers. + */ +export function normalizeWhereComparands(node: T, path = 'where'): T { + assertWhereComparandShapes(node, path); + return normalizeWhereComparandTypes(node, path); +} + /** * Lower an analytics query's `where` to the CANONICAL `FilterCondition` object, * before any node is built. `null` when the query carries no `where`. @@ -1789,9 +1950,11 @@ export function lowerAnalyticsWhere( // [#19888, #20010] The object spelling meets the shared comparand-shape face // here — the equality arm first, then every other arm — the way the array - // spelling met it inside `parseFilterAST` just above. - assertWhereComparandShapes(where); - return where as Record; + // spelling met it inside `parseFilterAST` just above. [#20035] Then the + // shared comparand-TYPE face, in `parseFilterAST`'s order; its RETURN value + // is the condition lowered from here on (a bigint within 2^53 narrowed to its + // number, copy-on-write), exactly as the engine seam lowers its own. + return normalizeWhereComparands(where as Record); } /** From b30c1b40b3116f4e596bc93f521984926f092a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:28:34 +0000 Subject: [PATCH 3/8] test(service-analytics): re-judge the 39 where-door pins the comparand-TYPE face moves Each keeps its verdict or flips with a note quoting the #7872 ruling; none is deleted. Predicted 39 red in 9 files before wiring the gate; measured 39 red in the same 9 files, the identical set. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../comparand-door-single-source.test.ts | 29 ++- .../__tests__/comparand-shape-refusal.test.ts | 79 +++++--- .../cross-field-reference-refusal.test.ts | 44 +++-- .../filter-normalizer-mixed-wrapper.test.ts | 22 ++- .../filter-normalizer-not-null-safe.test.ts | 6 +- ...ter-normalizer-undefined-comparand.test.ts | 185 ++++++++++++------ .../__tests__/filter-refusal-envelope.test.ts | 6 +- .../filter-value-type-fidelity.test.ts | 7 +- .../__tests__/where-face-arms-refusal.test.ts | 18 +- 9 files changed, 285 insertions(+), 111 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts index 12c0d8aa188..4f1bf246aa1 100644 --- a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts +++ b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts @@ -45,6 +45,11 @@ * - **binary** binds but has no faithful text rendering, so it is accepted in * a bind position and refused by the LIKE family. That asymmetry is the * reason the package carries two predicates rather than one with a flag. + * [#20035] At the PREDICATE level it still is. At the `where` DOOR it is + * now refused in every position: the door runs the shared comparand-TYPE + * face before any predicate is asked, and that face does not admit binary + * (see the binary row's note for the evidence the call rests on). The + * read-scope door keeps the admission. * * @see comparand-shape.ts — the predicates and the messages this pins * @see https://github.com/objectstack-ai/objectstack/issues/8186 @@ -146,16 +151,31 @@ const MATRIX: readonly Row[] = [ whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE, scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE }, // binary: binds, does not render — accepted where it binds, refused by LIKE. + // [#20035] RE-JUDGED: the two `where` bind cells moved from accept to + // refused, on purpose and with the evidence stated, because the type face's + // docblock lets a door keep binary only as a DECLARED local extra and #8186 + // asked for an explicit keep-or-reconcile call. Reconciled: the maintainer's + // ruling on #7872 (2026-08-12) 「refuses everything else loudly at the + // compile face」, and this door never delivered the extra — measured on a + // real engine before the change, the native path bound the buffer as the + // JSON TEXT '{"0":1,"1":2}' (no row; `$ne` served every row), the engine + // path and the FilterArray spelling refused it, and no producer can send one + // over JSON. The predicate columns and the read-scope cells are unchanged. { label: 'binary', value: new Uint8Array([1, 2]), bindable: true, renderable: false, - whereLike: REFUSED_WHERE, whereIn: OK, whereEq: OK, + whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE, scopeLike: REFUSED_SCOPE, scopeIn: OK, scopeEq: OK }, // ── shapes outside the fence ────────────────────────────────────────────── - // `$eq` accepts them on purpose: #5234 left the `{$eq: {…}}` account alone. + // `$eq` accepted them on purpose: #5234 left the `{$eq: {…}}` account alone. + // [#20035] RE-JUDGED: the `where` door's `$eq` cell moved to refused. The + // #7872 ruling (2026-08-12) closes that account at the shared comparand-TYPE + // face (「refuses everything else loudly at the compile face」), which the + // door now runs on the object spelling, as `parseFilterAST` and the engine + // seam always did. The read-scope cell is that door's own and is not moved. { label: 'plain object', value: { foo: 1 }, bindable: false, renderable: false, - whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK, + whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE, scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: OK }, // [#19975] One cell of this row moved AFTER the #8186 measurement, on // purpose: ruling 乙 (#19757) refuses a list in the equality slot, and the @@ -257,6 +277,9 @@ describe('[#8186] the comparand matrix is unchanged by the door reconciliation', }); it('binary stays a package-local extra the door does not admit', () => { + // [#20035] At the predicate level, which is what this case reads. The + // `where` door refuses binary before this predicate is asked (the matrix + // row above); the read-scope door still asks it. const buf = new Uint8Array([1, 2]); expect(isAcceptedFilterComparand(buf)).toBe(false); expect(isBindableComparand(buf)).toBe(true); diff --git a/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts b/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts index fc9d0455ad0..d0336ad301c 100644 --- a/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts @@ -62,6 +62,21 @@ * added the entry; the loops now name all five, and the `where`-door arm of the * `$icontains` row is the case that only passes because of it (the read-scope * arm was already green and is here as the no-regression control). + * + * # [#20035] The `where` door answers a TYPE defect in the shared face's words + * + * The maintainer's ruling on #7872 (2026-08-12) defines the accepted comparand + * types (`string | number | bigint | boolean | null | Date`) at the shared + * comparand-TYPE face and 「refuses everything else loudly at the compile + * face」. The `where` door now runs that face on the object spelling before any + * leaf is built, so a plain-object `$in` member and a plain-object LIKE + * comparand are refused first by the face, in its sentence and at its path + * (`where.status.$in[1]`) — the answer the `FilterArray` spelling of this door + * and the engine seam already gave. Every such pin below keeps its verdict + * (`INVALID_FILTER` / 400) and is re-judged for the wording. The shapes the face + * does not judge keep #5234's sentences: an ARRAY member or comparand, and a + * `{ $field }` reference. And the `{ $eq: {…} }` account #5234 left open is + * closed by the same ruling: refused, not bound as JSON text. */ import { describe, it, expect } from 'vitest'; @@ -99,7 +114,11 @@ describe('[#5234] the analytics `where` door refuses an uncompilable comparand', const err = refusalOf(() => tree({ status: { $in: ['a', { foo: 1 }] } })); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); - expect(err.message).toContain('index 1'); + // [#20035] RE-JUDGED: still refused, now by the shared type face first + // (#7872: 「refuses everything else loudly at the compile face」), which + // names the member by its path (`$in[1]`) where #5234's sentence said + // `index 1` — the FilterArray spelling's bytes. + expect(err.message.startsWith('Filter comparand at where.status.$in[1] is a plain object')).toBe(true); expect(err.message).toContain('$in'); expect(err.message).toContain('{"foo":1}'); }); @@ -107,8 +126,11 @@ describe('[#5234] the analytics `where` door refuses an uncompilable comparand', it('`$nin` too — the direction there is WIDER, not narrower', () => { const err = refusalOf(() => tree({ status: { $nin: [{ foo: 1 }] } })); expect(err.code).toBe('INVALID_FILTER'); - expect(err.message).toContain('index 0'); - expect(err.message).toContain('$nin loses the exclusion'); + // [#20035] RE-JUDGED: refused by the shared type face first (#7872). The + // widening #5234's sentence spelled out as "$nin loses the exclusion" is + // the face's closing sentence: the unapplied filter returns everything. + expect(err.message.startsWith('Filter comparand at where.status.$nin[0] is a plain object')).toBe(true); + expect(err.message).toContain('would have returned the UNFILTERED result set'); }); it('a nested-array member is refused as well', () => { @@ -126,7 +148,12 @@ describe('[#5234] the analytics `where` door refuses an uncompilable comparand', expect(err.code, op).toBe('INVALID_FILTER'); expect(err.status, op).toBe(400); expect(err.message, op).toContain(op); - expect(err.message, op).toContain('StringOperatorSchema'); + // [#20035] RE-JUDGED: the shared type face answers a plain object first + // (#7872), so the sentence is its own and not the LIKE-family + // `StringOperatorSchema` one, which now answers only the shapes the face + // does not judge — the array and the `{ $field }` rows below. + expect(err.message.startsWith(`Filter comparand at where.name.${op} is a plain object`), op).toBe(true); + expect(err.message, op).not.toContain('StringOperatorSchema'); }); } @@ -201,25 +228,33 @@ describe('[#5234] the analytics `where` door refuses an uncompilable comparand', }); }); - it('`{$eq: {…}}` is deliberately UNTOUCHED — a separate account', () => { - // #5526 pinned `toSqlBindValue({a:1})` → `'{"a":1}'`. Refusing it is the - // analytics-side half of #5041, which this change does not open. + it('`{$eq: {…}}` — the account #5234 left open is closed by the #7872 ruling', () => { + // #5526 pinned `toSqlBindValue({a:1})` → `'{"a":1}'`, and #5234 left this + // account open on purpose: refusing it was the analytics-side half of + // #5041, which that change did not open. [#7598] made the row + // load-bearing: it proved the field-reference gate keys on the SHAPE + // `{$field: }` and not on "an object comparand". // - // ⚠️ [#7598] Still true, and now load-bearing in a second way: the - // field-reference gate added there covers this very operator, so this case - // is what proves the gate keys on the SHAPE `{$field: }` and not on - // "an object comparand". A gate that had widened to every object would turn - // this row red — which is why the row is worth keeping rather than being - // folded into the block above. - expect(tree({ name: { $eq: { a: 1 } } })).toEqual({ - kind: 'leaf', member: 'name', operator: 'equals', values: [{ a: 1 }], - }); - // The same distinction one step finer: `$field` present but NOT a string is - // the ordinary object account too, exactly as on `driver-sql`, whose - // `fieldReferenceOf` requires `typeof ref === 'string'`. - expect(tree({ name: { $eq: { $field: 5 } } })).toEqual({ - kind: 'leaf', member: 'name', operator: 'equals', values: [{ $field: 5 }], - }); + // [#20035] RE-JUDGED — FLIPPED. The account is no longer open: the + // maintainer's ruling on #7872 (2026-08-12) puts a plain object in a + // SCALAR slot outside the accepted comparand types and 「refuses + // everything else loudly at the compile face」, and the door now runs that + // face. Measured before the flip, on a real engine: the native path bound + // the JSON text and matched nothing, the `/analytics/sql` echo answered + // DATABASE_ERROR / 500, and the engine path refused it — one condition, + // three answers. What #7598 needed from this row still holds, one row + // down: a STRING `$field` is a reference, so it is kept and served + // (`cross-field-reference-refusal.test.ts`), while a non-string `$field` + // is an ordinary object and is refused with every other object. + for (const [where, path, preview] of [ + [{ name: { $eq: { a: 1 } } }, 'where.name.$eq', '{"a":1}'], + [{ name: { $eq: { $field: 5 } } }, 'where.name.$eq', '{"$field":5}'], + ] as const) { + const err = refusalOf(() => tree(where)); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message.startsWith(`Filter comparand at ${path} is a plain object (${preview})`)).toBe(true); + } }); }); }); diff --git a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts index 3ee142f9ab3..592905db6ec 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts @@ -414,23 +414,40 @@ describe('[#7598] the field-reference shape is read exactly as `driver-sql` read expect(err.message).toContain('budget'); }); - it('a NON-STRING `$field` is not a reference — it stays the object account', () => { + it('a NON-STRING `$field` is not a reference — it is the ordinary object account', () => { // `driver-sql`'s `fieldReferenceOf` requires `typeof ref === 'string'`, and // this package mirrors that spelling rather than inventing a third reading. - // It is therefore NOT routed either: it binds as JSON, exactly as any other - // object comparand does, which is the account #5234 left open on purpose. + // It is therefore NOT routed either: it is judged exactly as any other + // object comparand is. + // + // [#20035] RE-JUDGED. On the `where` door that account used to BIND the + // object as JSON text — the account #5234 left open on purpose. The + // maintainer's ruling on #7872 (2026-08-12) closes it (a plain object is + // outside the accepted comparand types, and the shared type face + // 「refuses everything else loudly at the compile face」), and the door now + // runs that face: refused INVALID_FILTER / 400, as a plain object, at the + // face's path. The read-scope cell is that door's own and is unchanged. + // What this case protects — a non-string `$field` is not a reference, so + // it is neither routed nor served as one — is unchanged. expect(findCrossFieldComparand({ amount: { $gt: { $field: 5 } } })).toBeNull(); - expect(tree({ amount: { $gt: { $field: 5 } } })).toEqual({ - kind: 'leaf', member: 'amount', operator: 'gt', values: [{ $field: 5 }], - }); + const err = refusalOf(() => tree({ amount: { $gt: { $field: 5 } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message.startsWith('Filter comparand at where.amount.$gt is a plain object ({"$field":5})')).toBe(true); expect(scope({ amount: { $gt: { $field: 5 } } }).params).toEqual([{ $field: 5 }]); }); - it('an ordinary object comparand is untouched — #5234 left that account open', () => { + it('an ordinary object comparand is not a reference either — #7872 closed the account #5234 left open', () => { + // [#20035] RE-JUDGED — FLIPPED. This pinned `{ $eq: { a: 1 } }` compiling + // to a leaf, the account #5234 left open. The #7872 ruling (2026-08-12, + // 「refuses everything else loudly at the compile face」) now answers it on + // this door too; the routing detector still ignores it, which is the half + // this block is about. expect(findCrossFieldComparand({ amount: { $eq: { a: 1 } } })).toBeNull(); - expect(tree({ amount: { $eq: { a: 1 } } })).toEqual({ - kind: 'leaf', member: 'amount', operator: 'equals', values: [{ a: 1 }], - }); + const err = refusalOf(() => tree({ amount: { $eq: { a: 1 } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message.startsWith('Filter comparand at where.amount.$eq is a plain object ({"a":1})')).toBe(true); }); }); @@ -497,7 +514,12 @@ describe('[#7693] `$icontains` is fenced on the `where` door, like its four sibl const where = refusalOf(() => tree({ name: { $icontains: { foo: 1 } } })); expect(where.code).toBe('INVALID_FILTER'); expect(where.status).toBe(400); - expect(where.message).toContain('[object Object]'); + // [#20035] RE-JUDGED: still refused rather than bound, now by the shared + // type face first (#7872: 「refuses everything else loudly at the compile + // face」), whose sentence names the value as a plain object rather than the + // `[object Object]` pattern the LIKE-family sentence quoted. The sibling + // comparison below — the point of #7693 — holds in the face's words. + expect(where.message.startsWith('Filter comparand at where.name.$icontains is a plain object ({"foo":1})')).toBe(true); // The envelope `$contains` gets, said about the same shape — the point of // the card is that these two rows are now identical apart from the operator. diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts index 8b1be757362..efbdc65f017 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-mixed-wrapper.test.ts @@ -340,14 +340,22 @@ describe('[#6444] the two pure shapes do not move', () => { expect(String(refusalFor({ stage: { $sortOf: 'won' } })?.message)).toContain('Unsupported filter operator'); }); - it('a mixed wrapper whose $-comparand is undefined is refused by the #6386 gate first', () => { - // Measured ordering, pinned as a fact rather than a contract: - // `assertDefinedComparands` runs at `fieldLeaves`'s entry, this gate in the - // wrapper arm below it. Both refusals share the envelope, so the REST face - // answers 400 either way — which is why the ordering is allowed to be an - // implementation fact. + it('a mixed wrapper whose $-comparand is undefined is refused as the comparand first', () => { + // Measured ordering, pinned as a fact rather than a contract: both refusals + // share the envelope, so the REST face answers 400 either way — which is why + // the ordering is allowed to be an implementation fact. + // + // [#20035] RE-JUDGED. The comparand is still diagnosed before the mix, but + // by an earlier gate in different words: `assertDefinedComparands` (#6386) + // at `fieldLeaves`'s entry used to answer, and now the shared comparand-TYPE + // face answers in `lowerAnalyticsWhere`, before any node is built — the + // maintainer's ruling on #7872 (2026-08-12), 「refuses everything else + // loudly at the compile face」. The face judges the `$eq` comparand and + // steps over the non-`$` sibling, so a mixed wrapper whose operators carry + // only accepted comparands still reaches this gate (every row above). const err = refusalFor({ d: { $eq: undefined, nested: 'x' } }); - expect(String(err?.message)).toContain('comparand at "d".$eq is undefined'); + expect(String(err?.message).startsWith('Filter comparand at where.d.$eq is undefined.')).toBe(true); + expect(String(err?.message)).not.toContain('mixes $-operator keys'); expect(err?.code).toBe('INVALID_FILTER'); expect(err?.status).toBe(400); }); diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts index ba9688ee8ac..5aad0a15108 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts @@ -744,8 +744,12 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit // into a statement with no `WHERE` (#5297's lesson, one call above the // compiler). So this asserts the refusal reaches the executing seam, not // merely that the normalizer throws in isolation. + // + // [#20035] RE-JUDGED for the wording only: the shared comparand-TYPE face + // answers first now (#7872, 2026-08-12: 「refuses everything else loudly + // at the compile face」), in its sentence and at its path. await expect(ids({ stage: undefined, owner: 'u1' })).rejects.toThrowError( - /comparand at "stage" is undefined/, + /^Filter comparand at where\.stage is undefined\./, ); // `$not: undefined` is NOT the same condition and must not borrow the same // message: it is a combinator with a missing operand, and the branch that diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-undefined-comparand.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-undefined-comparand.test.ts index 8037af79343..06c43c02ce8 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-undefined-comparand.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-undefined-comparand.test.ts @@ -90,6 +90,34 @@ * ⛔ `read-scope-sql.ts` is the sibling door and is not touched here; its own * refusal landed in PR #6390 with a different envelope on purpose (500: it * compiles a platform artifact, this door receives caller input). + * + * ## [#20035] RE-JUDGED: the verdicts stand, the WORDING is the type face's + * + * The maintainer's ruling on #7872 (2026-08-12) puts the accepted comparand + * type set on the shared comparand-TYPE face (`normalizeFilterComparandTypes`, + * `@objectstack/spec/data`), which 「refuses everything else loudly at the + * compile face」 — `undefined` included, in every literal comparand position. + * `parseFilterAST` and the engine seam ran that face; this door's object + * spelling did not, so an `undefined` was refused in #6386's sentence here and + * in the face's sentence on the `FilterArray` spelling of the same door. + * #20035 runs the face on the object spelling too, after the shape face and + * before any node is built, so every refusal below keeps its verdict + * (`INVALID_FILTER` / 400, never compiled) and now reads in the face's + * single-sourced sentence at the face's path (`where.d.$gt` rather than + * `"d".$gt`). Two consequences are recorded where they land: + * + * - The face runs BEFORE the #5146 rewrite, so the rewrite block below now + * pins that the author's own `$not` is judged, not the rewritten one. + * - The face judges `$null` / `$exists` as literal comparands (its operator + * split, reconciled against `FieldOperatorsSchema` by the face's own test), + * so `{$null: undefined}` is refused rather than read as `set`. The boolean + * DOMAIN question for an ACCEPTED value (#5347 / #5369 / #6387) is not + * touched: `{$null: 'false'}` still lowers as before. + * + * `assertDefinedComparands` (#6386) stays as `fieldLeaves`' invariant and still + * answers the positions the face does not judge — an `undefined` inside an + * array comparand, or under an operator outside the vocabulary — pinned in + * `where-type-face-refusal.test.ts`. */ import { describe, it, expect } from 'vitest'; @@ -123,43 +151,43 @@ const MEASURED: Array<{ name: string; where: unknown; path: string; wasReadAs: s { name: '① a single-key where — the whole filter disappeared', where: { d: undefined }, - path: '"d"', + path: 'where.d', wasReadAs: 'null — no predicate at all, so the query ran UNFILTERED', }, { name: '② one conjunct of several — that conjunct disappeared', where: { stage: 'won', d: undefined }, - path: '"d"', + path: 'where.d', wasReadAs: "only `stage equals 'won'` survived", }, { name: '③ inside a $not — a predicate GREW from the discarded leaf', where: { $not: { d: undefined } }, - path: '"d"', + path: 'where.$not.d', wasReadAs: 'NOT (d set) — i.e. `d IS NULL`, which the author never wrote', }, { name: '④ $eq — a value comparison, not $eq: null’s null predicate', where: { d: { $eq: undefined } }, - path: '"d".$eq', + path: 'where.d.$eq', wasReadAs: 'd equals [null]', }, { name: '⑤ $gt — an ordering comparison against NULL, UNKNOWN for every row', where: { d: { $gt: undefined } }, - path: '"d".$gt', + path: 'where.d.$gt', wasReadAs: 'd gt [null]', }, { name: '⑥ a member of a $in list', where: { d: { $in: [undefined] } }, - path: '"d".$in[0]', + path: 'where.d.$in[0]', wasReadAs: 'd in [null]', }, { name: '⑦ $ne — the null-safe guard wrapped a comparison against NULL', where: { d: { $ne: undefined } }, - path: '"d".$ne', + path: 'where.d.$ne', wasReadAs: 'd notSet OR d notEquals [null]', }, ]; @@ -193,31 +221,31 @@ const BEYOND_THE_TABLE: Array<{ name: string; where: unknown; path: string; wasR { name: 'a member of a $nin list', where: { d: { $nin: [undefined] } }, - path: '"d".$nin[0]', + path: 'where.d.$nin[0]', wasReadAs: 'd notSet OR d notIn [null]', }, { name: 'the LIKE family, whose comparand the spec declares a string', where: { d: { $contains: undefined } }, - path: '"d".$contains', + path: 'where.d.$contains', wasReadAs: "d contains [null] — LIKE '%null%' since #5526", }, { name: 'a NESTED relation, refused on the DOTTED member (twin refuses nesting itself)', where: { profile: { verified: undefined } }, - path: '"profile.verified"', + path: 'where.profile.verified', wasReadAs: 'profile.verified equals [null]', }, { name: 'a branch of a $and', where: { $and: [{ d: undefined }] }, - path: '"d"', + path: 'where.$and[0].d', wasReadAs: 'null — the branch reduced to TRUE and the $and to nothing', }, { name: 'ONE branch of a $or — TRUE absorbed the whole disjunction (#5325)', where: { $or: [{ d: undefined }, { stage: 1 }] }, - path: '"d"', + path: 'where.$or[0].d', wasReadAs: 'null — the surviving `stage` branch was absorbed too, so EVERY row', }, ]; @@ -296,7 +324,11 @@ describe('[#6386] the seven measured readings are now ONE refusal', () => { it(`refuses ${c.name} (was: ${c.wasReadAs})`, () => { const err = refusalFor(c.where); expect(err, 'compiled instead of refusing — the #3650 widening is back').toBeInstanceOf(Error); - expect(String(err?.message)).toContain(`comparand at ${c.path} is undefined`); + // [#20035] RE-JUDGED: the verdict stands; the sentence and the path are + // the shared type face's (#7872: 「refuses everything else loudly at the + // compile face」), the ones the FilterArray spelling already got. It + // used to read `[analytics] comparand at "d".$gt is undefined`. + expect(String(err?.message).startsWith(`Filter comparand at ${c.path} is undefined.`)).toBe(true); // The envelope every refusal in this module carries since #5352, and the // one #6050 chose for this shape: caller input, so 400. expect(err?.code).toBe('INVALID_FILTER'); @@ -304,14 +336,16 @@ describe('[#6386] the seven measured readings are now ONE refusal', () => { }); } - it('says ONE thing, differing only in `path` and the field it repairs (#5240)', () => { - // Erase the two things that legitimately vary — the position and the field - // name the repair hints quote — and every message must be the same string. - // Two variables rather than one: unlike `read-scope-sql`'s twin, this - // wording PRESCRIBES (`{ "d": null }`), so the field appears outside the - // path as well. + it('says ONE thing, differing only in `path` (#5240)', () => { + // Erase what legitimately varies and every message must be the same string. + // [#20035] RE-JUDGED: ONE variable now, not two. #6386's sentence + // prescribed `{ "d": null }`, so the field appeared outside the path; the + // shared type face's sentence (#7872) prescribes the null predicate by + // complete spellings and names the position only in its path, so the path + // is the one thing left to erase. The #5240 property this case protects is + // unchanged — and is now true across both spellings of the door, not only + // across this door's own positions. const generic = [...MEASURED, ...BEYOND_THE_TABLE].map((c) => { - const field = c.path.replace(/^"/, '').replace(/".*$/, ''); const err = refusalFor(c.where); // ⚠️ Load-bearing, and measured: without it this case is VACUOUSLY green // whenever nothing throws — every row maps to the same `"undefined"` @@ -319,30 +353,33 @@ describe('[#6386] the seven measured readings are now ONE refusal', () => { // "one wording". Removing the gate turned all 14 rows silent and this // case stayed green until the guard was added. expect(err, `${c.name} did not refuse — the wording check would pass on nothing`).toBeInstanceOf(Error); - return String(err?.message) - .replace(`comparand at ${c.path} is`, 'comparand at is') - // `split`/`join` rather than `replaceAll` — this package's tsconfig `lib` - // predates ES2021, and a new tsc error here would widen a shrink-only - // ledger (`check:type-check-debt`). - .split(`"${field}"`).join('""'); + return String(err?.message).replace(`comparand at ${c.path} is`, 'comparand at is'); }); expect(new Set(generic).size, `expected one wording, got:\n${[...new Set(generic)].join('\n\n')}`).toBe(1); }); - it('names the two repairs and the producer to fix, not just the refusal', () => { + it('names the repairs and why the value is unreadable, not just the refusal', () => { + // [#20035] RE-JUDGED. This pinned #6386's door-local prescription — the two + // spellings `{ "d": null }` / `{ "d": { "$null": true } }`, the two + // consequences this door used to have, and "The producer to fix is whoever + // BUILT this where". The door now answers in the shared type face's + // single-sourced sentence (#7872, 2026-08-12: 「refuses everything else + // loudly at the compile face」), which every other face of the platform + // already gave for this input, including this door's FilterArray spelling. + // What the case protects survives in the face's words: the repairs are + // named, and so is the reason no reading of `undefined` is safe. Its + // prescription is position-safe by design — the complete null-predicate + // spellings, never "write null", which at an ordering slot or a list + // member lands in a refusal one face over (the 2026-08-31 and 2026-09-01 + // rulings). const message = String(refusalFor({ d: undefined })?.message); - // The author's two legitimate spellings… - expect(message).toContain('{ "d": null }'); - expect(message).toContain('{ "d": { "$null": true } }'); - expect(message).toContain('omit the key entirely'); - // …both consequences it used to have, since this door has two… - expect(message).toContain('the key was dropped outright'); - expect(message).toContain('became a comparison against null'); - // …and where the fix belongs. Unlike the sibling read-scope door, the - // producer here IS the caller, so the message must not send them to an - // admin-authored policy. - expect(message).toContain('The producer to fix is whoever BUILT this where'); - expect(message).toContain('undefined cannot cross JSON'); + expect(message).toContain('Write the null predicate — {"$eq": null} / {"$ne": null} — or omit the key.'); + expect(message).toContain('cannot be told apart from an omitted key'); + expect(message).toContain('OPPOSITE things'); + expect(message).toContain('The filter was NOT applied'); + // One sentence, not two: nothing of #6386's door-local wording survives. + expect(message).not.toContain('[analytics]'); + expect(message).not.toContain('whoever BUILT'); }); }); @@ -382,35 +419,43 @@ describe('[#6386] the #5146 rewrite cannot swallow the leaf — the gate side is { name: "`requireValue` — pushes {k: {$null: false}}, {k: spec}; spec kept by reference", where: { $not: { d: undefined } }, - path: '"d"', + path: 'where.$not.d', }, { name: '`requireValue` via an operator spec', where: { $not: { d: { $eq: undefined } } }, - path: '"d".$eq', + path: 'where.$not.d.$eq', }, { name: '`allowNull` — pushes {$or: [{k: {$null: true}}, {k: spec}]} ($ne’s polarity)', where: { $not: { d: { $ne: undefined } } }, - path: '"d".$ne', + path: 'where.$not.d.$ne', }, { name: 'the nested-relation recursion, which guards the DOTTED member', where: { $not: { profile: { verified: undefined } } }, - path: '"profile.verified"', + path: 'where.$not.profile.verified', }, { name: 'a list member inside a negation', where: { $not: { d: { $in: [undefined] } } }, - path: '"d".$in[0]', + path: 'where.$not.d.$in[0]', }, ]; + // [#20035] RE-JUDGED. The shared type face (#7872) now answers first, on the + // author's OWN condition, before `nullSafeNegationOperand` rewrites anything: + // `lowerAnalyticsWhere` runs it ahead of `buildNode`. So the refusal names + // the `$not` path the author wrote (`where.$not.d`) — the question "can the + // rewrite swallow the leaf before the gate sees it" is answered upstream of + // the rewrite for every row here. Each row still refuses, which is what the + // block protects; `assertDefinedComparands` downstream of the rewrite stays + // `fieldLeaves`' invariant. for (const c of REWRITE_PATHS) { it(`throws rather than changing shape: ${c.name}`, () => { const err = refusalFor(c.where); expect(err, 'the rewrite swallowed the leaf and the gate blessed the new shape').toBeInstanceOf(Error); - expect(String(err?.message)).toContain(`comparand at ${c.path} is undefined`); + expect(String(err?.message).startsWith(`Filter comparand at ${c.path} is undefined.`)).toBe(true); }); } @@ -419,26 +464,46 @@ describe('[#6386] the #5146 rewrite cannot swallow the leaf — the gate side is // `operatorIsNullTotal`, which for an `undefined` comparand is false on every // operator this gate sweeps ($eq/$ne compare `value === null`; $in/$nin need // an empty array). So the only field specs reaching 'none' while holding an - // `undefined` are the `$null` / `$exists` flags — deliberately not swept, and - // asserted below to compile exactly as before. - expect(refusalFor({ $not: { d: { $null: undefined } } })).toBeUndefined(); - expect(treeFor({ $not: { d: { $null: undefined } } })).toEqual({ - kind: 'not', - child: { kind: 'leaf', member: 'd', operator: 'set', values: [] }, - }); + // `undefined` are the `$null` / `$exists` flags, which #6386 did not sweep. + // + // [#20035] RE-JUDGED. That last shape used to compile to `NOT (d set)`. The + // shared type face (#7872: 「refuses everything else loudly at the compile + // face」) judges the `$null` / `$exists` comparand as a literal and refuses + // `undefined` there, and the door now runs it before the rewrite. So no + // field spec holding an `undefined` reaches ANY disposition from this door: + // the reason there is no `none` case is now stronger than the one above. + const err = refusalFor({ $not: { d: { $null: undefined } } }); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + expect(String(err?.message).startsWith('Filter comparand at where.$not.d.$null is undefined.')).toBe(true); }); }); describe('[#6386] what the sweep deliberately leaves alone', () => { - it('$null / $exists carry a declared BOOLEAN flag, not a comparand', () => { + it('$null / $exists: an undefined flag is refused by the type face; an accepted flag value reads as before', () => { // Same call as `read-scope-sql`'s twin. ⚠️ The two modules read the flag - // DIFFERENTLY — identity here, truthiness there — so this module answers - // `{$null: undefined}` with `set` while that one answers `IS NOT NULL` by a - // different route. Which reading is right is the boolean-DOMAIN question - // (#5347 / #5369, measured on the sibling door as #6387); refusing it here as - // an "undefined comparand" would decide it sideways and mislabel a flag. - expect(treeFor({ d: { $null: undefined } })).toEqual({ kind: 'leaf', member: 'd', operator: 'set', values: [] }); - expect(treeFor({ d: { $exists: undefined } })).toEqual({ kind: 'leaf', member: 'd', operator: 'set', values: [] }); + // DIFFERENTLY — identity here, truthiness there. Which reading is right for + // a non-boolean value is the boolean-DOMAIN question (#5347 / #5369, + // measured on the sibling door as #6387), and #6386 did not sweep the flag + // so as not to decide it sideways. + // + // [#20035] RE-JUDGED. This case pinned `{$null: undefined}` → `set`. That + // is no longer the door's answer, and not by this door's decision: the + // shared type face (#7872, 2026-08-12: 「refuses everything else loudly at + // the compile face」) lists `$null` and `$exists` among the operators whose + // comparand is a LITERAL, and refuses `undefined` there — the engine seam + // has answered `{$null: undefined}` that way since #7872, and the door now + // runs the same face. The domain question is still not decided here: the + // face accepts every value of the six types, so an accepted non-boolean flag + // keeps this module's identity reading, unchanged. + for (const op of ['$null', '$exists'] as const) { + const err = refusalFor({ d: { [op]: undefined } }); + expect(err?.code, op).toBe('INVALID_FILTER'); + expect(err?.status, op).toBe(400); + expect(String(err?.message).startsWith(`Filter comparand at where.d.${op} is undefined.`), op).toBe(true); + } + expect(treeFor({ d: { $null: 'false' } })).toEqual({ kind: 'leaf', member: 'd', operator: 'set', values: [] }); + expect(treeFor({ d: { $exists: 'yes' } })).toEqual({ kind: 'leaf', member: 'd', operator: 'set', values: [] }); }); it('a combinator with an undefined value gets its own, truer refusal', () => { diff --git a/packages/services/service-analytics/src/__tests__/filter-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/filter-refusal-envelope.test.ts index 8eb2db97f84..5a4b432f14e 100644 --- a/packages/services/service-analytics/src/__tests__/filter-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-refusal-envelope.test.ts @@ -157,7 +157,11 @@ const REFUSALS: Array<{ // envelope block below covers the tenth site the way it covers the nine. name: 'an undefined comparand (#6386)', where: { stage: undefined }, - message: /comparand at "stage" is undefined/, + // [#20035] RE-JUDGED for the wording only: the shared comparand-TYPE face + // answers first now (#7872, 2026-08-12: 「refuses everything else loudly at + // the compile face」), in its sentence and at its path; the verdict and + // the envelope are this row's and do not move. + message: /^Filter comparand at where\.stage is undefined\./, issueBullet: false, addedAfter5352: '#6386', }, diff --git a/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts b/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts index 4fd6e27412b..1d27a9db1c7 100644 --- a/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts @@ -234,8 +234,13 @@ describe('[#5526] the SQL bind form converts only what a driver cannot bind', () // The `null` half of the same position is untouched and asserted below, in // the SQL/engine consumer blocks — that is the pair this file exists to keep // apart, and they live one `===` apart in every polarity table in the module. + // + // [#20035] RE-JUDGED for the wording only: the refusal now comes from the + // shared comparand-TYPE face, which the door runs before any leaf is built + // (#7872, 2026-08-12: 「refuses everything else loudly at the compile + // face」), in its sentence and at its path — the FilterArray spelling's. const refusal = (): unknown => normalizeAnalyticsFilterTree({ where: { code: { $eq: undefined } } }); - expect(refusal).toThrowError(/comparand at "code"\.\$eq is undefined/); + expect(refusal).toThrowError(/^Filter comparand at where\.code\.\$eq is undefined\./); // Still the module's one envelope (#5352), so the REST face answers 400. try { refusal(); diff --git a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts index d8efce7b217..09852e15e92 100644 --- a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts @@ -201,15 +201,23 @@ describe('[#20010] what is diagnosed first', () => { expect(b.message).not.toContain('mixes $-operator keys'); }); - it('CONTROL: an `undefined` outside a `$between` endpoint keeps #6386\'s own sentence — the TYPE face is not run here', () => { + it('CONTROL: an `undefined` outside a `$between` endpoint is the TYPE face\'s, not this face\'s', () => { + // [#20035] RE-JUDGED. This pinned #6386's door-local sentence, because the + // comparand-TYPE face was not run on this door. It is now — after this + // face, as in `parseFilterAST` (#7872, 2026-08-12: 「refuses everything + // else loudly at the compile face」) — so these three answer in the type + // face's sentence. What the control protects is unchanged: the SHAPE face + // judges `undefined` only as a `$between` endpoint, so none of these reads + // as a blank bound. for (const [where, path] of [ - [{ amt: { $gt: undefined } }, '"amt".$gt'], - [{ stage: { $in: [undefined] } }, '"stage".$in[0]'], - [{ stage: undefined }, '"stage"'], + [{ amt: { $gt: undefined } }, 'where.amt.$gt'], + [{ stage: { $in: [undefined] } }, 'where.stage.$in[0]'], + [{ stage: undefined }, 'where.stage'], ] as const) { const err = refusalOf(() => tree(where)); expectEnvelope(err); - expect(err.message).toContain(`comparand at ${path} is undefined`); + expect(err.message.startsWith(`Filter comparand at ${path} is undefined.`)).toBe(true); + expect(err.message).not.toContain('non-blank bounds'); } }); }); From e48c5705a115fb98e8d7a1dce97f140466cfbd78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:31:57 +0000 Subject: [PATCH 4/8] test(service-analytics): pin the comparand-TYPE face on every face of the where door Byte identity (object = FilterArray = face) for every moved cell, the order against the shape face and the door's own gates, the positions the face leaves to the door, the copy-on-write bigint narrowing, the four faces over a real ObjectQL engine with zero statements and zero aggregates on refusal, and the stored-dataset service doors. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../__tests__/where-type-face-refusal.test.ts | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts diff --git a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts new file mode 100644 index 00000000000..9b8535030a7 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts @@ -0,0 +1,539 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20035] The analytics `where` door runs the shared comparand-TYPE face on the + * object spelling, after the comparand-shape face and before any node is built. + * + * `normalizeFilterComparandTypes` (`@objectstack/spec/data`) is the #7872 door. + * The maintainer's ruling there (2026-08-12) defines the accepted comparand + * types as `string | number | bigint | boolean | null | Date` and 「refuses + * everything else loudly at the compile face」; a `bigint` within 2^53 is + * accepted and NARROWED to its number, copy-on-write. `parseFilterAST` runs it + * on everything it returns, and the engine seam on every object-form `where`, + * so this door's `FilterArray` spelling and the ObjectQL engine path always + * met it. The object spelling did not. Measured on a real engine before this + * change (recorded on the branch, `4e1cd13aac`): + * + * | object `where` | before | + * |---|---| + * | `{ stage: { $ne: { a: 1 } } }` | native bound `'{"a":1}'` and served EVERY row; the `/analytics/sql` echo answered 500; the draft preview served every row | + * | `{ amt: { $between: [{ a: 1 }, 5] } }` | `amt >= '{"a":1}' AND amt <= 5`; the engine path refused it as a `$gte` | + * | `{ stage: { $in: [Uint8Array] } }` | native bound the JSON text `'{"0":1,"1":2}'`: no row | + * | `{ stage: Uint8Array }` | flattened to `stage.0 = 1 AND stage.1 = 2`: native 500 | + * | `{ stage: new Map(…) }` | refused as #5240's zero-operator wrapper; the draft preview served every row | + * | `{ amt: { $gt: 2n ** 60n } }` | bound as-is: no row; the engine path refused it | + * | `{ amt: { $gt: undefined } }` | refused in #6386's sentence; the draft preview answered NO row | + * | `{ amt: { $gt: 2n } }` | the right rows, except the draft preview, which ordered it as text and lost `amt = 10` | + * + * while the `FilterArray` spelling and the engine seam refused every one of the + * refusal rows `INVALID_FILTER` / 400 in the face's words, and narrowed the + * last one. + * + * Blocks: one condition, one wording (object = `FilterArray` = the face, byte + * for byte); what is diagnosed first; what the face does not judge (the door's + * own sentences, unchanged); the narrowing, copy-on-write; the four faces over + * a real engine, refusals and controls; a stored dataset through the service + * doors. + * + * Every refusal asserts the ADR-0112 envelope (`code` + `status`); a bare + * `toThrow()` would be satisfied by any uncoded error. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { ObjectQL } from '@objectstack/objectql'; +import { normalizeFilterComparandTypes, type Cube } from '@objectstack/spec/data'; +import { DatasetSchema, type Dataset } from '@objectstack/spec/ui'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; + +import { lowerAnalyticsWhere, normalizeAnalyticsFilterTree } from '../strategies/filter-normalizer.js'; +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js'; +import { AnalyticsService } from '../analytics-service.js'; + +interface Refusal extends Error { + code?: unknown; + status?: unknown; +} + +const tree = (where: unknown) => normalizeAnalyticsFilterTree({ where } as never); + +function refusalOf(run: () => unknown): Refusal { + let out: unknown; + try { + out = run(); + } catch (e) { + return e as Refusal; + } + throw new Error(`expected a refusal, but it answered ${String(out)}`); +} + +function expectEnvelope(err: Refusal): void { + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); +} + +/** The face's own refusal of `node`, read straight from `@objectstack/spec`. */ +function faceSays(node: unknown): string { + return refusalOf(() => normalizeFilterComparandTypes(node)).message; +} + +/** The face's opening sentence for each refusal kind. */ +const TYPE = (path: string, kind: string) => `Filter comparand at ${path} is ${kind} (`; +const UNDEFINED = (path: string) => `Filter comparand at ${path} is undefined.`; +const BIGINT = (path: string) => `Filter comparand at ${path} is the bigint `; + +const bin = () => new Uint8Array([1, 2]); +const map = () => new Map([['a', 1]]); +class Money { + constructor(readonly amount: number) {} +} +const BIG = 2n ** 60n; + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#20035] one condition, one wording — the object spelling, the FilterArray spelling and the face agree byte for byte', () => { + // [label, object spelling, FilterArray spelling, opening sentence] + const PAIRS: Array<[string, () => unknown, () => unknown, string]> = [ + ['a plain object under $ne', () => ({ stage: { $ne: { a: 1 } } }), () => ['stage', '!=', { a: 1 }], TYPE('where.stage.$ne', 'a plain object')], + ['a plain object under $gt', () => ({ amt: { $gt: { a: 1 } } }), () => ['amt', '>', { a: 1 }], TYPE('where.amt.$gt', 'a plain object')], + ['a plain object under $lte', () => ({ amt: { $lte: { a: 1 } } }), () => ['amt', '<=', { a: 1 }], TYPE('where.amt.$lte', 'a plain object')], + ['a plain-object $between endpoint', () => ({ amt: { $between: [{ a: 1 }, 5] } }), () => ['amt', 'between', [{ a: 1 }, 5]], TYPE('where.amt.$between[0]', 'a plain object')], + ['a plain-object $in member', () => ({ stage: { $in: ['won', { a: 1 }] } }), () => ['stage', 'in', ['won', { a: 1 }]], TYPE('where.stage.$in[1]', 'a plain object')], + ['a plain-object $nin member', () => ({ stage: { $nin: [{ a: 1 }] } }), () => ['stage', 'nin', [{ a: 1 }]], TYPE('where.stage.$nin[0]', 'a plain object')], + ['a plain object under $contains', () => ({ stage: { $contains: { a: 1 } } }), () => ['stage', 'contains', { a: 1 }], TYPE('where.stage.$contains', 'a plain object')], + ['a plain object under $icontains', () => ({ stage: { $icontains: { a: 1 } } }), () => ['stage', 'icontains', { a: 1 }], TYPE('where.stage.$icontains', 'a plain object')], + ['a non-string { $field } (not a reference)', () => ({ amt: { $gt: { $field: 5 } } }), () => ['amt', '>', { $field: 5 }], TYPE('where.amt.$gt', 'a plain object')], + ['a binary under $ne', () => ({ stage: { $ne: bin() } }), () => ['stage', '!=', bin()], TYPE('where.stage.$ne', 'a Uint8Array instance')], + ['a binary $in member', () => ({ stage: { $in: [bin()] } }), () => ['stage', 'in', [bin()]], TYPE('where.stage.$in[0]', 'a Uint8Array instance')], + ['a binary implicit comparand', () => ({ stage: bin() }), () => ['stage', '=', bin()], TYPE('where.stage', 'a Uint8Array instance')], + ['a Map implicit comparand', () => ({ stage: map() }), () => ['stage', '=', map()], TYPE('where.stage', 'a Map instance')], + ['a class instance implicit comparand', () => ({ amt: new Money(5) }), () => ['amt', '=', new Money(5)], TYPE('where.amt', 'a Money instance')], + ['a bigint beyond 2^53 under $gt', () => ({ amt: { $gt: BIG } }), () => ['amt', '>', BIG], BIGINT('where.amt.$gt')], + ['a bigint beyond 2^53 as a $in member', () => ({ amt: { $in: [1, BIG] } }), () => ['amt', 'in', [1, BIG]], BIGINT('where.amt.$in[1]')], + ['a bigint beyond -2^53 implicit', () => ({ amt: -BIG }), () => ['amt', '=', -BIG], BIGINT('where.amt')], + ['an undefined implicit comparand', () => ({ stage: undefined }), () => ['stage', '=', undefined], UNDEFINED('where.stage')], + ['an undefined under $gt', () => ({ amt: { $gt: undefined } }), () => ['amt', '>', undefined], UNDEFINED('where.amt.$gt')], + ['an undefined under $ne', () => ({ stage: { $ne: undefined } }), () => ['stage', '!=', undefined], UNDEFINED('where.stage.$ne')], + ['an undefined $in member', () => ({ stage: { $in: ['won', undefined] } }), () => ['stage', 'in', ['won', undefined]], UNDEFINED('where.stage.$in[1]')], + ['under $and', () => ({ $and: [{ id: 'd3' }, { stage: { $ne: { a: 1 } } }] }), () => ['and', ['id', '=', 'd3'], ['stage', '!=', { a: 1 }]], TYPE('where.$and[1].stage.$ne', 'a plain object')], + ['under $or', () => ({ $or: [{ id: 'd3' }, { amt: { $gt: BIG } }] }), () => ['or', ['id', '=', 'd3'], ['amt', '>', BIG]], BIGINT('where.$or[1].amt.$gt')], + ['a flat conjunction', () => ({ $and: [{ id: 'd3' }, { stage: bin() }] }), () => [['id', '=', 'd3'], ['stage', '=', bin()]], TYPE('where.$and[1].stage', 'a Uint8Array instance')], + ]; + + for (const [name, object, array, opening] of PAIRS) { + it(name, () => { + const face = faceSays(object()); + const objectErr = refusalOf(() => tree(object())); + const arrayErr = refusalOf(() => tree(array())); + expectEnvelope(objectErr); + expectEnvelope(arrayErr); + expect(face.startsWith(opening), face).toBe(true); + expect(objectErr.message).toBe(face); + expect(arrayErr.message).toBe(face); + expect(objectErr.message).toContain('The filter was NOT applied'); + }); + } + + // The positions with no `FilterArray` spelling: `'='` lowers to the implicit + // slot, `$not` has no array keyword, and the null predicates ignore their + // value. The object spelling is held to the face's own answer. + const OBJECT_ONLY: Array<[string, unknown, string]> = [ + ['a plain object under $eq', { stage: { $eq: { a: 1 } } }, TYPE('where.stage.$eq', 'a plain object')], + ['a binary under $eq', { stage: { $eq: bin() } }, TYPE('where.stage.$eq', 'a Uint8Array instance')], + ['a Map under $eq', { stage: { $eq: map() } }, TYPE('where.stage.$eq', 'a Map instance')], + ['an undefined under $eq', { stage: { $eq: undefined } }, UNDEFINED('where.stage.$eq')], + ['an undefined $null flag', { stage: { $null: undefined } }, UNDEFINED('where.stage.$null')], + ['a plain-object $null flag', { stage: { $null: { a: 1 } } }, TYPE('where.stage.$null', 'a plain object')], + ['an undefined $exists flag', { stage: { $exists: undefined } }, UNDEFINED('where.stage.$exists')], + ['under $not', { $not: { stage: { $ne: { a: 1 } } } }, TYPE('where.$not.stage.$ne', 'a plain object')], + ['under $not over $or', { $not: { $or: [{ id: 'd3' }, { amt: { $lt: BIG } }] } }, BIGINT('where.$not.$or[1].amt.$lt')], + ]; + + for (const [name, where, opening] of OBJECT_ONLY) { + it(`${name} (no FilterArray spelling: held to the face)`, () => { + const face = faceSays(where); + const objectErr = refusalOf(() => tree(where)); + expectEnvelope(objectErr); + expect(face.startsWith(opening), face).toBe(true); + expect(objectErr.message).toBe(face); + }); + } + + it('a nested relation is judged on its dotted member, byte-identical to the dotted spelling', () => { + // The face leaves a nested-relation object alone as filter STRUCTURE; this + // compiler flattens it to `acct.amt`, so the door hands its entries over. + const dotted = { 'acct.amt': { $gt: { a: 1 } } }; + const face = faceSays(dotted); + expect(face.startsWith(TYPE('where.acct.amt.$gt', 'a plain object'))).toBe(true); + expect(refusalOf(() => tree({ acct: { amt: { $gt: { a: 1 } } } })).message).toBe(face); + expect(refusalOf(() => tree(dotted)).message).toBe(face); + expect(refusalOf(() => tree(['acct.amt', '>', { a: 1 }])).message).toBe(face); + // …at any depth, and under a combinator. + expect(refusalOf(() => tree({ $or: [{ id: 'd3' }, { acct: { owner: { amt: undefined } } }] })).message) + .toBe(faceSays({ $or: [{ id: 'd3' }, { 'acct.owner.amt': undefined }] })); + }); +}); + +describe('[#20035] what is diagnosed first', () => { + it('the shape face before the type face, over the WHOLE condition — parseFilterAST\'s order', () => { + // A type defect in the first entry, a shape defect in the second: the + // shape face judges the whole condition first, on both spellings. + const object = { amt: { $gt: { a: 1 } }, stage: { $in: 'won' } }; + const array = [['amt', '>', { a: 1 }], ['stage', 'in', 'won']]; + const objectErr = refusalOf(() => tree(object)); + expectEnvelope(objectErr); + expect(objectErr.message.startsWith('Operator "$in" on field "stage" requires an ARRAY of values')).toBe(true); + expect(refusalOf(() => tree(array)).message.replace('where.$and[1]', 'where')).toBe(objectErr.message); + // Inside one list: the null member (shape) before the plain object (type). + const member = refusalOf(() => tree({ stage: { $in: [{ a: 1 }, null] } })); + expect(member.message.startsWith('Operator "$in" on field "stage" does not accept null as a list member')).toBe(true); + }); + + it('the equality-slot list before either (#19888)', () => { + const err = refusalOf(() => tree({ stage: [1, { a: 1 }] })); + expectEnvelope(err); + expect(err.message.startsWith('The implicit-equality comparand on field "stage" requires a single comparable value')).toBe(true); + }); + + it('the type face before this door\'s own gates: #6444\'s mixed wrapper, #3948\'s operator vocabulary', () => { + const mixed = refusalOf(() => tree({ amt: { $gt: { a: 1 }, nested: 'x' } })); + expect(mixed.message.startsWith(TYPE('where.amt.$gt', 'a plain object'))).toBe(true); + expect(mixed.message).not.toContain('mixes $-operator keys'); + // `$like` is in the face's vocabulary, so its comparand is judged first; + // this door's unsupported-operator refusal answers an accepted comparand. + expect(refusalOf(() => tree({ stage: { $like: bin() } })).message.startsWith(TYPE('where.stage.$like', 'a Uint8Array instance'))).toBe(true); + expect(refusalOf(() => tree({ stage: { $like: 'w%' } })).message).toContain('Unsupported filter operator "$like"'); + }); +}); + +describe('[#20035] what the type face does not judge keeps this door\'s own sentence', () => { + // The face steps around arrays outside the list operators, `{ $field }` + // references and unknown operators, so those positions reach the door's own + // gates (#6386's `undefined` sweep, #5234's member and LIKE checks) exactly + // as before. + it('an undefined inside an ARRAY comparand, or under an unknown operator — #6386', () => { + for (const [where, path] of [ + [{ d: { $contains: ['a', undefined] } }, '"d".$contains[1]'], + [{ d: { $wat: undefined } }, '"d".$wat'], + ] as const) { + const err = refusalOf(() => tree(where)); + expectEnvelope(err); + expect(err.message).toContain(`[analytics] comparand at ${path} is undefined`); + } + }); + + it('an ARRAY or a { $field } member of $in, and the same as a LIKE comparand — #5234 / #7598', () => { + expect(refusalOf(() => tree({ s: { $in: ['a', [1, 2]] } })).message).toContain('cannot be bound as a SQL parameter'); + expect(refusalOf(() => tree({ s: { $in: [{ $field: 'other' }] } })).message).toContain('cannot be bound as a SQL parameter'); + expect(refusalOf(() => tree({ s: { $contains: ['a', 'b'] } })).message).toContain('StringOperatorSchema'); + expect(refusalOf(() => tree({ s: { $contains: { $field: 'other' } } })).message).toContain('StringOperatorSchema'); + }); +}); + +describe('[#20035] a bigint within 2^53 is NARROWED, copy-on-write — and every other accepted comparand is untouched', () => { + const leaf = (member: string, operator: string, values: unknown[]) => ({ kind: 'leaf', member, operator, values }); + + it('the tree carries the number, the same tree the FilterArray spelling compiles', () => { + for (const [object, array, expected] of [ + [{ amt: { $gt: 2n } }, ['amt', '>', 2n], leaf('amt', 'gt', [2])], + [{ amt: 5n }, ['amt', '=', 5n], leaf('amt', 'equals', [5])], + [{ amt: { $in: [5n, 10n] } }, ['amt', 'in', [5n, 10n]], leaf('amt', 'in', [5, 10])], + [{ amt: { $gte: 2n ** 53n } }, ['amt', '>=', 2n ** 53n], leaf('amt', 'gte', [2 ** 53])], + ] as const) { + expect(tree(object)).toEqual(expected); + expect(tree(array)).toEqual(expected); + expect(typeof (tree(object) as { values: unknown[] }).values[0]).toBe('number'); + } + // A $between keeps its two bounds, narrowed. + expect(tree({ amt: { $between: [2n, 5n] } })).toEqual({ + kind: 'and', + children: [leaf('amt', 'gte', [2]), leaf('amt', 'lte', [5])], + }); + // A nested relation's bigint is narrowed on its dotted member. + expect(tree({ acct: { amt: 7n } })).toEqual(leaf('acct.amt', 'equals', [7])); + }); + + it('the caller\'s condition is never edited, and nothing is copied when nothing narrowed', () => { + const inner = { amt: { $gt: 2n } }; + const where = { $and: [{ stage: 'won' }, inner] }; + const lowered = lowerAnalyticsWhere({ where }) as { $and: Array> }; + expect(lowered).not.toBe(where); + expect(lowered.$and[0]).toBe(where.$and[0]); + expect(lowered.$and[1]).toEqual({ amt: { $gt: 2 } }); + expect(inner.amt.$gt).toBe(2n); + + const untouched = { $and: [{ stage: 'won' }, { amt: { $gt: 2, $lt: new Date(0) } }], $not: { acct: { region: 'emea' } } }; + expect(lowerAnalyticsWhere({ where: untouched })).toBe(untouched); + }); + + it('CONTROL: the accepted comparands compile exactly as before', () => { + const when = new Date('2026-03-04T05:06:07.000Z'); + const ACCEPTED: Array<[unknown, unknown]> = [ + [{ stage: 'won' }, leaf('stage', 'equals', ['won'])], + [{ amt: { $gt: 2 } }, leaf('amt', 'gt', [2])], + [{ flag: true }, leaf('flag', 'equals', [true])], + [{ stage: null }, leaf('stage', 'notSet', [])], + [{ stage: { $eq: null } }, leaf('stage', 'notSet', [])], + [{ stage: { $ne: null } }, leaf('stage', 'set', [])], + [{ stage: { $null: true } }, leaf('stage', 'notSet', [])], + [{ stage: { $contains: null } }, leaf('stage', 'contains', [null])], + [{ amt: { $gt: { $field: 'id' } } }, leaf('amt', 'gt', [{ $field: 'id' }])], + [{ acct: { region: 'emea' } }, leaf('acct.region', 'equals', ['emea'])], + ]; + for (const [where, expected] of ACCEPTED) { + expect(tree(where), JSON.stringify(where)).toEqual(expected); + } + // A Date is a comparand, not structure, and is not copied. + expect((tree({ closed: { $gte: when } }) as { values: unknown[] }).values[0]).toBe(when); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +const OBJECT = 'deal'; +const ROWS = [ + { id: 'd1', amt: 1, stage: 'won' }, + { id: 'd2', amt: 5, stage: 'lost' }, + { id: 'd3', amt: 10, stage: 'open' }, + { id: 'd4', amt: null, stage: null }, + { id: 'd5', amt: 3, stage: '' }, +]; +const FIELDS = { + id: { type: 'text', name: 'id' }, + amt: { type: 'number', name: 'amt' }, + stage: { type: 'text', name: 'stage' }, +}; +const CUBE: Cube = { + name: 'deals', + sql: OBJECT, + measures: { n: { sql: '*', type: 'count', title: 'n' } }, + dimensions: Object.fromEntries( + [['id', 'string'], ['amt', 'number'], ['stage', 'string']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), + ), + public: false, +} as unknown as Cube; +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } } as never; + +function carriesBigint(value: unknown): boolean { + if (typeof value === 'bigint') return true; + if (Array.isArray(value)) return value.some(carriesBigint); + if (value && typeof value === 'object' && !(value instanceof Date)) return Object.values(value).some(carriesBigint); + return false; +} + +describe('[#20035] every analytics face refuses before anything runs, and narrows what it accepts (real engine)', () => { + let driver: SqliteWasmDriver; + let engine: ObjectQL; + let statements = 0; + let aggregates = 0; + let bound: unknown[][] = []; + let received: unknown[] = []; + + const runRawSql = async (sql: string, params: unknown[]): Promise[]> => { + statements++; + bound.push(params); + const result = await driver.execute(sql.replace(/\$\d+/g, '?'), params); + if (Array.isArray(result)) return result as Record[]; + if (result && typeof result === 'object' && 'rows' in (result as Record)) { + return (result as { rows: Record[] }).rows; + } + return []; + }; + + const ctxFor = (nativeSql: boolean): StrategyContext => + ({ + getCube: (name: string) => (name === 'deals' ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql, objectqlAggregate: !nativeSql, inMemory: false }), + getReadScope: () => undefined, + executeRawSql: (_object: string, sql: string, params: unknown[]) => runRawSql(sql, params), + // The engine path: a REAL ObjectQL engine over the same sql.js database, + // with the auto-bridge's own mapping (`plugin.ts`). + executeAggregate: async (objectName: string, options: Record) => { + aggregates++; + received.push(options.filter); + return (await engine.aggregate(objectName, { + where: options.filter, + groupBy: options.groupBy, + aggregations: options.aggregations?.map((a: any) => ({ function: a.method, field: a.field, alias: a.alias })), + } as never)) as Record[]; + }, + sqlDialect: () => 'sqlite', + }) as unknown as StrategyContext; + + const q = (where: unknown) => ({ cube: 'deals', dimensions: ['id'], measures: ['n'], where }) as unknown as AnalyticsQuery; + const ids = (rows: Array>) => rows.map((r) => String(r.id)).sort(); + + const FACES: Array<[string, (where: unknown) => Promise]> = [ + ['native execute', async (where) => ids((await new NativeSQLStrategy().execute(q(where), ctxFor(true))).rows)], + ['/analytics/sql echo', async (where) => { + const { sql, params } = await new ObjectQLStrategy().generateSql(q(where), ctxFor(false)); + return ids(await runRawSql(sql, params)); + }], + ['ObjectQL engine path', async (where) => ids((await new ObjectQLStrategy().execute(q(where), ctxFor(false))).rows)], + ['draft preview', async (where) => ids(evaluateAnalyticsQueryOverRows(q(where), CUBE, ROWS.map((r) => ({ ...r }))).rows)], + ]; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + (driver as unknown as { logger: unknown }).logger = quiet; + await driver.initObjects([{ name: OBJECT, fields: FIELDS } as never]); + for (const row of ROWS) await driver.create(OBJECT, { ...row }); + engine = new ObjectQL({ logger: quiet }); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerObject({ name: OBJECT, label: 'Deal', fields: FIELDS } as never); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + it('CONTROL: no where serves every row, and scalars and null serve the same rows on every face', async () => { + // Without this, the refusals below could pass on a harness that serves nothing. + for (const [face, run] of FACES) { + expect(await run(undefined), `${face}: no where`).toEqual(['d1', 'd2', 'd3', 'd4', 'd5']); + expect(await run({ stage: 'won' }), `${face}: scalar`).toEqual(['d1']); + expect(await run({ amt: { $gt: 2 } }), `${face}: $gt 2`).toEqual(['d2', 'd3', 'd5']); + expect(await run({ stage: null }), `${face}: null`).toEqual(['d4']); + expect(await run({ stage: { $ne: 'won' } }), `${face}: $ne`).toEqual(['d2', 'd3', 'd4', 'd5']); + } + }); + + it('CONTROL: a bigint within 2^53 serves the same rows as its number on every face, and reaches none of them as a bigint', async () => { + for (const [face, run] of FACES) { + bound = []; + received = []; + expect(await run({ amt: { $gt: 2n } }), `${face}: $gt 2n`).toEqual(['d2', 'd3', 'd5']); + expect(await run({ amt: 5n }), `${face}: 5n`).toEqual(['d2']); + expect(await run({ amt: { $in: [5n, 10n] } }), `${face}: $in`).toEqual(['d2', 'd3']); + expect(await run({ amt: { $between: [2n, 5n] } }), `${face}: $between`).toEqual(['d2', 'd5']); + expect(bound.some(carriesBigint), `${face}: a bigint was bound`).toBe(false); + expect(received.some(carriesBigint), `${face}: the engine received a bigint`).toBe(false); + } + }); + + const REFUSED: Array<[string, unknown, string]> = [ + ['a plain object under $ne (served EVERY row natively before)', { stage: { $ne: { a: 1 } } }, TYPE('where.stage.$ne', 'a plain object')], + ['a plain object under $gt', { amt: { $gt: { a: 1 } } }, TYPE('where.amt.$gt', 'a plain object')], + ['a plain object under $eq', { stage: { $eq: { a: 1 } } }, TYPE('where.stage.$eq', 'a plain object')], + ['a plain-object $between endpoint', { amt: { $between: [{ a: 1 }, 5] } }, TYPE('where.amt.$between[0]', 'a plain object')], + ['a binary $in member', { stage: { $in: [bin()] } }, TYPE('where.stage.$in[0]', 'a Uint8Array instance')], + ['a binary under $ne', { stage: { $ne: bin() } }, TYPE('where.stage.$ne', 'a Uint8Array instance')], + ['a binary implicit comparand', { stage: bin() }, TYPE('where.stage', 'a Uint8Array instance')], + ['a Map implicit comparand', { stage: map() }, TYPE('where.stage', 'a Map instance')], + ['a bigint beyond 2^53', { amt: { $gt: BIG } }, BIGINT('where.amt.$gt')], + ['an undefined under $gt (the preview answered no row)', { amt: { $gt: undefined } }, UNDEFINED('where.amt.$gt')], + ['an undefined implicit comparand', { stage: undefined }, UNDEFINED('where.stage')], + ['an undefined $in member', { stage: { $in: [undefined] } }, UNDEFINED('where.stage.$in[0]')], + ['an undefined $null flag', { stage: { $null: undefined } }, UNDEFINED('where.stage.$null')], + ['under $or (served EVERY row natively before)', { $or: [{ id: 'd3' }, { stage: { $ne: { a: 1 } } }] }, TYPE('where.$or[1].stage.$ne', 'a plain object')], + ]; + + for (const [name, where, opening] of REFUSED) { + for (const [face, run] of FACES) { + it(`${face}: ${name} is refused, and nothing reaches the database or the engine`, async () => { + statements = 0; + aggregates = 0; + let err: Refusal | undefined; + let served: string[] | undefined; + try { + served = await run(where); + } catch (e) { + err = e as Refusal; + } + expect(served, `${face}: expected a refusal, got rows`).toBeUndefined(); + expectEnvelope(err as Refusal); + expect((err as Refusal).message.startsWith(opening), (err as Refusal).message).toBe(true); + expect(statements).toBe(0); + expect(aggregates).toBe(0); + }); + } + } +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#20035] a STORED dataset carrying an off-set comparand is refused on every service door', () => { + // A plain object is the off-set comparand a stored (JSON) dataset can carry. + const BASE = DatasetSchema.parse({ + name: 'deal_metrics', + label: 'Deal Metrics', + object: OBJECT, + dimensions: [{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' }], + measures: [{ name: 'deal_count', label: 'Deals', aggregate: 'count' }], + }) as Dataset; + const withScope = (filter: unknown): Dataset => ({ ...BASE, filter } as Dataset); + const withMeasureFilter = (filter: unknown): Dataset => + ({ ...BASE, measures: [{ ...BASE.measures[0], filter }] } as Dataset); + + function service(preview: boolean) { + const calls = { aggregate: 0, raw: 0 }; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => { + calls.aggregate++; + return [{ stage: 'won', deal_count: 1 }]; + }, + executeRawSql: async () => { + calls.raw++; + return []; + }, + ...(preview ? { draftRowsResolver: async () => ROWS.map((r) => ({ ...r })) } : {}), + }); + return { svc, calls }; + } + + const DOORS: Array<[string, (ds: Dataset) => Promise]> = [ + ['queryDataset (the dashboard door)', (ds) => service(false).svc.queryDataset!(ds, { measures: ['deal_count'], dimensions: ['stage'] })], + ['queryDataset over a pending seed draft (the draft preview)', (ds) => service(true).svc.queryDataset!(ds, { measures: ['deal_count'], dimensions: ['stage'] }, undefined, { previewDrafts: true })], + ]; + + // The path prefix depends on how each door composes the stored filter into + // the condition it lowers, so the position is asserted from the field on. + const STORED: Array<[string, (f: unknown) => Dataset, unknown, string]> = [ + ['the dataset scope filter, a plain object under $ne', withScope, { stage: { $ne: { a: 1 } } }, '.stage.$ne is a plain object ({"a":1})'], + ['a measure filter, a plain-object $in member', withMeasureFilter, { stage: { $in: [{ a: 1 }] } }, '.stage.$in[0] is a plain object ({"a":1})'], + ]; + + for (const [label, filterOf, where, position] of STORED) { + for (const [door, run] of DOORS) { + it(`${door}: ${label} → INVALID_FILTER / 400`, async () => { + let err: Refusal | undefined; + try { + await run(filterOf(where)); + } catch (e) { + err = e as Refusal; + } + expectEnvelope(err as Refusal); + expect((err as Refusal).message.startsWith('Filter comparand at where'), (err as Refusal).message).toBe(true); + expect((err as Refusal).message).toContain(position); + }); + } + } + + it('the registered cube on the ObjectQL door refuses before engine.aggregate runs', async () => { + const { svc, calls } = service(false); + svc.registerDataset(withScope({ stage: { $ne: { a: 1 } } })); + let err: Refusal | undefined; + try { + await svc.query({ cube: 'deal_metrics', measures: ['deal_count'] } as AnalyticsQuery); + } catch (e) { + err = e as Refusal; + } + expectEnvelope(err as Refusal); + expect(calls.aggregate).toBe(0); + }); + + it('CONTROL: the same dataset with an accepted comparand answers on the dashboard door', async () => { + const { svc, calls } = service(false); + const result = (await svc.queryDataset!( + withScope({ stage: { $ne: 'lost' } }), + { measures: ['deal_count'], dimensions: ['stage'] }, + )) as { rows: unknown[] }; + expect(result.rows.length).toBeGreaterThan(0); + expect(calls.aggregate).toBeGreaterThan(0); + }); +}); From e3f929511c482cd10134dc1c2eb059190ace4bf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:33:01 +0000 Subject: [PATCH 5/8] docs(service-analytics): record the comparand-TYPE face in the where door's docblocks The module header gains the #20035 section; the #6386, #5234 and toSqlBindValue notes say which positions the type face now answers first and which the door's own gates keep. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/strategies/filter-normalizer.ts | 68 ++++++++++++++++--- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index 5f9218552fe..69ad109957c 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -280,7 +280,10 @@ * The refusal is {@link undefinedComparandError}, in this module's existing * envelope (`INVALID_FILTER` / 400) — the opposite attribution from * `read-scope-sql`'s 500, and deliberately so: that door compiles a platform - * artifact, this one receives what the CALLER wrote. + * artifact, this one receives what the CALLER wrote. [#20035] In every + * position the shared comparand-TYPE face judges, that face now refuses the + * `undefined` first, in the same envelope and in its own sentence (see the + * #20035 section below); this gate answers the positions it does not judge. * * ⛔ `null` does not move, and that is the way this change could do harm: the two * live one `===` apart in every polarity table here. `{d: null}`, `{$eq: null}`, @@ -361,9 +364,27 @@ * `INVALID_FILTER` / 400. {@link assertWhereComparandShapes} now hands every * field entry to the face after the #19888 equality pass, so both spellings * get the face's own refusal byte for byte, on every face of this door and on - * the draft preview. The comparand-TYPE face is not run here; `undefined` - * keeps {@link assertDefinedComparands}' refusal except as a `$between` - * endpoint, which the face now answers. + * the draft preview. [#20035] The comparand-TYPE face now runs right after + * it — the next section. + * + * # …and so does the shared comparand-TYPE face (#20035) + * + * The maintainer's ruling on #7872 (2026-08-12) puts the accepted comparand + * types — `string | number | bigint | boolean | null | Date` — on the shared + * type face, `normalizeFilterComparandTypes`, which 「refuses everything else + * loudly at the compile face」 and narrows a `bigint` within 2^53 to its + * number. `parseFilterAST` runs it on the `FilterArray` spelling and the + * engine seam on every object-form `where`; the object spelling of this door + * never met it, so a plain object under `$ne` bound as JSON text and served + * every row while the other spelling was refused 400. {@link normalizeWhereComparands} + * now runs it after the shape face and before any node is built, in + * `parseFilterAST`'s order, and the condition this door lowers is the face's + * RETURN value. Refusals this door gave in its own words for a position the + * type face judges (#6386's `undefined`, #5234's unbindable member and + * LIKE-family comparand) now read in the face's words; the positions it does + * not judge keep theirs. Binary is reconciled to the face's refusal rather + * than kept as a declared local extra — the evidence is on + * {@link normalizeWhereComparands}. * * Row-result cover: `filter-operator-coverage.test.ts` for the operator * vocabulary, `native-sql-filter-logic-conformance.test.ts`, which runs the @@ -381,7 +402,8 @@ * `where-equality-slot-list-refusal.test.ts` for the equality-slot list refusal * on every analytics face and its neighbouring shapes (#19888), and * `where-face-arms-refusal.test.ts` for the face's other arms, both spellings, - * every face (#20010). + * every face (#20010), and `where-type-face-refusal.test.ts` for the + * comparand-TYPE face, both spellings, every face, and its narrowing (#20035). */ import { @@ -500,7 +522,8 @@ const MONGO_TO_CUBE_OP: Record = { * ## Addendum (#6386): the `undefined` arm is now UNREACHABLE from this door * * {@link assertDefinedComparands} refuses an `undefined` before any comparand is - * read, and it covers every call site of this function — the `$between` bounds, + * read — and since #20035 the shared comparand-TYPE face refuses it before that, + * in every position it judges — and it covers every call site of this function — the `$between` bounds, * the operator value and its array members, and the implicit `=` (the bare-array * `$in` that used to be a fifth call site is refused whole since #19888) — so * nothing can arrive here holding `undefined` any more. The refusal @@ -619,7 +642,11 @@ function andOf(children: NormalizedFilterNode[]): NormalizedFilterNode | null { * * Two shapes are refused, the two #5234 measured. `$eq` and friends keep * binding any OTHER object as JSON (`toSqlBindValue`), which remains a separate - * account. + * account. [#20035] That account is closed: the shared comparand-TYPE face + * refuses a plain object, a `Map`, a binary or a class instance in every + * comparand position before this function runs (the #7872 ruling). From the + * `where` door this gate now answers only what that face steps around — an + * ARRAY and a `{ $field }` reference, as a list member or a LIKE comparand. * * ⚠️ [#7598, maintainer ruling 2026-08-12 Q1 = B] A THIRD arm briefly lived * here — a `{$field}` reference in the comparand of the six scalar comparison @@ -793,14 +820,29 @@ function undefinedComparandError(field: string, path: string): Error { * that face before any leaf is built, so this gate's sentence is reached * only in the other positions. * + * [#20035] From the `where` door, the shared comparand-TYPE face now answers + * first in every position it judges — the implicit comparand, each declared + * operator's comparand, each `$in` / `$nin` member — in its own sentence and at + * its own path (the #7872 ruling). This gate's sentence is reached only where + * that face steps around: a member of an ARRAY comparand outside the list + * operators (`{d: {$contains: ['a', undefined]}}`) and the comparand of an + * operator outside the vocabulary (`{d: {$wat: undefined}}`). It stays as + * {@link fieldLeaves}' invariant, the same stance {@link assertCompilableComparand} + * takes, and `where-type-face-refusal.test.ts` pins those two positions. + * * `$null` / `$exists` are deliberately NOT swept, exactly as on the twin: their * comparand is a declared BOOLEAN — a flag, not a value to compare against — so * `undefined` there is not a comparand at all. ⚠️ This module reads that flag by * IDENTITY (`=== true` / `=== false`, see {@link fieldLeaves}) where the twin - * reads it by truthiness, so `{$null: undefined}` lowers here to `set` + * reads it by truthiness, so `{$null: undefined}` used to lower here to `set` * (`IS NOT NULL`). That is the boolean-DOMAIN question #5347 / #5369 opened and - * #6387 is measuring on the sibling door; it is a different cell and is not - * decided as a rider on this one. + * #6387 measured on the sibling door; it is a different cell and is not + * decided as a rider on this one. [#20035] The `undefined` half of it is + * answered upstream now, and not by this gate: the shared comparand-TYPE face + * judges the `$null` / `$exists` comparand as a literal (its operator split), + * so from the `where` door `{$null: undefined}` is refused before any leaf + * exists. An ACCEPTED non-boolean flag (`{$null: 'false'}`) still reaches the + * identity read unchanged. * * ## Why the gate sits HERE, and what that decides for `{$not: {d: undefined}}` * @@ -2086,7 +2128,11 @@ export function collectFilterLeaves( * unbindable object would otherwise reach the driver. * - any other object / array → JSON text. Not a meaningful comparison on any * column, but the shape `filter.zod.ts` cannot exclude, and a driver-level - * bind error tells the author nothing about their filter. + * bind error tells the author nothing about their filter. [#20035] From the + * `where` door no plain object, `Map`, binary or class instance reaches + * this arm any more: the shared comparand-TYPE face refuses each before a + * leaf exists, where it used to bind here as JSON text (a plain object + * under `$ne` served every row that way). * * `number`, `bigint`, `null` and `string` pass through — `null` included, and * that is deliberate: `col > NULL` is UNKNOWN, so the widget draws nothing. It is From f4ba18ba9a73965210f9a1ee6cd68556151b15a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:54:48 +0000 Subject: [PATCH 6/8] chore(changeset): service-analytics minor for the where door's comparand-TYPE face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-②: no (narrowing). ADR-0087: not-required (no-migration-prescription); the #7872 transition is not on the ledger. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .changeset/20035-analytics-where-type-face.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/20035-analytics-where-type-face.md diff --git a/.changeset/20035-analytics-where-type-face.md b/.changeset/20035-analytics-where-type-face.md new file mode 100644 index 00000000000..dccacbad3a1 --- /dev/null +++ b/.changeset/20035-analytics-where-type-face.md @@ -0,0 +1,33 @@ +--- +"@objectstack/service-analytics": minor +--- + +fix(service-analytics)!: the analytics `where` door runs the shared comparand-TYPE face on the object spelling, so a plain-object, binary, `Map`, class-instance, oversized-bigint or `undefined` comparand is refused like the `FilterArray` spelling and the engine refuse it (#20035) + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` in the object spelling that carries one of the comparands below compiled before this change, at any depth under `$and` / `$or` / `$not` or inside a nested relation. It is now refused with `INVALID_FILTER` / 400, in the shared face's own message, path and prescription, before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings. + +| comparand in an object-form `where` | before (native SQL execute) | now, on every face | +|:--|:--|:--| +| a plain object under a scalar operator: `{ stage: { $ne: { a: 1 } } }`, `$gt`, `$eq`, the LIKE family | bound as the JSON text `'{"a":1}'`; `$ne` served EVERY row, the others matched nothing; the `/analytics/sql` echo answered `DATABASE_ERROR` / 500 | refused 400: "Filter comparand at where.stage.$ne is a plain object …" | +| a plain object as a `$between` endpoint or an `$in` / `$nin` member | the endpoint was compared as JSON text; the member was refused in this package's own sentence | refused 400, in the face's sentence | +| a `{ $field: 5 }` object (a non-string `$field`) under an ordering operator | bound as JSON text (it is not a field reference) | refused 400 as a plain object | +| a binary (`Uint8Array` / `Buffer`) under `$eq` / `$ne` or as an `$in` member | bound as the JSON text `'{"0":1,…}'`, never as a blob; `$ne` served every row | refused 400 | +| a binary, a `Map` or a class instance as the implicit comparand `{ stage: VALUE }` | read as a NESTED RELATION (`stage.0 = 1 AND …`, a 500 on the native path) or refused as a zero-operator wrapper | refused 400 as the value it is | +| a `bigint` beyond ±2^53 | bound as-is, answering no row | refused 400 | +| `undefined`, implicit or under any operator, including `$null` / `$exists` | refused 400 in this package's own sentence, except `{ $null: undefined }`, which compiled to IS NOT NULL; the draft preview answered no row | refused 400, in the face's sentence | + +The shared comparand-type face in `@objectstack/spec` (`normalizeFilterComparandTypes`) is the #7872 door: its accepted comparand types are `string | number | bigint | boolean | null | Date`, and it refuses everything else loudly at the compile face (maintainer ruling, 2026-08-12). `parseFilterAST` runs it on everything it returns and the ObjectQL engine's seam on every object-form `where`, so the `FilterArray` spelling of this door and the engine path already refused each row above. The object spelling now meets it too, after the comparand-shape face and before any node is built, the order `parseFilterAST` uses. Both spellings of one condition get the same refusal, byte for byte, on the native SQL execute path, the `/analytics/sql` echo, the ObjectQL engine path and the draft-data preview. + +A `bigint` within ±2^53 is not refused: the face narrows it to its number, and the condition every face lowers is the narrowed one. The rows do not change on the published faces. The draft-data preview used to order a bigint as text (`{ amt: { $gt: 2n } }` lost the row holding 10); it now evaluates the narrowed number and charts the published rows. + +Binary comparands are reconciled with the face rather than kept as a declared local extra of this package. Measured before this change, the `where` door never compared a binary as a blob on any face: the native path bound it as JSON text, the engine path and the `FilterArray` spelling refused it, and no producer can send one over JSON. The read-scope door's own binary admission is not changed. + +Refusals this door already gave in its own words now read in the face's words, the same words the `FilterArray` spelling gets: an `undefined` comparand, and a plain-object `$in` / `$nin` member or LIKE-family comparand. Their verdict, code and status are unchanged. The positions the face does not judge keep this package's sentences: an array or a `{ $field }` reference as a list member or a LIKE comparand, and an `undefined` inside an array comparand or under an operator outside the vocabulary. + +Who is affected: nothing in this repository's examples, seeds, docs or package sources authors any of the refused comparands. A text scan over 4013 non-test files found none, and it did find the shape in a code comment written for this change. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. Of the refused values, only the plain object can be stored as JSON. + +Not changed: every string, number, boolean, `null` and `Date` comparand; a `{ $field }` reference in an ordering slot (served on the engine path); nested relations and dotted members; `$ne` with a list, which the shared face does not judge yet. From 8c78550a75e60172f23ac33d60d478f80ae518aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:03:55 +0000 Subject: [PATCH 7/8] docs(service-analytics): scope comparand-shape.ts's where-door sentences to the type face Docblocks only. The undefined table's where row now names the shared comparand-TYPE face as the first refusal (same verdict and envelope), with #6386's gate kept for the two positions the face steps around; the header's envelope clause, the non-string $field note, the two shared refusal sentences and the $between endpoint notes are scoped to the door each is true of. The "(or a binary value)" parenthetical in unbindableListMemberMessage's text is flagged in its docblock, not changed (no code change in this round). Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../service-analytics/src/comparand-shape.ts | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/services/service-analytics/src/comparand-shape.ts b/packages/services/service-analytics/src/comparand-shape.ts index 61ac1e790d4..ecf8d757b5c 100644 --- a/packages/services/service-analytics/src/comparand-shape.ts +++ b/packages/services/service-analytics/src/comparand-shape.ts @@ -73,11 +73,21 @@ import { * predicates against the driver's post-#7872 expressions over a shared value * table, and by `__tests__/comparand-door-single-source.test.ts`, which pins the * end-to-end accept/refuse matrix this reconciliation had to leave untouched. + * (Cells of that matrix have moved since, each named in the test with the + * change that moved it — #20010 and #20035 at the `where` door, #19975 and + * #20018 at the read scope.) * * ⛔ The ENVELOPES and the position logic below are this package's own and were * deliberately NOT moved: a caller-authored `where` refuses with a 400 * `INVALID_FILTER`, a read scope fails closed with a 500 (ADR-0021 D-C), and * only the type membership and the shared sentence come from the door. + * [#20035] That last clause is now true of the predicates below, not of the + * `where` door as a whole: the door runs the shared comparand-TYPE face itself + * (`normalizeFilterComparandTypes`, through `filter-normalizer.ts`'s + * `normalizeWhereComparands`) before any predicate here is asked, so a TYPE + * defect there is refused in the face's own sentence and path, in the same + * `INVALID_FILTER` / 400 envelope. The read scope runs the same face after its + * own gates (#20018), so there the predicates below still answer first. * * ## ⚠️ [#7598] What the mirror does NOT cover: a position no gate ever reached * @@ -194,7 +204,15 @@ export function isBindableComparand(value: unknown): boolean { * | door | what refuses an `undefined` comparand first | envelope | * |---|---|---| * | read scope | `read-scope-sql.ts`, per #6050 ruling B pushed down by #6125 | `READ_SCOPE_COMPILE_FAILED` / 500 | - * | analytics `where` | `assertDefinedComparands` (#6386, same ruling) | `INVALID_FILTER` / 400 | + * | analytics `where` | the shared comparand-TYPE face (`normalizeFilterComparandTypes`, #7872), run by `filter-normalizer.ts`'s `normalizeWhereComparands` before any node is built (#20035); `assertDefinedComparands` (#6386, same ruling) answers only the positions that face does not judge | `INVALID_FILTER` / 400 | + * + * [#20035] The `where` row used to name `assertDefinedComparands` alone. The + * type face now answers first, in its own sentence and at its own path, with + * the same verdict and envelope. #6386's gate stays as `fieldLeaves`' + * invariant and still answers the two positions the face steps around: an + * `undefined` inside an ARRAY comparand (`{d: {$contains: ['a', undefined]}}`) + * and the comparand of an operator outside the vocabulary (`{d: {$wat: + * undefined}}`). Either way, nothing reaches this predicate holding one. * * So `comparand()`'s normalise-to-`null` is itself a deliberately-kept dead arm * (its own TSDoc says so, and says reopening it is #5526's call, not a @@ -230,9 +248,14 @@ export function isRenderableTextComparand(value: unknown): boolean { * close (#5222 measured the same cell driver-side and moved its own test). * - **A non-string `$field` is NOT one.** `{ $field: 5 }` falls through to the * ordinary object-comparand account — `driver-sql` binds it as JSON there - * and so does this package (#5234 left `{$eq: {…}}` alone on purpose). That - * cell is untouched here; changing it would be a different ruling, not a - * rider on this one. + * (for a direct driver caller), and this package used to as well (#5234 + * left `{$eq: {…}}` alone on purpose). That cell was untouched here; + * changing it was a different ruling, not a rider on this one. That ruling + * is #7872's accepted comparand set, and both of this package's doors now + * apply it: the `where` door refuses the object as a plain object + * (`INVALID_FILTER` / 400, #20035) and the read-scope lowering refuses it + * after its own gates (`READ_SCOPE_COMPILE_FAILED` / 500, #20018). Neither + * reads it as a reference. * * ⚠️ `@objectstack/formula` is the WIDER of the two (`'$field' in raw`, any * value type). The driver's spelling is mirrored because this file's contract is @@ -290,7 +313,9 @@ export const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet = new Set([ * sides of the routing decision — the LIKE family and `$in` / `$nin` members * through this file's two predicates here and through `driver-sql`'s own #5222 * refusal arm, a `$between` endpoint through - * `filter-normalizer.ts`'s surviving gate, a bare `{ field: { $field: … } }` as + * `filter-normalizer.ts`'s surviving gate (from the `where` door, the shared + * comparand-shape face refuses that endpoint first since #20010, and the gate + * is `fieldLeaves`' invariant), a bare `{ field: { $field: … } }` as * an unsupported operator. Declining for those would swap one refusal for * another refusal a package further away, trading this package's precise * wording for the driver's without changing a single outcome. The scalar @@ -502,6 +527,13 @@ export function shapePreview(value: unknown): string { * `INVALID_FILTER` for a caller-authored filter, a fail-closed compile refusal * for a read scope — because the envelope is what differs between them, not the * diagnosis. + * + * [#20035] On the `where` door this sentence now answers only what the shared + * comparand-TYPE face steps around — an ARRAY comparand and a `{ $field }` + * reference. A plain object, a `Map`, a binary or a class instance is refused + * there first by that face, in its own sentence. The read-scope lowering still + * says this sentence for every object, because its own gates run before the + * face (#20018). */ export function unrenderableTextComparandMessage(op: string, field: string, value: unknown): string { return ( @@ -597,6 +629,12 @@ export function fieldReferenceComparandMessage( * #5222 COMPILES — succeeding on the analytics face alone, in defiance of both * the driver corpus and the schema. See `filter-normalizer.ts`'s * `assertNoFieldReferenceComparand`. + * + * [#20010] From the `where` door this sentence is no longer reached: the door + * hands every field entry to the shared comparand-shape face before any leaf + * is built, and the face refuses a `{ $field }` endpoint first, in its own + * words (the 2026-08-11 ruling, #7596). `assertNoFieldReferenceComparand` + * stays as `fieldLeaves`' invariant, for the laundering reason above. */ export function fieldReferenceBetweenBoundMessage( op: string, @@ -624,7 +662,9 @@ export function fieldReferenceBetweenBoundMessage( /** * The sentence both doors say about a list member that cannot be bound. See * {@link unrenderableTextComparandMessage} for why the message is shared and the - * envelope is not. + * envelope is not. [#20035] As there, on the `where` door it now answers only + * an ARRAY member and a `{ $field }` reference; a plain-object, `Map`, binary or + * class-instance member is refused first by the shared comparand-TYPE face. * * [#8186] The accepted-set clause is {@link ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}, * with binary kept as this package's own parenthetical extra — the exact shape @@ -635,6 +675,13 @@ export function fieldReferenceBetweenBoundMessage( * have always accepted and both doors have always compiled. Quoting the door * fixes the omission as a side effect of removing the copy — the accepted set * itself does not move (`__tests__/comparand-door-single-source.test.ts`). + * + * ⚠️ [#20035] The binary parenthetical in the text below — "(or a binary + * value)" — no longer describes either door: the read-scope lowering refuses a + * binary member with the shared comparand-type face (#20018), and so does the + * `where` door (#20035). It is left unchanged here because this change is + * docblock-only; the string and the test that pins it + * (`__tests__/comparand-door-single-source.test.ts`) are for a follow-up. */ export function unbindableListMemberMessage( op: string, From cf1563d6f80d26b97a5bb63040ceed9984877e2a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 01:23:29 +0000 Subject: [PATCH 8/8] fix(service-analytics): the unbindable list-member refusal no longer offers a binary value as a repair Neither analytics door accepts a binary comparand any more (#20018 at the read scope, #20035 at the where door), so unbindableListMemberMessage names the accepted set and nothing else. isBindableComparand's binary arm (the driver-sql mirror) and driver-sql's own copy of the sentence are unchanged. The #8186 pin is re-judged; the changeset gains one line. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .changeset/20035-analytics-where-type-face.md | 2 ++ .../comparand-door-single-source.test.ts | 15 +++++++++++-- .../service-analytics/src/comparand-shape.ts | 21 ++++++++++++------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.changeset/20035-analytics-where-type-face.md b/.changeset/20035-analytics-where-type-face.md index 78245f806d5..b9e36863523 100644 --- a/.changeset/20035-analytics-where-type-face.md +++ b/.changeset/20035-analytics-where-type-face.md @@ -30,4 +30,6 @@ Refusals this door already gave in its own words now read in the face's words, t Who is affected: nothing in this repository's examples, seeds, docs or package sources authors any of the refused comparands. A text scan over 4013 non-test files found none, and it did find the shape in a code comment written for this change. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. Of the refused values, only the plain object can be stored as JSON. +The refusal both analytics doors give for an unbindable `$in` / `$nin` / `$between` member no longer offers "(or a binary value)" as a repair, because neither door accepts a binary any more. It now names only the accepted set; its code, status and verdict are unchanged. + Not changed: every string, number, boolean, `null` and `Date` comparand; a `{ $field }` reference in an ordering slot (served on the engine path); nested relations and dotted members; `$ne` with a list, which the shared face does not judge yet. diff --git a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts index 14b09a4f9b5..e66aede4131 100644 --- a/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts +++ b/packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts @@ -310,8 +310,19 @@ describe('[#8186] the comparand matrix is unchanged by the door reconciliation', const buf = new Uint8Array([1, 2]); expect(isAcceptedFilterComparand(buf)).toBe(false); expect(isBindableComparand(buf)).toBe(true); - expect(unbindableListMemberMessage('$in', 'status', { foo: 1 }, 0)) - .toContain('(or a binary value)'); + // [#20035] RE-JUDGED — FLIPPED. This asserted the refusal sentence offered + // "(or a binary value)" as a repair, the parenthetical #8186 kept beside + // the door's sentence. Neither door accepts a binary any more: the + // read-scope lowering refuses it with the shared comparand-type face + // (#20018) and so does the `where` door (#20035, the #7872 ruling: + // 「refuses everything else loudly at the compile face」). A refusal that + // still prescribed it sent the author to a value the same door refuses, + // so the sentence now names the accepted set and nothing more. The + // predicate's binary arm above is unchanged — it is `driver-sql`'s + // mirror, and `driver-sql`, which does bind a binary, keeps its own copy. + const message = unbindableListMemberMessage('$in', 'status', { foo: 1 }, 0); + expect(message).not.toContain('binary'); + expect(message).toContain(`use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. Refusing rather than binding it`); }); }); diff --git a/packages/services/service-analytics/src/comparand-shape.ts b/packages/services/service-analytics/src/comparand-shape.ts index ecf8d757b5c..4cc83d7bf44 100644 --- a/packages/services/service-analytics/src/comparand-shape.ts +++ b/packages/services/service-analytics/src/comparand-shape.ts @@ -669,19 +669,24 @@ export function fieldReferenceBetweenBoundMessage( * [#8186] The accepted-set clause is {@link ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}, * with binary kept as this package's own parenthetical extra — the exact shape * `driver-sql`'s twin took when #7872 reconciled it, so the two faces describe - * one rule in one wording again. The hand-copy it replaces read "a string, + * one rule in one wording again. [#20035] The parenthetical is gone from this + * copy; see the note below. The hand-copy it replaces read "a string, * number, boolean, null, Date or binary value", which had silently gone WRONG * in the quieter direction: it omitted `bigint`, a type both predicates here * have always accepted and both doors have always compiled. Quoting the door * fixes the omission as a side effect of removing the copy — the accepted set * itself does not move (`__tests__/comparand-door-single-source.test.ts`). * - * ⚠️ [#20035] The binary parenthetical in the text below — "(or a binary - * value)" — no longer describes either door: the read-scope lowering refuses a - * binary member with the shared comparand-type face (#20018), and so does the - * `where` door (#20035). It is left unchanged here because this change is - * docblock-only; the string and the test that pins it - * (`__tests__/comparand-door-single-source.test.ts`) are for a follow-up. + * [#20035] FIXED: the text no longer offers "(or a binary value)" as a + * repair. It stopped describing either door when each began refusing a binary + * member with the shared comparand-type face — the read-scope lowering + * (#20018) and the `where` door (#20035) — so a refusal that still prescribed it + * sent the author to a value the same door refuses (#5240's wrong-repair + * class). The repair it names is now exactly the accepted set. What did NOT + * move: {@link isBindableComparand}'s binary arm, which stays as the + * value-for-value mirror of `driver-sql`, and `driver-sql`'s own copy of this + * sentence, which keeps the parenthetical because that driver does bind a + * binary. `__tests__/comparand-door-single-source.test.ts` pins both halves. */ export function unbindableListMemberMessage( op: string, @@ -692,7 +697,7 @@ export function unbindableListMemberMessage( return ( `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL ` + `parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand ` + - `in its own right — use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). ` + + `in its own right — use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. ` + `Refusing rather than binding it: the member can equal no stored value, so the list silently ` + `loses that entry (and a $nin loses the exclusion the caller wrote).` );