Skip to content

Commit a3dc817

Browse files
fix(driver-memory,driver-mongodb): refuse a non-boolean $exists comparand with INVALID_FILTER / 400, as $null's is refused (#20897) (#20979)
Fixes #20897 Clause-②: no (narrowing) A non-boolean `$exists` comparand (`"yes"`, `1`, `"false"`, `0`, `null`, `undefined`, an object) is now refused with `INVALID_FILTER` / 400 on driver-memory and driver-mongodb, which were the two faces still answering it. The words are driver-sql's `nonBooleanExistsComparandError`, and the refusal takes the same place and form as each driver's existing `$null` refusal. `true` and `false` answer exactly as before. Why the declaration is no (narrowing): `FieldOperatorsSchema` declares `$exists: z.boolean()`, so this pulls two faces back to the declared contract. It reuses the existing `INVALID_FILTER` code and driver-sql's existing wording, and it adds no key, code or accepted shape. It does narrow what driver-memory and driver-mongodb accept: a filter they used to answer is now refused. The changeset declares it BREAKING, `minor` for both packages under the launch-window convention for accept-set narrowings, with the ADR-0087 disposition `not-required (already-registered filter-query-face-comparands-refused-at-save)`. That registered entry's reason already states that every query face refuses a non-boolean flag, and its replacement is this change's migration. ## What was wrong Measured on `origin/main` `f6ccca4a` with an object `px` holding row `a` (`name: "won"`) and row `b` (`name: null`): | face | `"yes"` | `1` | `"false"` | `0` / `null` | `true` | `false` | |:--|:--|:--|:--|:--|:--|:--| | `engine.find` over driver-memory | `[b]` | `[b]` | `[b]` | `[b]` | `[a]` | `[b]` | | driver-memory analytics (cube) face, sum over the rows | `a` | `a` | `a` | `b` | `a` | `b` | | driver-mongodb `translateFilter` | `$eq: null` | `$eq: null` | `$eq: null` | `$eq: null` | `$ne: null` | `$eq: null` | | `engine.find` over driver-sql (better-sqlite3) | refused | refused | refused | refused | `[a]` | `[b]` | driver-memory's query path and driver-mongodb's emitter both asked `value === true` and sent every other value to the no-value side, so `$exists: "yes"` returned the rows with NO value. That is the author's intent inverted. driver-memory's own cube face read the same flag by truthiness (`Boolean(raw[0])`) and answered the opposite rows for `"yes"`, `1` and `"false"`. So one package gave two answers to one filter. ## The landing: per-face gates, not an engine door The PM's H1 held. `$null`'s refusal lives per face, and no engine-level door judges a flag. The spec's shared comparand faces (`assertListComparandShapes`, `normalizeFilterComparandTypes`) deliberately leave `$null` / `$exists` / `$empty` to the faces, as `filter.zod.ts` records. Each driver already runs one validating walk ahead of its lowering, and `$null` is refused there. This PR puts `$exists` beside it: - **driver-memory** `filter-refusal.ts`: `assertFilterConditionShape` refuses a non-boolean `$exists` through a new `nonBooleanExistsComparandError`, next to the `$null` / `$empty` checks. Every memory entry runs this gate before it lowers anything, so the one edit covers `find`, `findOne`, `count`, `aggregate`, `updateMany`, `deleteMany` and the analytics face (`query()` and `generateSql()`). All of these were measured. `memory-driver.ts` is not touched: its `$exists` arm now sees only booleans. - **driver-mongodb** `mongodb-filter.ts`: `reduceFilterKey` (the walk) refuses it next to the `$null` gate, and the emitter's `$exists` arm keeps a local check with the same constructor, as the `$null` arm does. I did not add an engine-level door. The faces that still accept a non-boolean `$exists` after this PR are the engine's own in-process evaluators: objectql's aggregation `filter` and `having`, plus `@objectstack/formula`. Their evaluability doors (`assertHavingIsEvaluable`, `assertAggregationFilterIsEvaluable`, and `assertConditionIsEvaluable`, which already refuses a non-boolean `$empty`) live in `having-filter.ts`. That file and formula's `matches-filter.ts` are fenced for this card. A door anywhere else would be a second copy of the rule that still does not reach them. They are measured and named below for routing. ## Every compile face | # | face | conclusion | |:--|:--|:--| | 1 | `driver-sql` `applyFilterCondition` (and `driver-sqlite-wasm`, Turso local, which inherit it) | **Already compliant.** `engine.find` on better-sqlite3 and `driver-sqlite-wasm` `find` refuse `"yes"`, `1`, `"false"`, `0` and `null` with `INVALID_FILTER` / 400, and `true` / `false` give `[a]` / `[b]`. Pinned in `sql-driver-out-of-contract-filter-input.test.ts` (the `[#5369]` block: `"yes"`, `1`, `0`, `null`, `undefined`, `{}`, `"false"`, plus a `true` / `false` control). Not edited (#20822 group 2 in flight). | | 2 | Turso `RemoteTransport` `buildWhereSQL` | **Already compliant.** `remote-transport-null-comparand-refusal.test.ts` (block d) and `turso-local-remote-null-parity.test.ts` ("both transports REFUSE a non-boolean `$exists`"): 90 tests green at this head. Not edited. | | 3 | service-analytics `read-scope-sql` `compileScopedFilterToSql` | **Already compliant.** `assertBooleanFlagComparands` refuses `$null` / `$exists` / `$empty`, fail-closed. `read-scope-boolean-flag-comparand.test.ts` is green. | | 4 | service-analytics `filter-normalizer` `lowerAnalyticsWhere` | **Already compliant.** `assertBooleanNullFlags` refuses a non-boolean `$null` / `$exists` before any lowering. `where-boolean-flag-refusal.test.ts` is green (the two suites, 104 tests). | | 5 | `formula` `matchesFilterCondition` | **Out of scope** (#20869 is in flight on this file). Measured: `"yes"`, `1`, `"false"`, `0` and `null` all match row `b`, because `v === true ? actual != null : actual == null`. Its `$null` arm is unrefused the same way. Named in the report for the PM to route. | | half | objectql `having-filter` (`applyHaving`, `matchesHaving`, `matchesAggregationFilter`) | **Out of scope** (serial behind #20822 F8, then #20873). Measured through `engine.aggregate` on memory AND SQLite: both the aggregation `filter` and `having` read `$exists` as `!!target`. `"yes"`, `1` and `"false"` select the valued rows and groups, and `0` / `null` select the no-value ones. The engine evaluates these in-process, so no driver refuses them. For context, `$null: "yes"` on these two faces drops the constraint (every row or group comes back). Named in the report. | | unfrozen | `driver-memory` (query path and cube face) | **Changed**: refused on every entry (above). | | unfrozen | `driver-mongodb` `translateFieldOperators` | **Changed**: refused on the walk, with the emitter check kept. | ## Pins - `memory-null-comparand-refusal.test.ts`: the case "`$exists` is deliberately NOT tightened here" pinned the inverted answer `['2']` for `$exists: "yes"`. It now pins the refusal (`code` `INVALID_FILTER`, `status` 400, driver-sql's first sentence, the position) for `"yes"`, `1` and `"false"`, through the live path and the gate alike, with `true` / `false` as the control. The case was flipped, not deleted. - `memory-exists-non-boolean-refusal.test.ts` (new) is the multi-face invariant: every entry of the package (the eight listed above) refuses the seven non-booleans. For `true` / `false`, every read entry, the cube face included, answers exactly `find()`'s rows. The same row set as `find()`, or `INVALID_FILTER`, and never a third answer. On the cube face, `undefined` and a plain object are refused first by that face's comparand-type check, which is its documented precedence. It is the same envelope and the same position, with that face's own sentence. The file also pins the refusal at every depth, including behind a TRUE identity (`$or: [{}, …]`), and checks that a refused `updateMany` / `deleteMany` leaves the store untouched. - `mongodb-exists-non-boolean-refusal.test.ts` (new): the seven non-booleans refused on the translator, with the position inside combinators and behind a boolean identity that would otherwise settle the node before the emitter runs. `true` / `false` still translate to `$ne: null` / `$eq: null` and equal their `$null` mirror. - SQLite: the existing driver-sql pins above already assert `"yes"`, `1` and `"false"` refused with a `true` / `false` control. driver-sql is fenced, so they were cited, not duplicated. **Pin sweep.** A repo-wide grep for a non-boolean `$exists` literal and for the `$exists` refusal's words outside the faces that already refuse found one pin that asserted the old answer, the memory case above. The CHANGELOG entries that say "`$exists` is deliberately NOT tightened" are released text and are not edited. ## Verification (driver changes at `da4feaf8`; final head `378effc8`) - `pnpm --filter @objectstack/driver-memory test`: 68 files, 1500 tests passed. - `pnpm --filter @objectstack/driver-mongodb test`: 30 files passed, 5 skipped, 671 passed and 172 skipped. The skipped files need a real `mongod`, which is opt-in (`OS_TEST_MONGODB_MEMORY_SERVER_ENABLED`). The new pins do not need it: they run on the translator. - `typecheck` for both packages exit 0. The new memory test is in `tsc --noEmit`'s program (`--listFiles`), and the mongodb one is in `tsconfig.test.json`'s (`check:test-typecheck`). - **Reverse verification**, run from the committed fix through `scripts/ablation-replace.mjs`, which asserts each mutation on disk (anchor count 1 to 0, blob changed) and proves each restore (blob equals HEAD, `git diff HEAD` empty). Both packages' tests import `src/` directly, so no `dist/` was involved. - Memory gate removed: the two memory files went **red, 55 failed / 24 passed**. The 24 that stayed green are the 18 `$null` cases of the older file, the two `true` / `false` controls and the four cube-face cells the type face refuses first. - MongoDB walk gate removed, emitter check kept: **red, 1 failed / 9 passed**. Only the identity-settled case failed, as predicted: the emitter still refuses the flat shapes, and only the walk reaches a node an identity settles. - MongoDB walk gate and emitter check both removed: **red, 9 failed / 1 passed**. Only the `true` / `false` control stayed green. - The first attempt at the second leg was an empty operation. Its replacement text contained the anchor, so the tool refused the mutation and nothing was measured. The anchor was changed and the leg re-run. - Driver conformance ledger (`node scripts/check-driver-conformance.mjs`): **50 covered, 0 DEBT, 0 exempt** before and after. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` after the last commit (`378effc8`) derived 79 families. `--ran` reconciles them as 79 accounted for: 77 run and exit 0, and 2 NOT MEASURED. Those two are `check:dual-build-cjs-loads`, which reads every package's `dist/`, and `check:type-check-debt`, whose re-measure needs the whole build closure. Both exit 3 (PREREQUISITE NOT MET) on this partial build and are declared to CI. `check:skill-examples` first exited 3 for want of the `client-react` build, and ran green once that closure was built. - ESLint, narrowed to the 7 changed code files (the 6 `.ts` files and `scripts/cross-package-test-inputs.mjs`) with `--no-inline-config`: 7 files, 0 errors, 0 warnings (counted from `--format json`). Each file resolves a config (`--print-config`). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on an untouched file. ## Outside the drivers: the envelope caller census The new memory suite calls `analytics.query(` twice on its own `MemoryAnalyticsService`. `@objectstack/client`'s `envelope-caller-census.test.ts` walks the whole repo for that call shape, so CI went red at `da4feaf8` (`Test Core (5/6)`). The census prescribes classifying every counted site, and its receiver split already has the class for a producer call. So `LEDGER` gains one `NOT_SDK` row (count 2, receiver `service`), and its two exact-count controls move with it: the service-receiver control now expects 3 sites and pins their files, and the verdict split reads 3 not-SDK. Nothing is exempted or loosened. The census as it stood at `da4feaf8` goes red locally on the same two cases CI named. At `378effc8` it is 20/20 green, and the client package's tests (641) and typecheck pass. The census reads that driver-memory file, so `pnpm check:cross-package-test-inputs` requires it declared. `scripts/cross-package-test-inputs.mjs` names the one file in `@objectstack/client`'s globs, and `turbo.json` mirrors it into `@objectstack/client#test` inputs, so a change to that suite re-runs the census. It is per-file, not `packages/**`, for the price the census header records. `check:ci-filter-parity` passes, because `core` already covers the path. ## Acceptance notes - `memory-driver.ts`'s `$exists` arm has no local totality check. The `$null` arm has one. After the gate it is unreachable with a non-boolean, so the asymmetry is dead code. That file belongs to #20874 this round and was not touched. - `filter.zod.ts` (the save-door docblock, "Every query face refuses a non-boolean `$null` / `$exists` flag") and `filter-save-door-refusals.ts` ("refused on every query face") now hold for every driver. They still overstate the two engine-side evaluators and formula, measured above. - No shared conformance case-set carries a driver-level refusal verdict for the flags. `FILTER_LOGIC_CASES` asserts rows only, and `FILTER_COMPARAND_TYPE_CASES`' refusal verdict is the upstream `parseFilterAST` door. So the flag refusal is held per driver (sql, sqlite-wasm, the Turso parity suite, mongodb, and now memory), not by one table. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b253fad commit a3dc817

9 files changed

Lines changed: 480 additions & 19 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/driver-memory': minor
3+
'@objectstack/driver-mongodb': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (already-registered filter-query-face-comparands-refused-at-save) this narrows the query faces to the rule that registered entry already records: its reason states that every query face refuses a non-boolean $null / $exists flag, and its replacement is this change's whole migration (a flag is the boolean itself; $exists true is "has a value", false "has no value"). This change makes that statement true on the two drivers that did not yet refuse. No authorable key, spelling, export or published type moves, and no stored row is read or rewritten; a stored filter carrying such a flag is already refused when it is saved, by that entry. -->
11+
12+
**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.
13+
14+
`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.
15+
16+
**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.
17+
18+
**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.
19+
20+
**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.
21+
22+
**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.

‎packages/client/src/envelope-caller-census.test.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@
7070
* `AnalyticsService` in `analytics-automation-json-erasure.test.ts`, where
7171
* `analytics` is the SERVICE, not the client. That is a producer call and no
7272
* part of the SDK caller population, so every row carries its receiver and the
73-
* ledger classifies it `NOT_SDK`.
73+
* ledger classifies it `NOT_SDK`. [#20897] driver-memory's refusal suite does
74+
* the same on its own cube service (`MemoryAnalyticsService` bound to
75+
* `analytics`), so it carries a `NOT_SDK` row too.
7476
*
7577
* ## The verdicts
7678
*
@@ -479,6 +481,12 @@ const LEDGER: readonly LedgerRow[] = [
479481
method: 'analytics.query', receiver: 'service', count: 5, verdict: 'NOT_SDK',
480482
why: 'the real AnalyticsService (the cube read), called to compare its answer for the nested-relation filter with the engine\'s',
481483
},
484+
// ── a producer face outside the SDK: driver-memory's cube service ────────
485+
{
486+
file: 'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts',
487+
method: 'analytics.query', receiver: 'service', count: 2, verdict: 'NOT_SDK',
488+
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',
489+
},
482490
{
483491
file: 'packages/client/src/analytics-automation-json-erasure.test.ts',
484492
method: 'analytics.meta', receiver: 'sdk', count: 2, verdict: 'PAYLOAD_DEPENDENT',
@@ -676,11 +684,18 @@ describe('#13079 §2 — positive controls on the matcher itself', () => {
676684
// method, so a literal-embedded site lands HERE first, as a phantom
677685
// producer call. That makes this the assertion most likely to break
678686
// for a reason that has nothing to do with receivers.
679-
expect(service.length, literalNote()).toBe(6);
687+
// [#20897] Three producer faces call `analytics.query` bare: the real
688+
// AnalyticsService behind the SDK, the same service in `@objectstack/rest`'s
689+
// nested-relation pin, and driver-memory's cube service
690+
// (`MemoryAnalyticsService`) in its own refusal suite. None of the
691+
// receivers is the client, and every file is pinned.
692+
expect(service.length, literalNote()).toBe(8);
680693
expect([...new Set(service.map((s) => s.file))].sort()).toEqual([
681694
'packages/client/src/analytics-automation-json-erasure.test.ts',
695+
'packages/drivers/driver-memory/src/memory-exists-non-boolean-refusal.test.ts',
682696
'packages/rest/src/analytics-nested-relation-filter.test.ts',
683697
]);
698+
expect(service.every((s) => s.method === 'analytics.query')).toBe(true);
684699
});
685700
});
686701

@@ -725,10 +740,10 @@ describe('#13079 §3 — every call site is classified', () => {
725740
expect(production, literalNote()).toEqual([]);
726741
});
727742

728-
it('records the split: 18 payload pins, 10 result-insensitive, 6 not-SDK', () => {
743+
it('records the split: 18 payload pins, 10 result-insensitive, 8 not-SDK', () => {
729744
expect(verdictTotal('PAYLOAD_DEPENDENT')).toBe(18);
730745
expect(verdictTotal('RESULT_INSENSITIVE')).toBe(10);
731-
expect(verdictTotal('NOT_SDK')).toBe(6);
746+
expect(verdictTotal('NOT_SDK')).toBe(8);
732747
// The three above are LEDGER sums and cannot move on a census reading;
733748
// this one is census-derived, so it carries the note. [#13874]
734749
expect(sdkSites.length, literalNote()).toBe(28);

‎packages/drivers/driver-memory/src/filter-refusal.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,42 @@ export function nonBooleanNullComparandError(field: string, value: unknown, path
618618
);
619619
}
620620

621+
/**
622+
* [#20897, applying #5347's ruling A as #5369 did] `$exists` whose comparand
623+
* is not a boolean.
624+
*
625+
* The symmetric twin of {@link nonBooleanNullComparandError}, and a copy of its
626+
* disposition rather than a fresh judgement: `FieldOperatorsSchema` declares
627+
* `$exists: z.boolean()` exactly as it declares `$null`, and the 2026-08-06
628+
* ruling on #5298 applied #5347-A to `$exists` by name — `driver-sql` has
629+
* refused a non-boolean here since. This driver did not, and its answer was the
630+
* sharpest of the splits: the live path lowered `val === true` to has-a-value
631+
* and EVERYTHING else to no-value, so `{ stage: { $exists: 'yes' } }` returned
632+
* the rows with NO value — the author's intent inverted — while this package's
633+
* own cube face read the flag by truthiness (`Boolean(raw[0])`) and answered
634+
* the valued rows for the same filter. One filter, one package, two answers.
635+
* Measured on `origin/main` `f6ccca4a` through `engine.find`, `count`,
636+
* `aggregate`, `updateMany`, `deleteMany` and the analytics face, before this
637+
* refusal.
638+
*
639+
* The words are `driver-sql`'s `nonBooleanExistsComparandError`, verbatim —
640+
* one condition, one wording (#5240) — with its "this driver" clause re-aimed
641+
* at the backend it names, the way the `$null` twin above names `driver-sql`.
642+
*/
643+
export function nonBooleanExistsComparandError(field: string, value: unknown, path: string): Error {
644+
return unsupportedFilterError(
645+
`Operator "$exists" on field "${field}" requires a boolean comparand (true or false). ` +
646+
`Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` +
647+
`@objectstack/spec FieldOperatorsSchema declares $exists as a boolean. It is refused rather ` +
648+
`than coerced for the same reason $null is: a non-boolean lands on whichever side ` +
649+
`the backend's two-branch conditional happens to default to, and those defaults point in ` +
650+
`OPPOSITE directions — driver-sql's \`=== false\` test compiles IS NOT NULL for anything ` +
651+
`but false, this driver's \`=== true\` test compiled IS NULL for anything but true. Note ` +
652+
`"false" the STRING is truthy, so it lands on the side opposite the false it was written ` +
653+
`to mean.`,
654+
);
655+
}
656+
621657
/**
622658
* [#20444] A non-boolean `$empty` comparand. The leading sentence is
623659
* `driver-sql`'s `nonBooleanEmptyComparandError`, verbatim — one condition,
@@ -939,6 +975,14 @@ function assertFieldConstraintShape(
939975
if (op === '$null' && typeof spec[op] !== 'boolean') {
940976
throw nonBooleanNullComparandError(field, spec[op], `${path}.$null`);
941977
}
978+
// [#20897] `$exists`' comparand is a boolean by the same declaration, and
979+
// the ruling that refused `$null`'s third value refused this one too. On
980+
// this walk for the reason `$null` is: the live path's `=== true` arm and
981+
// the cube face's truthiness read put a third value on OPPOSITE sides, so
982+
// the refusal has to fire before either face lowers anything.
983+
if (op === '$exists' && typeof spec[op] !== 'boolean') {
984+
throw nonBooleanExistsComparandError(field, spec[op], `${path}.$exists`);
985+
}
942986
// [#20444] `$empty`'s comparand is a boolean by the same declaration
943987
// (`FieldOperatorsSchema`), refused on this walk for the same reason: both
944988
// faces of this package evaluate `true` / `false` exhaustively, so a third
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20897] `$exists` takes a boolean. A non-boolean is refused on EVERY entry
5+
* of this package, in `driver-sql`'s words — the `$null` twin's disposition
6+
* (#5347-A), applied to `$exists` by the ruling on #5298 (#5369).
7+
*
8+
* # What was measured
9+
*
10+
* `FieldOperatorsSchema` declares `$exists: z.boolean()`, and nothing between an
11+
* authored `where` and this driver validated it: `driver-sql`, `driver-sqlite-wasm`
12+
* and both Turso transports refused a non-boolean, and this package did not.
13+
* Measured on `origin/main` `f6ccca4a`, one row with `stage: 'won'` (id 1) and
14+
* one with `stage: null` (id 2):
15+
*
16+
* | entry | `'yes'` | `1` | `'false'` | `0` / `null` |
17+
* |---|---|---|---|---|
18+
* | `find` / `findOne` / `count` / `aggregate` / `updateMany` / `deleteMany` | id 2 | id 2 | id 2 | id 2 |
19+
* | the analytics (cube) face, `query()` | id 1 | id 1 | id 1 | id 2 |
20+
*
21+
* Two answers inside one package, and neither is a refusal. The query path's
22+
* `val === true` test sent every third value to the NO-value side — `'yes'`
23+
* asked for the rows without one, the author's intent inverted. The cube face's
24+
* `set` arm read the same flag by truthiness, so it answered the opposite rows
25+
* for `'yes'` and `1` — and for the string `'false'`, which is truthy.
26+
*
27+
* # Why every entry is asserted, and against `find()`
28+
*
29+
* The refusal lives in ONE function (`assertFilterConditionShape`), which every
30+
* entry runs before it lowers anything. What would let a future change re-fork
31+
* the answers is an entry reaching its lowering WITHOUT that walk — the cube
32+
* face's truthiness arm is still there behind it. So each entry is asserted to
33+
* refuse, and, for the two booleans the spec declares, to answer exactly the
34+
* rows `find()` answers: the same row set as `find()`, or refused with
35+
* `INVALID_FILTER` — never a third, quieter answer.
36+
*/
37+
38+
import { describe, it, expect, beforeEach } from 'vitest';
39+
import type { FilterCondition } from '@objectstack/spec/data';
40+
41+
import { InMemoryDriver } from './memory-driver.js';
42+
import { MemoryAnalyticsService } from './memory-analytics.js';
43+
44+
interface WireBearingError extends Error {
45+
code?: string;
46+
status?: number;
47+
}
48+
49+
const ROWS = [
50+
{ id: '1', stage: 'won', score: 10 },
51+
// The null-valued row that separates "has a value" from "has none".
52+
{ id: '2', stage: null, score: 20 },
53+
];
54+
55+
/**
56+
* The exact leading sentence `driver-sql` produces for this condition, copied
57+
* from `sql-driver.ts`'s `nonBooleanExistsComparandError`. A literal rather
58+
* than an import: this package does not depend on driver-sql (and must not),
59+
* so one condition, one wording (#5240) is held by pinning the other side's
60+
* text here.
61+
*/
62+
const DRIVER_SQL_LEADING_SENTENCE = (field: string) =>
63+
`Operator "$exists" on field "${field}" requires a boolean comparand (true or false).`;
64+
65+
/**
66+
* The triage pins (`'yes'`, `1`, the string `'false'`), then the rest of the
67+
* battery `driver-sql`'s own `$exists` pins run — so the two backends are held
68+
* to the same inputs.
69+
*/
70+
const NON_BOOLEAN: Array<[label: string, value: unknown]> = [
71+
["the string 'yes'", 'yes'],
72+
['the number 1', 1],
73+
["the STRING 'false'", 'false'],
74+
['the number 0', 0],
75+
['null', null],
76+
['undefined', undefined],
77+
['an object', {}],
78+
];
79+
80+
const CUBE = {
81+
name: 'deals',
82+
title: 'Deals',
83+
sql: 'deal',
84+
measures: { total: { label: 'Total', type: 'count', sql: 'id' } },
85+
dimensions: {
86+
id: { label: 'Id', type: 'string', sql: 'id' },
87+
stage: { label: 'Stage', type: 'string', sql: 'stage' },
88+
},
89+
public: true,
90+
} as never;
91+
92+
describe('[#20897] a non-boolean $exists is refused on every entry of driver-memory', () => {
93+
let driver: InMemoryDriver;
94+
let analytics: MemoryAnalyticsService;
95+
96+
beforeEach(async () => {
97+
driver = new InMemoryDriver({ persistence: false });
98+
await driver.syncSchema('deal', {
99+
fields: {
100+
id: { type: 'text', name: 'id' },
101+
stage: { type: 'text', name: 'stage' },
102+
score: { type: 'number', name: 'score' },
103+
},
104+
} as any);
105+
for (const row of ROWS) await driver.create('deal', { ...row });
106+
analytics = new MemoryAnalyticsService({ driver, cubes: [CUBE] } as never);
107+
});
108+
109+
const sorted = (rows: unknown): string[] => (rows as Array<Record<string, unknown>>).map((r) => String(r.id)).sort();
110+
const q = (where: unknown) => ({ where: where as FilterCondition });
111+
const cubeQuery = (where: unknown) =>
112+
({ cube: 'deals', measures: ['total'], dimensions: ['id'], where }) as never;
113+
114+
/**
115+
* Every entry of this package that takes a `where`, by name, with the root its
116+
* refusal names and whether the shared comparand-TYPE face runs ahead of the
117+
* gate there. The analytics face runs it first (its door, ADR-0053 D-D1), so
118+
* a flag that face refuses on TYPE — `undefined`, a plain object — keeps that
119+
* face's own sentence, the precedence the analytics `where` door and the
120+
* engine seam give it too; the envelope and the position are the same.
121+
*/
122+
const ENTRIES: Array<[name: string, run: (where: unknown) => Promise<unknown>, path: string, typeFaceFirst: boolean]> = [
123+
['find', (w) => driver.find('deal', q(w)), 'filter', false],
124+
['findOne', (w) => driver.findOne('deal', q(w)), 'filter', false],
125+
['count', (w) => driver.count('deal', q(w)), 'filter', false],
126+
['aggregate', (w) => driver.aggregate('deal', { ...q(w), aggregations: [{ function: 'count', alias: 'n' }] } as never), 'filter', false],
127+
['updateMany', (w) => driver.updateMany('deal', q(w), { score: 99 }), 'filter', false],
128+
['deleteMany', (w) => driver.deleteMany('deal', q(w)), 'filter', false],
129+
['the analytics face, query()', (w) => analytics.query(cubeQuery(w)), 'where', true],
130+
['the analytics face, generateSql()', (w) => analytics.generateSql(cubeQuery(w)), 'where', true],
131+
];
132+
133+
/** The comparands the comparand-TYPE face refuses before any flag rule is asked. */
134+
const TYPE_FACE_REFUSED: ReadonlySet<unknown> = new Set<unknown>([undefined]);
135+
const isTypeFaceRefused = (value: unknown): boolean =>
136+
TYPE_FACE_REFUSED.has(value) || (typeof value === 'object' && value !== null);
137+
138+
const refusalOf = async (run: () => Promise<unknown>): Promise<WireBearingError> => {
139+
try {
140+
await run();
141+
} catch (e) {
142+
return e as WireBearingError;
143+
}
144+
throw new Error('expected this entry to refuse the filter, but it resolved');
145+
};
146+
147+
for (const [entry, run, root, typeFaceFirst] of ENTRIES) {
148+
for (const [label, value] of NON_BOOLEAN) {
149+
const words = typeFaceFirst && isTypeFaceRefused(value) ? 'the type face\'s words' : 'driver-sql\'s words';
150+
it(`${entry} refuses ${label} with INVALID_FILTER / 400, in ${words}`, async () => {
151+
const err = await refusalOf(() => run({ stage: { $exists: value } }));
152+
expect(err.code).toBe('INVALID_FILTER');
153+
expect(err.status).toBe(400);
154+
expect(err.message).toContain(`${root}.stage.$exists`);
155+
// The type face's sentence is its own contract, pinned in its own
156+
// suite; here only the flag rule's first sentence is load-bearing.
157+
if (!(typeFaceFirst && isTypeFaceRefused(value))) {
158+
expect(err.message).toContain(DRIVER_SQL_LEADING_SENTENCE('stage'));
159+
}
160+
});
161+
}
162+
}
163+
164+
it('refuses it at every depth, and a satisfiable sibling does not let it through', async () => {
165+
// The gate is a WALK, not an evaluation: `{ stage: 'won' }` matches and
166+
// `{}` is the TRUE identity, yet neither short-circuits the refusal.
167+
for (const [where, at] of [
168+
[{ $and: [{ stage: { $exists: 'yes' } }] }, 'filter.$and[0].stage.$exists'],
169+
[{ $or: [{ stage: 'won' }, { stage: { $exists: 1 } }] }, 'filter.$or[1].stage.$exists'],
170+
[{ $or: [{}, { stage: { $exists: 'false' } }] }, 'filter.$or[1].stage.$exists'],
171+
[{ $not: { stage: { $exists: 'yes' } } }, 'filter.$not.stage.$exists'],
172+
] as Array<[unknown, string]>) {
173+
const err = await refusalOf(() => driver.find('deal', q(where)));
174+
expect(err.code).toBe('INVALID_FILTER');
175+
expect(err.status).toBe(400);
176+
expect(err.message).toContain(at);
177+
}
178+
});
179+
180+
it('a refused write leaves the store untouched', async () => {
181+
await refusalOf(() => driver.updateMany('deal', q({ stage: { $exists: 'yes' } }), { score: 99 }));
182+
await refusalOf(() => driver.deleteMany('deal', q({ stage: { $exists: 1 } })));
183+
const rows = (await driver.find('deal', {} as never)) as Array<Record<string, unknown>>;
184+
expect(rows.map((r) => [String(r.id), r.score]).sort()).toEqual([['1', 10], ['2', 20]]);
185+
});
186+
187+
describe('the control: true and false answer find()\'s rows on every read entry', () => {
188+
for (const [flag, expected] of [[true, ['1']], [false, ['2']]] as Array<[boolean, string[]]>) {
189+
it(`$exists: ${flag} selects ${JSON.stringify(expected)}, and every read entry agrees with find()`, async () => {
190+
const where = { stage: { $exists: flag } };
191+
const found = sorted(await driver.find('deal', q(where)));
192+
expect(found).toEqual(expected);
193+
expect(await driver.count('deal', q(where))).toBe(expected.length);
194+
expect(String((await driver.findOne('deal', q(where)) as Record<string, unknown>).id)).toBe(expected[0]);
195+
const cube = (await analytics.query(cubeQuery(where))).rows as Array<Record<string, unknown>>;
196+
expect(cube.map((r) => String(r.id)).sort()).toEqual(found);
197+
});
198+
}
199+
});
200+
});

0 commit comments

Comments
 (0)