From d4d5fe07840969105fe3da23a788a94546c05efe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:30:57 +0000 Subject: [PATCH 1/6] fix(driver-memory,driver-mongodb): refuse a non-boolean $exists comparand as $null's is refused Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .../driver-memory/src/filter-refusal.ts | 44 +++++ .../memory-exists-non-boolean-refusal.test.ts | 183 ++++++++++++++++++ .../src/memory-null-comparand-refusal.test.ts | 43 ++-- .../driver-mongodb/src/mongodb-filter.ts | 46 +++++ 4 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts diff --git a/packages/drivers/driver-memory/src/filter-refusal.ts b/packages/drivers/driver-memory/src/filter-refusal.ts index 191df14a7e0..c1be71dc879 100644 --- a/packages/drivers/driver-memory/src/filter-refusal.ts +++ b/packages/drivers/driver-memory/src/filter-refusal.ts @@ -618,6 +618,42 @@ export function nonBooleanNullComparandError(field: string, value: unknown, path ); } +/** + * [#20897, applying #5347's ruling A as #5369 did] `$exists` whose comparand + * is not a boolean. + * + * The symmetric twin of {@link nonBooleanNullComparandError}, and a copy of its + * disposition rather than a fresh judgement: `FieldOperatorsSchema` declares + * `$exists: z.boolean()` exactly as it declares `$null`, and the 2026-08-06 + * ruling on #5298 applied #5347-A to `$exists` by name — `driver-sql` has + * refused a non-boolean here since. This driver did not, and its answer was the + * sharpest of the splits: the live path lowered `val === true` to has-a-value + * and EVERYTHING else to no-value, so `{ stage: { $exists: 'yes' } }` returned + * the rows with NO value — the author's intent inverted — while this package's + * own cube face read the flag by truthiness (`Boolean(raw[0])`) and answered + * the valued rows for the same filter. One filter, one package, two answers. + * Measured on `origin/main` `f6ccca4a` through `engine.find`, `count`, + * `aggregate`, `updateMany`, `deleteMany` and the analytics face, before this + * refusal. + * + * The words are `driver-sql`'s `nonBooleanExistsComparandError`, verbatim — + * one condition, one wording (#5240) — with its "this driver" clause re-aimed + * at the backend it names, the way the `$null` twin above names `driver-sql`. + */ +export function nonBooleanExistsComparandError(field: string, value: unknown, path: string): Error { + return unsupportedFilterError( + `Operator "$exists" on field "${field}" requires a boolean comparand (true or false). ` + + `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + + `@objectstack/spec FieldOperatorsSchema declares $exists as a boolean. It is refused rather ` + + `than coerced for the same reason $null is: a non-boolean lands on whichever side ` + + `the backend's two-branch conditional happens to default to, and those defaults point in ` + + `OPPOSITE directions — driver-sql's \`=== false\` test compiles IS NOT NULL for anything ` + + `but false, this driver's \`=== true\` test compiled IS NULL for anything but true. Note ` + + `"false" the STRING is truthy, so it lands on the side opposite the false it was written ` + + `to mean.`, + ); +} + /** * [#20444] A non-boolean `$empty` comparand. The leading sentence is * `driver-sql`'s `nonBooleanEmptyComparandError`, verbatim — one condition, @@ -939,6 +975,14 @@ function assertFieldConstraintShape( if (op === '$null' && typeof spec[op] !== 'boolean') { throw nonBooleanNullComparandError(field, spec[op], `${path}.$null`); } + // [#20897] `$exists`' comparand is a boolean by the same declaration, and + // the ruling that refused `$null`'s third value refused this one too. On + // this walk for the reason `$null` is: the live path's `=== true` arm and + // the cube face's truthiness read put a third value on OPPOSITE sides, so + // the refusal has to fire before either face lowers anything. + if (op === '$exists' && typeof spec[op] !== 'boolean') { + throw nonBooleanExistsComparandError(field, spec[op], `${path}.$exists`); + } // [#20444] `$empty`'s comparand is a boolean by the same declaration // (`FieldOperatorsSchema`), refused on this walk for the same reason: both // faces of this package evaluate `true` / `false` exhaustively, so a third diff --git a/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts b/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts new file mode 100644 index 00000000000..f4c9cabe738 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20897] `$exists` takes a boolean. A non-boolean is refused on EVERY entry + * of this package, in `driver-sql`'s words — the `$null` twin's disposition + * (#5347-A), applied to `$exists` by the ruling on #5298 (#5369). + * + * # What was measured + * + * `FieldOperatorsSchema` declares `$exists: z.boolean()`, and nothing between an + * authored `where` and this driver validated it: `driver-sql`, `driver-sqlite-wasm` + * and both Turso transports refused a non-boolean, and this package did not. + * Measured on `origin/main` `f6ccca4a`, one row with `stage: 'won'` (id 1) and + * one with `stage: null` (id 2): + * + * | entry | `'yes'` | `1` | `'false'` | `0` / `null` | + * |---|---|---|---|---| + * | `find` / `findOne` / `count` / `aggregate` / `updateMany` / `deleteMany` | id 2 | id 2 | id 2 | id 2 | + * | the analytics (cube) face, `query()` | id 1 | id 1 | id 1 | id 2 | + * + * Two answers inside one package, and neither is a refusal. The query path's + * `val === true` test sent every third value to the NO-value side — `'yes'` + * asked for the rows without one, the author's intent inverted. The cube face's + * `set` arm read the same flag by truthiness, so it answered the opposite rows + * for `'yes'` and `1` — and for the string `'false'`, which is truthy. + * + * # Why every entry is asserted, and against `find()` + * + * The refusal lives in ONE function (`assertFilterConditionShape`), which every + * entry runs before it lowers anything. What would let a future change re-fork + * the answers is an entry reaching its lowering WITHOUT that walk — the cube + * face's truthiness arm is still there behind it. So each entry is asserted to + * refuse, and, for the two booleans the spec declares, to answer exactly the + * rows `find()` answers: the same row set as `find()`, or refused with + * `INVALID_FILTER` — never a third, quieter answer. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { FilterCondition } from '@objectstack/spec/data'; + +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const ROWS = [ + { id: '1', stage: 'won', score: 10 }, + // The null-valued row that separates "has a value" from "has none". + { id: '2', stage: null, score: 20 }, +]; + +/** + * The exact leading sentence `driver-sql` produces for this condition, copied + * from `sql-driver.ts`'s `nonBooleanExistsComparandError`. A literal rather + * than an import: this package does not depend on driver-sql (and must not), + * so one condition, one wording (#5240) is held by pinning the other side's + * text here. + */ +const DRIVER_SQL_LEADING_SENTENCE = (field: string) => + `Operator "$exists" on field "${field}" requires a boolean comparand (true or false).`; + +/** + * The triage pins (`'yes'`, `1`, the string `'false'`), then the rest of the + * battery `driver-sql`'s own `$exists` pins run — so the two backends are held + * to the same inputs. + */ +const NON_BOOLEAN: Array<[label: string, value: unknown]> = [ + ["the string 'yes'", 'yes'], + ['the number 1', 1], + ["the STRING 'false'", 'false'], + ['the number 0', 0], + ['null', null], + ['undefined', undefined], + ['an object', {}], +]; + +const CUBE = { + name: 'deals', + title: 'Deals', + sql: 'deal', + measures: { total: { label: 'Total', type: 'count', sql: 'id' } }, + dimensions: { + id: { label: 'Id', type: 'string', sql: 'id' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, + }, + public: true, +} as never; + +describe('[#20897] a non-boolean $exists is refused on every entry of driver-memory', () => { + let driver: InMemoryDriver; + let analytics: MemoryAnalyticsService; + + beforeEach(async () => { + driver = new InMemoryDriver({ persistence: false }); + await driver.syncSchema('deal', { + fields: { + id: { type: 'text', name: 'id' }, + stage: { type: 'text', name: 'stage' }, + score: { type: 'number', name: 'score' }, + }, + } as any); + for (const row of ROWS) await driver.create('deal', { ...row }); + analytics = new MemoryAnalyticsService({ driver, cubes: [CUBE] } as never); + }); + + const sorted = (rows: unknown): string[] => (rows as Array>).map((r) => String(r.id)).sort(); + const q = (where: unknown) => ({ where: where as FilterCondition }); + const cubeQuery = (where: unknown) => + ({ cube: 'deals', measures: ['total'], dimensions: ['id'], where }) as never; + + /** Every entry of this package that takes a `where`, by name. */ + const ENTRIES: Array<[name: string, run: (where: unknown) => Promise, path: string]> = [ + ['find', (w) => driver.find('deal', q(w)), 'filter'], + ['findOne', (w) => driver.findOne('deal', q(w)), 'filter'], + ['count', (w) => driver.count('deal', q(w)), 'filter'], + ['aggregate', (w) => driver.aggregate('deal', { ...q(w), aggregations: [{ function: 'count', alias: 'n' }] } as never), 'filter'], + ['updateMany', (w) => driver.updateMany('deal', q(w), { score: 99 }), 'filter'], + ['deleteMany', (w) => driver.deleteMany('deal', q(w)), 'filter'], + ['the analytics face, query()', (w) => analytics.query(cubeQuery(w)), 'where'], + ['the analytics face, generateSql()', (w) => analytics.generateSql(cubeQuery(w)), 'where'], + ]; + + const refusalOf = async (run: () => Promise): Promise => { + try { + await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected this entry to refuse the filter, but it resolved'); + }; + + for (const [entry, run, root] of ENTRIES) { + for (const [label, value] of NON_BOOLEAN) { + it(`${entry} refuses ${label} with INVALID_FILTER / 400, in driver-sql's words`, async () => { + const err = await refusalOf(() => run({ stage: { $exists: value } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain(DRIVER_SQL_LEADING_SENTENCE('stage')); + expect(err.message).toContain(`${root}.stage.$exists`); + }); + } + } + + it('refuses it at every depth, and a satisfiable sibling does not let it through', async () => { + // The gate is a WALK, not an evaluation: `{ stage: 'won' }` matches and + // `{}` is the TRUE identity, yet neither short-circuits the refusal. + for (const [where, at] of [ + [{ $and: [{ stage: { $exists: 'yes' } }] }, 'filter.$and[0].stage.$exists'], + [{ $or: [{ stage: 'won' }, { stage: { $exists: 1 } }] }, 'filter.$or[1].stage.$exists'], + [{ $or: [{}, { stage: { $exists: 'false' } }] }, 'filter.$or[1].stage.$exists'], + [{ $not: { stage: { $exists: 'yes' } } }, 'filter.$not.stage.$exists'], + ] as Array<[unknown, string]>) { + const err = await refusalOf(() => driver.find('deal', q(where))); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain(at); + } + }); + + it('a refused write leaves the store untouched', async () => { + await refusalOf(() => driver.updateMany('deal', q({ stage: { $exists: 'yes' } }), { score: 99 })); + await refusalOf(() => driver.deleteMany('deal', q({ stage: { $exists: 1 } }))); + const rows = (await driver.find('deal', {} as never)) as Array>; + expect(rows.map((r) => [String(r.id), r.score]).sort()).toEqual([['1', 10], ['2', 20]]); + }); + + describe('the control: true and false answer find()\'s rows on every read entry', () => { + for (const [flag, expected] of [[true, ['1']], [false, ['2']]] as Array<[boolean, string[]]>) { + it(`$exists: ${flag} selects ${JSON.stringify(expected)}, and every read entry agrees with find()`, async () => { + const where = { stage: { $exists: flag } }; + const found = sorted(await driver.find('deal', q(where))); + expect(found).toEqual(expected); + expect(await driver.count('deal', q(where))).toBe(expected.length); + expect(String((await driver.findOne('deal', q(where)) as Record).id)).toBe(expected[0]); + const cube = (await analytics.query(cubeQuery(where))).rows as Array>; + expect(cube.map((r) => String(r.id)).sort()).toEqual(found); + }); + } + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts b/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts index 5dbade8a16a..e6dd7ab47ee 100644 --- a/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts +++ b/packages/drivers/driver-memory/src/memory-null-comparand-refusal.test.ts @@ -37,7 +37,10 @@ * face asserted here is the gate itself, called directly — the live path and * the gate must refuse identically. Its row answers were the live path's too, * with ONE exception, pinned at the foot of this file: `$exists` over a - * non-boolean flag, where the matcher and the live path disagreed. + * non-boolean flag, where the matcher and the live path disagreed. [#20897] + * That cell is a refusal now, under the ruling `$null`'s refusal took; the + * full `$exists` battery, across every entry of this package, is + * `memory-exists-non-boolean-refusal.test.ts`. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -178,20 +181,32 @@ describe('[#5347] $null requires a boolean comparand, on the live path and its s expect(await findIds({})).toEqual(['1', '2']); }); - it('$exists is deliberately NOT tightened here — and the live path reads a non-boolean flag as FALSE', async () => { - // #5347 ruled on `$null` alone. `$exists` diverges on its own axis (#5299 - // holds the open question of what "exists" means for a null-valued key), so - // it keeps today's answers rather than being settled as a rider. + it('$exists is refused too — the carve-out this line used to pin is gone (#20897)', async () => { + // FLIPPED CARVE-OUT. This case was titled "$exists is deliberately NOT + // tightened here — and the live path reads a non-boolean flag as FALSE", + // and pinned `['2']` — the row with NO value — for `$exists: 'yes'`. #5347 + // ruled on `$null` alone, and what "exists" means for a null-valued key was + // still #5299's open question, so tightening it as a rider would have been + // settling a second ruling silently. // - // [#5930 step 4] This line used to pin the REFERENCE MATCHER's answer, - // `['1']`: it read the flag by truthiness (`!!'yes'`), so `'yes'` meant - // "has a value". The live path never agreed — it lowers `val === true` to - // `$ne: null` and anything else to `$eq: null`, so `'yes'` asks for the - // rows with NO value. That was the matcher's own divergence from the path - // users run; with the matcher retired, the live path's answer is pinned, - // measured, and the refusal this cell lacks is reported rather than - // decided here. - expect(await findIds({ stage: { $exists: 'yes' } })).toEqual(['2']); + // That question was closed ("has a value", ruled on #5298) and the same + // ruling applied #5347-A to `$exists` by name, which `driver-sql` enforced + // at once; this driver kept the pinned answer — the author's intent + // inverted, since `'yes'` fell to the `$eq: null` side of the live path's + // `val === true` test. So the pin becomes the refusal, through the live path + // and the gate alike, in `driver-sql`'s leading sentence. + for (const value of ['yes', 1, 'false']) { + const findErr = await refusalOfFind({ stage: { $exists: value } }); + expect(findErr.code).toBe('INVALID_FILTER'); + expect(findErr.status).toBe(400); + expect(findErr.message).toContain( + 'Operator "$exists" on field "stage" requires a boolean comparand (true or false).', + ); + expect(findErr.message).toContain('filter.stage.$exists'); + expect(refusalOfGate({ stage: { $exists: value } }).message).toBe(findErr.message); + } + // The control: the two booleans the spec declares answer exactly as before. expect(await findIds({ stage: { $exists: true } })).toEqual(['1']); + expect(await findIds({ stage: { $exists: false } })).toEqual(['2']); }); }); diff --git a/packages/drivers/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts index e4cfc26bc41..277bbb4a8ab 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-filter.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-filter.ts @@ -306,6 +306,20 @@ function classifyFilterKey(key: string, value: unknown, here: string): FilterVer throw nonBooleanNullComparandError(key, value.$null, `${here}.$null`); } + // [#20897] `$exists`' comparand is a boolean by the same declaration, and the + // ruling that refused `$null`'s third value (#5347-A) was applied to this one + // by name (#5369, ruled on #5298). Gated on this walk for `$null`'s + // evaluation-order reason, one paragraph up. Before it, the emitter's + // `value === true` arm lowered every non-boolean to `$eq: null`, so + // `{ stage: { $exists: 'yes' } }` asked MongoDB for the rows with NO value. + if ( + isFilterNode(value) && + Object.prototype.hasOwnProperty.call(value, '$exists') && + typeof value.$exists !== 'boolean' + ) { + throw nonBooleanExistsComparandError(key, value.$exists, `${here}.$exists`); + } + // [#20444] `$empty`'s comparand is a boolean by the same declaration, gated on // this walk for the same evaluation-order reason as `$null` above. if ( @@ -538,6 +552,33 @@ function nonBooleanNullComparandError(field: string, value: unknown, path: strin ); } +/** + * [#20897] `$exists` whose comparand is not a boolean — the twin of + * {@link nonBooleanNullComparandError}, under the same ruling (#5347-A, + * applied to `$exists` by #5369). + * + * The words are `driver-sql`'s `nonBooleanExistsComparandError`, verbatim — + * one condition, one wording (#5240) — with its "this driver" clause re-aimed + * at the backend it names, as the `$null` twin names `driver-sql`. Measured on + * `translateFilter` at `origin/main` `f6ccca4a` before the refusal: `'yes'`, + * `1`, `0`, `null` and the string `'false'` each translated to + * `{ stage: { $eq: null } }` — the no-value rows — because the emitter asked + * `value === true` and sent everything else to the other side. + */ +function nonBooleanExistsComparandError(field: string, value: unknown, path: string): Error { + return unsupportedFilterError( + `Operator "$exists" on field "${field}" requires a boolean comparand (true or false). ` + + `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + + `@objectstack/spec FieldOperatorsSchema declares $exists as a boolean. It is refused rather ` + + `than coerced for the same reason $null is: a non-boolean lands on whichever side ` + + `the backend's two-branch conditional happens to default to, and those defaults point in ` + + `OPPOSITE directions — driver-sql's \`=== false\` test compiles IS NOT NULL for anything ` + + `but false, this driver's \`=== true\` test compiled IS NULL for anything but true. Note ` + + `"false" the STRING is truthy, so it lands on the side opposite the false it was written ` + + `to mean.`, + ); +} + /** * [#20444] `$empty` whose comparand is not a boolean. The leading sentence is * `driver-sql`'s `nonBooleanEmptyComparandError`, verbatim — one condition, @@ -1143,6 +1184,11 @@ function translateFieldOperators( // already agreed, is unmoved. Measured on a real mongod 8.2.6 while this // cell was pinned. case '$exists': + // [#20897] The load-bearing copy of this gate is on the walk + // (`reduceFilterKey`), for the reason the `$null` arm below gives; this + // one keeps the arm's two-way choice total for its own invariant, with + // the same constructor and the same path spelling. + if (typeof value !== 'boolean') throw nonBooleanExistsComparandError(field, value, `${path}.$exists`); // Collected, not assigned: the assembly has to know whether the key // this lowers to is already spoken for. [#13524] Since the class was // generalised this arm is no longer special — it `put`s like every From 6fc07bb0b9e4fcb87a4a637c37307eefe74a4207 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:32:25 +0000 Subject: [PATCH 2/6] test(driver-mongodb): pin the non-boolean $exists refusal on the translator walk Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .../memory-exists-non-boolean-refusal.test.ts | 43 +++++-- ...mongodb-exists-non-boolean-refusal.test.ts | 111 ++++++++++++++++++ 2 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 packages/drivers/driver-mongodb/src/mongodb-exists-non-boolean-refusal.test.ts diff --git a/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts b/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts index f4c9cabe738..48c369c3c09 100644 --- a/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts +++ b/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts @@ -111,18 +111,30 @@ describe('[#20897] a non-boolean $exists is refused on every entry of driver-mem const cubeQuery = (where: unknown) => ({ cube: 'deals', measures: ['total'], dimensions: ['id'], where }) as never; - /** Every entry of this package that takes a `where`, by name. */ - const ENTRIES: Array<[name: string, run: (where: unknown) => Promise, path: string]> = [ - ['find', (w) => driver.find('deal', q(w)), 'filter'], - ['findOne', (w) => driver.findOne('deal', q(w)), 'filter'], - ['count', (w) => driver.count('deal', q(w)), 'filter'], - ['aggregate', (w) => driver.aggregate('deal', { ...q(w), aggregations: [{ function: 'count', alias: 'n' }] } as never), 'filter'], - ['updateMany', (w) => driver.updateMany('deal', q(w), { score: 99 }), 'filter'], - ['deleteMany', (w) => driver.deleteMany('deal', q(w)), 'filter'], - ['the analytics face, query()', (w) => analytics.query(cubeQuery(w)), 'where'], - ['the analytics face, generateSql()', (w) => analytics.generateSql(cubeQuery(w)), 'where'], + /** + * Every entry of this package that takes a `where`, by name, with the root its + * refusal names and whether the shared comparand-TYPE face runs ahead of the + * gate there. The analytics face runs it first (its door, ADR-0053 D-D1), so + * a flag that face refuses on TYPE — `undefined`, a plain object — keeps that + * face's own sentence, the precedence the analytics `where` door and the + * engine seam give it too; the envelope and the position are the same. + */ + const ENTRIES: Array<[name: string, run: (where: unknown) => Promise, path: string, typeFaceFirst: boolean]> = [ + ['find', (w) => driver.find('deal', q(w)), 'filter', false], + ['findOne', (w) => driver.findOne('deal', q(w)), 'filter', false], + ['count', (w) => driver.count('deal', q(w)), 'filter', false], + ['aggregate', (w) => driver.aggregate('deal', { ...q(w), aggregations: [{ function: 'count', alias: 'n' }] } as never), 'filter', false], + ['updateMany', (w) => driver.updateMany('deal', q(w), { score: 99 }), 'filter', false], + ['deleteMany', (w) => driver.deleteMany('deal', q(w)), 'filter', false], + ['the analytics face, query()', (w) => analytics.query(cubeQuery(w)), 'where', true], + ['the analytics face, generateSql()', (w) => analytics.generateSql(cubeQuery(w)), 'where', true], ]; + /** The comparands the comparand-TYPE face refuses before any flag rule is asked. */ + const TYPE_FACE_REFUSED: ReadonlySet = new Set([undefined]); + const isTypeFaceRefused = (value: unknown): boolean => + TYPE_FACE_REFUSED.has(value) || (typeof value === 'object' && value !== null); + const refusalOf = async (run: () => Promise): Promise => { try { await run(); @@ -132,14 +144,19 @@ describe('[#20897] a non-boolean $exists is refused on every entry of driver-mem throw new Error('expected this entry to refuse the filter, but it resolved'); }; - for (const [entry, run, root] of ENTRIES) { + for (const [entry, run, root, typeFaceFirst] of ENTRIES) { for (const [label, value] of NON_BOOLEAN) { - it(`${entry} refuses ${label} with INVALID_FILTER / 400, in driver-sql's words`, async () => { + const words = typeFaceFirst && isTypeFaceRefused(value) ? 'the type face\'s words' : 'driver-sql\'s words'; + it(`${entry} refuses ${label} with INVALID_FILTER / 400, in ${words}`, async () => { const err = await refusalOf(() => run({ stage: { $exists: value } })); expect(err.code).toBe('INVALID_FILTER'); expect(err.status).toBe(400); - expect(err.message).toContain(DRIVER_SQL_LEADING_SENTENCE('stage')); expect(err.message).toContain(`${root}.stage.$exists`); + // The type face's sentence is its own contract, pinned in its own + // suite; here only the flag rule's first sentence is load-bearing. + if (!(typeFaceFirst && isTypeFaceRefused(value))) { + expect(err.message).toContain(DRIVER_SQL_LEADING_SENTENCE('stage')); + } }); } } diff --git a/packages/drivers/driver-mongodb/src/mongodb-exists-non-boolean-refusal.test.ts b/packages/drivers/driver-mongodb/src/mongodb-exists-non-boolean-refusal.test.ts new file mode 100644 index 00000000000..f7f4426e404 --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-exists-non-boolean-refusal.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20897] `$exists` takes a boolean — this driver refuses a non-boolean the + * way it refuses `$null`'s (#5347-A, applied to `$exists` by #5369). + * + * Measured on `translateFilter` at `origin/main` `f6ccca4a`, before the refusal: + * + * ``` + * { stage: { $exists: 'yes' } } => {"stage":{"$eq":null}} + * { stage: { $exists: 1 } } => {"stage":{"$eq":null}} + * { stage: { $exists: 'false' } } => {"stage":{"$eq":null}} + * { stage: { $exists: 0 } } => {"stage":{"$eq":null}} + * { stage: { $exists: null } } => {"stage":{"$eq":null}} + * ``` + * + * The emitter asked `value === true` and sent every other value to the + * NO-value side, so `'yes'` asked MongoDB for the rows without one — the + * author's intent inverted — while `driver-sql`, `driver-sqlite-wasm` and both + * Turso transports refused the same filter. + * + * Asserted on the translator for the reason the `$null` twin + * (`mongodb-null-comparand-refusal.test.ts`) gives: it is a pure function, it is + * this package's only reader of `$exists`, and its output IS the query MongoDB + * receives — a suite that needs a downloaded `mongod` can be skipped, and a test + * of the ruling that can be skipped is not a test of the ruling. + */ + +import { describe, it, expect } from 'vitest'; +import { translateFilter } from './mongodb-filter.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +/** + * The exact leading sentence `driver-sql` produces for this condition + * (`nonBooleanExistsComparandError`). A literal, not an import: this package + * does not depend on driver-sql (and must not), so one condition, one wording + * (#5240) is held by pinning the other side's text here. + */ +const DRIVER_SQL_LEADING_SENTENCE = (field: string) => + `Operator "$exists" on field "${field}" requires a boolean comparand (true or false).`; + +const refusalOf = (where: unknown): WireBearingError => { + try { + translateFilter(where); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the translator to refuse this filter, but it translated'); +}; + +describe('[#20897] driver-mongodb refuses a non-boolean $exists comparand', () => { + const NON_BOOLEAN: Array<[label: string, value: unknown]> = [ + ["the string 'yes'", 'yes'], + ['the number 1', 1], + ["the STRING 'false'", 'false'], + ['the number 0', 0], + ['null', null], + ['undefined', undefined], + ['an object', {}], + ]; + + for (const [label, value] of NON_BOOLEAN) { + it(`refuses ${label} with INVALID_FILTER / 400`, () => { + const err = refusalOf({ stage: { $exists: value } }); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain(DRIVER_SQL_LEADING_SENTENCE('stage')); + expect(err.message).toContain('filter.stage.$exists'); + }); + } + + it('names the position inside a combinator', () => { + expect(refusalOf({ $and: [{ stage: { $exists: 'x' } }] }).message).toContain('filter.$and[0].stage.$exists'); + expect(refusalOf({ $or: [{ score: 1 }, { stage: { $exists: 1 } }] }).message).toContain( + 'filter.$or[1].stage.$exists', + ); + expect(refusalOf({ $not: { stage: { $exists: 'yes' } } }).message).toContain('filter.$not.stage.$exists'); + }); + + /** + * The gate is on the WALK (`reduceFilterKey`), not only in the emitter: a + * boolean identity settles these nodes before any emitter arm runs, so an + * emitter-only gate would refuse or ignore the same comparand depending on + * its SIBLINGS. Each fixture would short-circuit to match-all / match-nothing + * if the walk did not refuse first. + */ + it('is not skipped when a boolean identity settles the enclosing node', () => { + for (const [where, at] of [ + [{ $or: [{}, { stage: { $exists: 'yes' } }] }, 'filter.$or[1].stage.$exists'], + [{ $or: [], stage: { $exists: 1 } }, 'filter.stage.$exists'], + [{ $not: { $or: [{}, { stage: { $exists: 'false' } }] } }, 'filter.$not.$or[1].stage.$exists'], + ] as Array<[unknown, string]>) { + const err = refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain(at); + } + }); + + it('true and false translate exactly as before — the has-value lowering', () => { + expect(translateFilter({ stage: { $exists: true } })).toEqual({ stage: { $ne: null } }); + expect(translateFilter({ stage: { $exists: false } })).toEqual({ stage: { $eq: null } }); + // The complement `$null` answers the mirror image (#5298's reading). + expect(translateFilter({ stage: { $exists: true } })).toEqual(translateFilter({ stage: { $null: false } })); + expect(translateFilter({ stage: { $exists: false } })).toEqual(translateFilter({ stage: { $null: true } })); + }); +}); From da4feaf848953f657ad6971f4b59884b64c3c7dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:33:11 +0000 Subject: [PATCH 3/6] chore(changeset): patch driver-memory and driver-mongodb for the non-boolean $exists refusal Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .changeset/20897-exists-non-boolean-refused.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/20897-exists-non-boolean-refused.md diff --git a/.changeset/20897-exists-non-boolean-refused.md b/.changeset/20897-exists-non-boolean-refused.md new file mode 100644 index 00000000000..b64e4c7ee52 --- /dev/null +++ b/.changeset/20897-exists-non-boolean-refused.md @@ -0,0 +1,16 @@ +--- +'@objectstack/driver-memory': patch +'@objectstack/driver-mongodb': patch +--- + +fix(driver-memory, driver-mongodb): a non-boolean `$exists` comparand is refused with `INVALID_FILTER` / 400, as `$null`'s is, instead of selecting the rows with no value (#20897) + +Clause-②: no + +`FieldOperatorsSchema` declares `$exists` as a boolean, and `driver-sql`, `driver-sqlite-wasm` and both Turso transports already refused any other comparand. The in-memory driver and the MongoDB driver did not: they read `$exists` as `value === true`, so every other value asked for the rows with NO value. `{ stage: { $exists: "yes" } }` and `{ stage: { $exists: 1 } }` returned the rows without a stage, the opposite of what was written. `0`, `null` and the string `"false"` landed on that same side by the same default, not because anything read them. + +Both drivers now refuse a non-boolean `$exists` (a string, a number, `null`, `undefined`, an object) with `INVALID_FILTER` / 400. The message is `driver-sql`'s, beginning `Operator "$exists" on field "FIELD" requires a boolean comparand (true or false).`, and names the position (`filter.stage.$exists`). On the in-memory driver the refusal covers `find`, `findOne`, `count`, `aggregate`, `updateMany`, `deleteMany` and the analytics face (`query()` and `generateSql()`). There, an `undefined` or object comparand is refused first by that face's comparand-type check, also `INVALID_FILTER` / 400, in its own words. A refused write changes nothing. The analytics face had read the flag by truthiness and answered the valued rows for the same filter, so the in-memory driver used to give two different answers. + +`$exists: true` and `$exists: false` are unchanged: `true` selects the rows that have a value, `false` the rows that have none. + +**If a query now fails:** write the boolean itself. `"$exists": true` matches rows whose field has a value, and `"$exists": false` matches rows whose field has none. From 9df5e06c7a3bc8ca219ffe6a4d0cbcc5679d31d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:05:22 +0000 Subject: [PATCH 4/6] test(client): classify driver-memory's cube-service analytics.query sites in the envelope caller census Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .../client/src/envelope-caller-census.test.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/client/src/envelope-caller-census.test.ts b/packages/client/src/envelope-caller-census.test.ts index 636228e498a..3fcee8856f0 100644 --- a/packages/client/src/envelope-caller-census.test.ts +++ b/packages/client/src/envelope-caller-census.test.ts @@ -70,7 +70,9 @@ * `AnalyticsService` in `analytics-automation-json-erasure.test.ts`, where * `analytics` is the SERVICE, not the client. That is a producer call and no * part of the SDK caller population, so every row carries its receiver and the - * ledger classifies it `NOT_SDK`. + * ledger classifies it `NOT_SDK`. [#20897] driver-memory's refusal suite does + * the same on its own cube service (`MemoryAnalyticsService` bound to + * `analytics`), so it carries a `NOT_SDK` row too. * * ## The verdicts * @@ -473,6 +475,12 @@ const LEDGER: readonly LedgerRow[] = [ method: 'analytics.query', receiver: 'service', count: 1, verdict: 'NOT_SDK', why: 'the real AnalyticsService, called to assert the SDK value equals what the producer returned', }, + // ── a producer face outside the SDK: driver-memory's cube service ──────── + { + file: 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', + method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK', + why: 'a MemoryAnalyticsService bound to `analytics`, called directly (no HTTP, no dispatcher envelope) to assert the cube face refuses a non-boolean $exists and answers find()\'s rows for true / false', + }, { file: 'packages/client/src/analytics-automation-json-erasure.test.ts', method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT', @@ -670,8 +678,17 @@ describe('#13079 §2 — positive controls on the matcher itself', () => { // method, so a literal-embedded site lands HERE first, as a phantom // producer call. That makes this the assertion most likely to break // for a reason that has nothing to do with receivers. - expect(service.length, literalNote()).toBe(1); - expect(service[0]?.file).toBe('packages/client/src/analytics-automation-json-erasure.test.ts'); + // [#20897] Two producer faces call `analytics.query` bare: the real + // AnalyticsService behind the SDK, and driver-memory's cube service + // (`MemoryAnalyticsService`) in its own refusal suite. Neither receiver + // is the client, and every site is pinned by file. + expect(service.length, literalNote()).toBe(3); + expect(service.map((s) => s.file)).toEqual([ + 'packages/client/src/analytics-automation-json-erasure.test.ts', + 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', + 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', + ]); + expect(service.every((s) => s.method === 'analytics.query')).toBe(true); }); }); @@ -716,10 +733,10 @@ describe('#13079 §3 — every call site is classified', () => { expect(production, literalNote()).toEqual([]); }); - it('records the split: 18 payload pins, 10 result-insensitive, 1 not-SDK', () => { + it('records the split: 18 payload pins, 10 result-insensitive, 3 not-SDK', () => { expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18); expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10); - expect(verdictTotal('NOT_SDK')).toBe(1); + expect(verdictTotal('NOT_SDK')).toBe(3); // The three above are LEDGER sums and cannot move on a census reading; // this one is census-derived, so it carries the note. [#13874] expect(sdkSites.length, literalNote()).toBe(28); From 3682d8f414d3fd102d3e8c3a4e50f16e546f2587 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:06:49 +0000 Subject: [PATCH 5/6] chore(changeset): declare the non-boolean $exists refusal as an accept-set narrowing (minor, breaking) Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .../20897-exists-non-boolean-refused.md | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.changeset/20897-exists-non-boolean-refused.md b/.changeset/20897-exists-non-boolean-refused.md index b64e4c7ee52..b590d2745bf 100644 --- a/.changeset/20897-exists-non-boolean-refused.md +++ b/.changeset/20897-exists-non-boolean-refused.md @@ -1,16 +1,22 @@ --- -'@objectstack/driver-memory': patch -'@objectstack/driver-mongodb': patch +'@objectstack/driver-memory': minor +'@objectstack/driver-mongodb': minor --- -fix(driver-memory, driver-mongodb): a non-boolean `$exists` comparand is refused with `INVALID_FILTER` / 400, as `$null`'s is, instead of selecting the rows with no value (#20897) +fix(driver-memory, driver-mongodb)!: a non-boolean `$exists` comparand is refused with `INVALID_FILTER` / 400, as `$null`'s is, instead of selecting the rows with no value (#20897) -Clause-②: no +Clause-②: no (narrowing) -`FieldOperatorsSchema` declares `$exists` as a boolean, and `driver-sql`, `driver-sqlite-wasm` and both Turso transports already refused any other comparand. The in-memory driver and the MongoDB driver did not: they read `$exists` as `value === true`, so every other value asked for the rows with NO value. `{ stage: { $exists: "yes" } }` and `{ stage: { $exists: 1 } }` returned the rows without a stage, the opposite of what was written. `0`, `null` and the string `"false"` landed on that same side by the same default, not because anything read them. + -Both drivers now refuse a non-boolean `$exists` (a string, a number, `null`, `undefined`, an object) with `INVALID_FILTER` / 400. The message is `driver-sql`'s, beginning `Operator "$exists" on field "FIELD" requires a boolean comparand (true or false).`, and names the position (`filter.stage.$exists`). On the in-memory driver the refusal covers `find`, `findOne`, `count`, `aggregate`, `updateMany`, `deleteMany` and the analytics face (`query()` and `generateSql()`). There, an `undefined` or object comparand is refused first by that face's comparand-type check, also `INVALID_FILTER` / 400, in its own words. A refused write changes nothing. The analytics face had read the flag by truthiness and answered the valued rows for the same filter, so the in-memory driver used to give two different answers. +**BREAKING**: this narrows what the in-memory driver and the MongoDB driver accept in a filter. A `$exists` comparand that is not a boolean (a string, a number, `null`, `undefined`, an object) is now refused with `INVALID_FILTER` / 400, where these two drivers used to answer it. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. -`$exists: true` and `$exists: false` are unchanged: `true` selects the rows that have a value, `false` the rows that have none. +`FieldOperatorsSchema` declares `$exists` as a boolean, and `driver-sql`, `driver-sqlite-wasm` and both Turso transports already refused any other comparand. The in-memory driver and the MongoDB driver did not: they read `$exists` as `value === true`, so every other value asked for the rows with NO value. `{ stage: { $exists: "yes" } }` and `{ stage: { $exists: 1 } }` returned the rows without a stage, the opposite of what was written. `0`, `null` and the string `"false"` landed on that same side by the same default, not because anything read them. The in-memory driver's analytics face read the same flag by truthiness and answered the valued rows for the same filter, so that driver gave two different answers. -**If a query now fails:** write the boolean itself. `"$exists": true` matches rows whose field has a value, and `"$exists": false` matches rows whose field has none. +**What an author sees now.** `400 INVALID_FILTER` with `driver-sql`'s message, beginning `Operator "$exists" on field "FIELD" requires a boolean comparand (true or false).` and naming the position (`filter.stage.$exists`). On the in-memory driver the refusal covers `find`, `findOne`, `count`, `aggregate`, `updateMany`, `deleteMany` and the analytics face (`query()` and `generateSql()`). There, an `undefined` or object comparand is refused first by that face's comparand-type check, also `INVALID_FILTER` / 400, in its own words. A refused write changes nothing. + +**What to write instead.** Write the boolean itself. `"$exists": true` matches rows whose field has a value, and `"$exists": false` matches rows whose field has none. + +**Who is affected.** A caller that sent a non-boolean `$exists` to `InMemoryDriver` or `MongoDBDriver` (a test suite, a local or embedded deployment, a flow or hook calling the engine in-process) and read the answer as a real one. On `SqlDriver` the same filter was already a 400. + +**Unchanged.** `$exists: true` and `$exists: false` answer exactly as before. The aggregation `filter` and `having` positions, which the engine evaluates itself after the driver, are not changed by this entry. From 378effc8caf39153ed30e3cab93f6c7317d451f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:21:32 +0000 Subject: [PATCH 6/6] chore(client): declare the one driver-memory test the envelope caller census ledgers as a client test input Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- scripts/cross-package-test-inputs.mjs | 8 ++++++++ turbo.json | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 329dc77870d..1178c535eae 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -748,6 +748,14 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // `@objectstack/spec` already declares it verbatim, so Layer C reaches it // today. What stays uncovered stays recorded in that test's header. 'scripts/**', + // [#20897] ONE file under `packages/`, declared by name because the + // census LEDGER carries a row for it: driver-memory's `$exists` refusal + // suite calls `analytics.query(` on its own `MemoryAnalyticsService`, + // which the census counts (receiver `service`, verdict `NOT_SDK`). A real + // input, not a prose mention: a call added to or removed from that file + // moves the census verdict, so a change to it has to re-run this suite. + // Per-file, not `packages/**`, for the price the entry above records. + 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts', ], heldBy: { // `scripts/**` is rostered TODAY through the census's own diff --git a/turbo.json b/turbo.json index 0ddd48454ba..81094414676 100644 --- a/turbo.json +++ b/turbo.json @@ -174,7 +174,8 @@ "$TURBO_ROOT$/scripts/check-route-envelope.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.d.mts", - "$TURBO_ROOT$/scripts/**" + "$TURBO_ROOT$/scripts/**", + "$TURBO_ROOT$/packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts" ] }, "@objectstack/lint#test": {