fix(driver-memory): a row with no value is not inside $gt / $gte / $lt / $lte - #14077
Conversation
…$lte` (#13553) The reference matcher compared a stored `null` instead of deciding first whether the comparison was meaningful. On a NUMERIC column `null` coerces to `0`, so `null >= -1` is a true comparison between two numbers and the no-value row landed inside the bound — while this package's live mingo path excluded it. The four ordering operators now exclude a no-value row on BOTH readings of "no value" (a stored `null` and an absent key), decided in the pre-switch guard beside the reading that was already handled there. `$between` is deliberately not a member of the guarded set: it decides the case itself in `valueWithinRange`, and its answer for the degenerate all-absent range is the opposite one. A no-value COMPARAND in an ordering position is excluded from the guard too, so those cells keep today's answer rather than being decided here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…dering-arms-no-value
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 496ce3761e4ba2566cb23f0da33a879afcd64c1b && git checkout 496ce3761e4ba2566cb23f0da33a879afcd64c1b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8dc22d68ddcb52e0c3a1659f967685ddbc1a2b64 43b799bc66c22c3e7401f4b781786558981c77b4 && git checkout -B drift-repro 8dc22d68ddcb52e0c3a1659f967685ddbc1a2b64 && git merge --no-ff 43b799bc66c22c3e7401f4b781786558981c77b4
node scripts/docs-audit/affected-docs.mjs --json 8dc22d68ddcb52e0c3a1659f967685ddbc1a2b64 |
Fixes #13553
The reference matcher (
packages/drivers/driver-memory/src/memory-matcher.ts) compared a storednullinstead of first deciding whether the comparison meant anything. On a numeric column JS coercesnullto0, sonull >= -1is a true comparison between two numbers and the no-value row lands inside the bound.Why this card exists: after #13549 landed, this file contradicted itself
$betweenroutes throughvalueWithinRangeand excludes a null-valued numeric row. But this package's live path compiles$betweenINTO$gte+$lte(memory-driver.ts). So onmainbefore this PR, one face answered{n: {$between: [-1, 1]}}with['2'], and{n: {$gte: -1, $lte: 1}}with['2','3']on the same rows — although they are one predicate to the live path. That is the same face giving two answers to one query, and it is what this PR resolves.
Measured, before and after
Fixture (the card's):
{id:'1', n:5}{id:'2', n:0}{id:'3', n:null}{id:'4'}— key ABSENT on row 4. Both faces driven per cell;origin/mainat8b04c75d7d.{n: {$gte: -1}}['1','2']['1','2','3']['1','2']{n: {$gt: -1}}['1','2']['1','2','3']['1','2']{n: {$lte: 1}}['2']['2','3']['2']{n: {$lt: 1}}['2']['2','3']['2']A full sweep of all 18 declared operators on this numeric fixture went from 9 divergences to 3; the 3 that remain are pre-existing, out of scope, and measured byte-identical across the change (below).
The repair
The four ordering operators are named as a set and consulted from the pre-switch guard — the place that already decides the other reading of "no value" (
value === undefined). Both readings now land on one answer, EXCLUDE, side by side. That is the ruling on file: the live mingo path excludes such a row, and the platform's settled reading admits a no-value row only for the negation-carrying operators ($ne/$nin/$notContains, #5298 option A, re-affirmed 2026-08-10).Two things deliberately do not move, and both are enforced by assertions in this PR:
$betweenis not a member of the set. It decides the no-value case itself, and its answer is the opposite one — the degenerate range whose both ends are absent selects the no-value rows (driver-memory's reference matcher answers{$between: [null, null]}with EVERY valued row — the range arm's two comparisons are both false against a null bound, so a bounded range stops bounding #13495). Adding it to the set would returnfalsebeforevalueWithinRangeran and silently move a ruled cell.{$gte: null}and its three siblings keep today's answer exactly. See the next section — this was the design decision of the card.The one scope decision, made by measurement
The natural-looking repair is to extract
valueWithinRange's comparability half and reuse it for the four arms. Two measurements say not to:{n: {$gt: null}}with[]but{n: {$gte: null}}with['3']— it treats two absences as equal, so strictness decides the case.valueWithinRangenever met that: it is built from>=and<=only, and a blanket "both absent, therefore true" would have made$gt: nullanswer['3']where the live path answers[]— a new divergence introduced by the repair.parseFilterASTon this tree:$in: [null],$nin: [null]and both$betweennull endpoints are REFUSED (INVALID_FILTER/ 400) by the 2026-08-31 ruling on [finding] driver-memory's matcher answers a NULL comparand inconsistently across the two readings of "no value" —$in:[null]/$nin:[null]disagree while$null/$ne:nullagree #13357 — while$gt/$gte/$lt/$ltewith anullcomparand are ACCEPTED. It is the one null-comparand position the contract still admits, and 分析查询的{field: {$eq: null}}/{$ne: null}编译成col = ''/col != '',与同文件里{field: null}的IS NULL自相矛盾 #5332's landing had already recorded it in writing as a position "no ruling covers".So the guard is scoped to the no-value row — exactly what the card and its triage ruled — and the comparand cells are left byte-identical rather than decided in an operator arm. Their divergence from the live path is reported on the issue as an open question for the maintainer, not resolved here.
Tests
packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts— the file that already pins this class — gains a#13553block. Every cell asserts the row set on both faces in one call, the file's standing discipline.{id:'2', n:0}stays IN on every arm. A "repair" that excluded everything falsy — the shape a reader reaches for once told thatnullcoerces to0— would drop the zero row too and still turn the four cells above green. This is what stops the pin going vacuous;$betweenand its own two bounds answer one row the same way, over three ranges;null >= '2026-07-01'compares0againstNaN) and still do, which is why three earlier cards passed over this defect;$between's degenerate all-absent cell is asserted unmoved, on both fixtures.The
{$gte: null}cells are deliberately not pinned, in either direction — pinning them would prejudge a ruling nobody has made. The header note says so, in the same terms the file already uses for #13357's arms.Verification
Run at
43b799bc66(the final commit,origin/mainmerged in):pnpm --filter @objectstack/driver-memory test— 38 files, 1025 tests passed,TEST_EXIT=0pnpm --filter @objectstack/driver-memory typecheck—TYPECHECK_EXIT=0, andtsc --listFilesconfirms both edited files are in the program (no*.test.tsexclusion), so that reading covers the new testspnpm --filter '@objectstack/driver-memory^...' build)scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack): 34 families, 31 exit 0. Three exited 3 = NOT MEASURED, each by its own printed verdict and none a red:check-test-completeness(grades a saved turbo test log; none exists locally, unreachable in CI),check:dual-build-cjs-loads(needs every package'sdist/),check:type-check-debt(needs the full workspace closure built)Ablation — per-arm separability
Mutating the committed implementation, each leg proved on disk (
git hash-objectdiffering from the HEAD blob plus an anchor count) and restored undertrap ... EXIT INT TERMwith absolute paths pinned to HEAD, each restore proved by a matching blob and an emptygit diff HEAD. No rebuild leg applies: the probe imports./memory-matcher.jsrelatively, inside the same package, so nodistwall is crossed — evidenced by the source edit changing the measured answers with no build ofdriver-memorybetween.$betweencontrol green$gtedropped from the set$gtearm redARM $gte$ltdropped from the set$ltarm redARM $ltA first attempt at leg B was voided by its own on-disk proof and re-run: the anchor
'$gte'also occurs in the docblock prose, so the count check failed although the mutation had landed. It is recorded rather than quietly retried.Invariance of the cells this PR does not decide
Same sweep, before and after, byte-identical in both runs:
{n: {$in: [null]}}['3','4']['3']['3']{n: {$nin: [null]}}['1','2']['1','2','4']['1','2','4']{n: {$gte: null}}['3']['1','2','3']['1','2','3']{n: {$lte: null}}['3']['2','3']['2','3']{n: {$gt: null}}[]['1']['1']{n: {$lt: null}}[][][]The first two are #13357's cells, refused at the contract door since 2026-08-31 and reachable only by a caller that skips the compile face — the boundary
memory-null-list-member-unreachable.test.tsstates in its own docblock. The guard is written over the OPERATOR set, which is what keeps them outside its blast radius.Generated by Claude Code