Skip to content

fix: enforce all where conditions when index optimization is partial#1582

Open
kevin-dp wants to merge 2 commits into
TanStack:test/showcase-index-optimization-or-and-bugsfrom
kevin-dp:fix/index-optimization-partial-and-or
Open

fix: enforce all where conditions when index optimization is partial#1582
kevin-dp wants to merge 2 commits into
TanStack:test/showcase-index-optimization-or-and-bugsfrom
kevin-dp:fix/index-optimization-partial-and-or

Conversation

@kevin-dp

@kevin-dp kevin-dp commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1581 — fixes the index optimizer so that the seven failing tests added there pass. All fixes are correctness fixes for where clauses served from indexes.

Note: stacked on #1581 — this PR's base is the test/showcase-index-optimization-or-and-bugs branch, so the diff contains only the fixes. Merge #1581 first, then retarget this PR to main (GitHub does this automatically if the base branch is deleted on merge).

Commit 1 — enforce all conditions when index optimization is partial

currentStateAsChanges treats the optimizer's matchingKeys as the exact result set, but the optimizer silently dropped conditions it couldn't serve from an index:

  • OR unioned only the optimizable disjuncts → rows matched only by a non-indexed disjunct were missing.
  • AND intersected only the optimizable conjuncts → non-indexed conjuncts were never enforced, returning non-matching rows.
  • Compound ranges (age > 5 AND age < 10) early-returned and discarded all sibling conjuncts, even indexed ones.

This also affected the initial state of filtered subscriptions / live queries (requestSnapshot sends snapshots without re-filtering), while subsequent change events were evaluated correctly — producing inconsistent, timing-dependent state.

Fix: OptimizationResult gains an isExact flag. OR now requires every disjunct to be optimizable (missing rows can't be recovered by post-filtering), else falls back to a full scan. AND keeps partial index optimization but marks the result inexact when any conjunct was skipped; the compound-range path reports which conjuncts it covered so the rest are processed. currentStateAsChanges re-checks candidate rows against the full expression only when the result is inexact — fully-indexed queries take the same fast path as before.

Commit 2 — range boundary fixes (from CodeRabbit review + what it surfaced)

  • Strictest bound at equal values (CodeRabbit finding): gte(x, 5) AND gt(x, 5) kept the inclusive bound when the non-strict condition appeared first. Bounds are now merged strictness-aware, comparing values with makeComparator (the same comparison semantics the indexes use) so dates and locale strings behave correctly — === would silently fail for equal-valued Date instances.
  • One-sided compound ranges returned empty results: the optimizer always passed {from, to, ...} to rangeQuery, but rangeQuery distinguishes an absent bound from an explicit undefined (which becomes the undefined-sentinel and produces an empty interval). Bounds are now only passed when they exist.
  • BTree exclusive bounds were broken for normalized values: rangeQuery's exclusive-lower-bound check compared the normalized indexed value (dates stored as timestamps) against the raw query Date — never equal, so plain gt(createdAt, date) included the boundary row. It now compares against the normalized key.

Testing

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds an isExact flag to index optimizations, propagates exactness through compound-range, AND, OR, and IN paths, requires all OR disjuncts be indexable, re-filters inexact candidate sets before emitting changes, fixes BTree exclusive-bound comparisons against normalized keys, and adds tests and a changeset documenting the behavior.

Changes

Index Optimization Correctness

Layer / File(s) Summary
Optimization result contract and auxiliary types
packages/db/src/utils/index-optimization.ts
OptimizationResult adds isExact: boolean. CompoundRangeResult and related imports/argIndex storage are introduced to support compound-range coverage tracking.
Compound range construction and strictest-bound logic
packages/db/src/utils/index-optimization.ts
Collects same-field range predicates with source indices, uses a comparator for ordering, computes strictest from/to bounds (inclusive/exclusive), and returns coveredArgIndices when the index can cover multiple predicates.
Simple comparison, IN, and recursive fallback paths
packages/db/src/utils/index-optimization.ts
Recursive fallback, simple comparison, and IN-array paths now set isExact: true for index-served lookups and isExact: false for structural/non-optimizable cases; IN fallback unions return exact when composed of index-served EQs.
AND and OR expression optimization with correctness fixes
packages/db/src/utils/index-optimization.ts
AND applies compound-range first, skips covered conjuncts, and combines/intersects remaining optimized results while marking exactness only when all contributors are exact. OR requires every disjunct to be optimizable and is exact only if all disjuncts are exact.
Change event consumer integration with re-filtering
packages/db/src/collection/change-events.ts
When an optimization result is inexact, change-event generation applies a compiled filter function to each candidate row to ensure emitted insert changes satisfy the full predicate.
BTree exclusive-bound correctness
packages/db/src/indexes/btree-index.ts
Exclusive lower-bound comparison uses the normalized fromKey to compare against stored normalized index keys, ensuring strict comparisons exclude the boundary as expected.
Tests and changelog
packages/db/tests/collection-indexes.test.ts, .changeset/index-optimization-partial-and-or.md
Adds tests for strict date gt, mixed indexed/non-indexed boolean semantics, overlap/strictest-bound resolution, and documents the behavioral fixes in the changeset.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

A rabbit hops through indexed trees,
Ensuring bounds and branches meet—
When indexes blur the path ahead,
I recheck every springing tread.
Exactness keeps the rows in line; hop on! 🐰✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: enforcing all where conditions when index optimization is partial, which directly addresses the core issue addressed in the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description comprehensively covers objectives, implementation details, and testing status with structured sections for summary, commits, and testing outcomes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new

pkg-pr-new Bot commented Jun 10, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1582

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1582

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1582

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1582

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1582

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1582

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1582

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1582

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1582

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1582

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1582

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1582

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1582

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1582

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1582

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1582

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1582

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1582

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1582

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1582

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1582

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1582

commit: 559a10d

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/db/src/utils/index-optimization.ts (1)

281-305: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Preserve the stricter bound when two predicates use the same value.

If gte(field, 5) is seen before gt(field, 5) (or lte before lt), the later strict predicate is ignored because these branches only update on value > from / value < to. This path then returns isExact: true and marks both args as covered, so boundary rows like field === 5 can be returned even though the full AND should exclude them.

Suggested fix
         for (const { operation, value } of operations) {
           switch (operation) {
             case `gt`:
-              if (from === undefined || value > from) {
+              if (
+                from === undefined ||
+                value > from ||
+                (value === from && fromInclusive)
+              ) {
                 from = value
                 fromInclusive = false
               }
               break
             case `gte`:
@@
             case `lt`:
-              if (to === undefined || value < to) {
+              if (
+                to === undefined ||
+                value < to ||
+                (value === to && toInclusive)
+              ) {
                 to = value
                 toInclusive = false
               }
               break
             case `lte`:

Also applies to: 317-321

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/utils/index-optimization.ts` around lines 281 - 305, The loop
that computes numeric bounds (iterating over operations with variables
operation, value, from, fromInclusive, to, toInclusive, isExact) ignores
stricter predicates when the new predicate has the same numeric value (e.g., gte
then gt with same value), so modify the branch conditions to update when value >
current bound OR when value === current bound and the new operator is stricter
(for lower bounds prefer gt over gte => set fromInclusive=false when
value===from and operation==='gt'; for upper bounds prefer lt over lte => set
toInclusive=false when value===to and operation==='lt'). Apply the same change
in the identical block around the 317-321 region so equal-valued stricter
predicates overwrite inclusivity accordingly and preserve correct
isExact/covered results.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/utils/index-optimization.ts`:
- Around line 556-558: canOptimizeOrExpression is too permissive compared to
optimizeOrExpression because it only verifies canOptimizeExpression for each
disjunct without ensuring the referenced index actually supports the operation
(e.g., gt on an eq-only index). Update the canOptimize* helpers (notably
canOptimizeExpression, the simple-comparison and in helpers) so they check index
capability/support for the specific operator (range vs eq vs contains) the same
way optimizeOrExpression/optimizer would, or alternately change
canOptimizeOrExpression to call into the optimizer logic used by
optimizeOrExpression to determine OR eligibility; ensure symbols to update
include canOptimizeOrExpression, canOptimizeExpression, simple-comparison
helper, in helper, and keep behavior consistent with optimizeOrExpression for
operators like gt(ref,val).

---

Outside diff comments:
In `@packages/db/src/utils/index-optimization.ts`:
- Around line 281-305: The loop that computes numeric bounds (iterating over
operations with variables operation, value, from, fromInclusive, to,
toInclusive, isExact) ignores stricter predicates when the new predicate has the
same numeric value (e.g., gte then gt with same value), so modify the branch
conditions to update when value > current bound OR when value === current bound
and the new operator is stricter (for lower bounds prefer gt over gte => set
fromInclusive=false when value===from and operation==='gt'; for upper bounds
prefer lt over lte => set toInclusive=false when value===to and
operation==='lt'). Apply the same change in the identical block around the
317-321 region so equal-valued stricter predicates overwrite inclusivity
accordingly and preserve correct isExact/covered results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6458b40d-a773-4d42-b681-9b2d884e70cd

📥 Commits

Reviewing files that changed from the base of the PR and between 4d1abde and 4902108.

📒 Files selected for processing (4)
  • .changeset/index-optimization-partial-and-or.md
  • packages/db/src/collection/change-events.ts
  • packages/db/src/utils/index-optimization.ts
  • packages/db/tests/collection-indexes.test.ts

Comment thread packages/db/src/utils/index-optimization.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
.changeset/index-optimization-partial-and-or.md (1)

10-12: 💤 Low value

Optional style improvement: vary sentence beginnings.

Three successive sentences begin with "Compound range conditions." Consider rephrasing one or two for readability, e.g., "When conditions share the same boundary value..." or "One-sided compound ranges...".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/index-optimization-partial-and-or.md around lines 10 - 12, The
three bullet lines all start with "Compound range conditions," which is
repetitive; update one or two bullets to vary sentence openings for
readability—for example change the first to "When conditions share the same
boundary value..." and the second to "One-sided compound ranges..." while
keeping the technical meaning and examples intact; edit the three bullet items
shown (the lines about shared boundary value, one-sided compound range, and
strict range comparisons) to use the revised phrasing but preserve terminology
like "age >= 5 AND age > 5", "age > 5 AND age >= 8", and "gt/lt on BTree-indexed
fields" so the content remains precise.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.changeset/index-optimization-partial-and-or.md:
- Around line 10-12: The three bullet lines all start with "Compound range
conditions," which is repetitive; update one or two bullets to vary sentence
openings for readability—for example change the first to "When conditions share
the same boundary value..." and the second to "One-sided compound ranges..."
while keeping the technical meaning and examples intact; edit the three bullet
items shown (the lines about shared boundary value, one-sided compound range,
and strict range comparisons) to use the revised phrasing but preserve
terminology like "age >= 5 AND age > 5", "age > 5 AND age >= 8", and "gt/lt on
BTree-indexed fields" so the content remains precise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fd18e5dc-5e7f-4733-b5e7-0b5b285ec3f7

📥 Commits

Reviewing files that changed from the base of the PR and between 4902108 and 11be9da.

📒 Files selected for processing (4)
  • .changeset/index-optimization-partial-and-or.md
  • packages/db/src/indexes/btree-index.ts
  • packages/db/src/utils/index-optimization.ts
  • packages/db/tests/collection-indexes.test.ts

kevin-dp and others added 2 commits June 10, 2026 14:31
OR expressions now require every disjunct to be index-optimizable;
otherwise the query falls back to a full scan, since rows matched only
by a non-optimizable disjunct cannot be recovered from index lookups.

AND expressions keep partial index optimization but the optimizer now
reports whether the matching keys are exact. When they are a superset
(some conjuncts could not use an index, or a compound range was
combined with other conditions), currentStateAsChanges re-checks each
candidate row against the full where expression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…range edge cases

Compound range conditions sharing a boundary value (gte(x,5) AND
gt(x,5)) now keep the strict bound regardless of argument order. Bound
values are compared with the same comparator the indexes use so dates
and locale strings behave correctly.

Two further issues surfaced by the regression tests:

- One-sided compound ranges passed an explicit undefined bound to
  rangeQuery, which treats present-but-undefined as the undefined
  sentinel and returned an empty result. Bounds are now only passed
  when they exist.

- BTreeIndex's exclusive lower bound check compared the normalized
  indexed value against the raw query value, so gt on date fields
  included the boundary row. It now compares against the normalized
  key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kevin-dp kevin-dp force-pushed the fix/index-optimization-partial-and-or branch from 11be9da to 559a10d Compare June 10, 2026 12:32
@kevin-dp kevin-dp changed the base branch from main to test/showcase-index-optimization-or-and-bugs June 10, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant