Commit a3dc817
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
File tree
- .changeset
- packages
- client/src
- drivers
- driver-memory/src
- driver-mongodb/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | | - | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
74 | 76 | | |
75 | 77 | | |
76 | 78 | | |
| |||
479 | 481 | | |
480 | 482 | | |
481 | 483 | | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
482 | 490 | | |
483 | 491 | | |
484 | 492 | | |
| |||
676 | 684 | | |
677 | 685 | | |
678 | 686 | | |
679 | | - | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
680 | 693 | | |
681 | 694 | | |
| 695 | + | |
682 | 696 | | |
683 | 697 | | |
| 698 | + | |
684 | 699 | | |
685 | 700 | | |
686 | 701 | | |
| |||
725 | 740 | | |
726 | 741 | | |
727 | 742 | | |
728 | | - | |
| 743 | + | |
729 | 744 | | |
730 | 745 | | |
731 | | - | |
| 746 | + | |
732 | 747 | | |
733 | 748 | | |
734 | 749 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
618 | 618 | | |
619 | 619 | | |
620 | 620 | | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
621 | 657 | | |
622 | 658 | | |
623 | 659 | | |
| |||
939 | 975 | | |
940 | 976 | | |
941 | 977 | | |
| 978 | + | |
| 979 | + | |
| 980 | + | |
| 981 | + | |
| 982 | + | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
942 | 986 | | |
943 | 987 | | |
944 | 988 | | |
| |||
Lines changed: 200 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
0 commit comments