From d3a2b647175d40fc201c5f09a47e0d60d202f95c Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 23 Mar 2026 14:08:33 +0100 Subject: [PATCH 1/6] fix: use single-column comparator for BTree index in multi-column orderBy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a query has multiple orderBy columns (e.g. `.orderBy(createdAt, 'desc').orderBy(id, 'desc')`), the order-by compiler creates a multi-column comparator that expects array values. Previously, `ensureIndexForField` received this multi-column comparator to create a single-column BTree index on the first field. The BTree stored individual field values (numbers), but the multi-column comparator treated them as arrays — indexing into a number returns `undefined`, so all values compared as NaN/equal. This collapsed the entire BTree to a single entry, causing `takeFromStart()` to return at most 1 key. Any live query subscription created after data was already in the collection would see 0 results. The fix passes `makeComparator(compareOpts)` — a proper single-column comparator built from the first orderBy column's compare options — instead of the multi-column `compare` function. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/db/src/query/compiler/order-by.ts | 9 +- .../btree-index-wrong-comparator.test.ts | 139 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/db/tests/btree-index-wrong-comparator.test.ts diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index 6ed5f958c..0d07a1a41 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -158,12 +158,19 @@ export function processOrderBy( ) if (fieldName) { + // Use a single-column comparator for the index, not the + // multi-column `compare` function. The multi-column comparator + // expects array values [col1, col2, ...] but the index stores + // individual field values. Passing `compare` here causes the + // BTree to treat all single values as equal (since number[0] + // === undefined for both sides of the comparison). + const firstColumnCompareFn = makeComparator(compareOpts) ensureIndexForField( fieldName, followRefResult.path, followRefCollection, compareOpts, - compare, + firstColumnCompareFn, ) } diff --git a/packages/db/tests/btree-index-wrong-comparator.test.ts b/packages/db/tests/btree-index-wrong-comparator.test.ts new file mode 100644 index 000000000..0466c22df --- /dev/null +++ b/packages/db/tests/btree-index-wrong-comparator.test.ts @@ -0,0 +1,139 @@ +/** + * Regression test for multi-column orderBy index comparator bug. + * + * When a query has multiple orderBy columns (e.g. `.orderBy(createdAt, 'desc').orderBy(id, 'desc')`), + * the order-by compiler builds a multi-column comparator that expects array values: + * compare([createdAt, id], [createdAt, id]) + * + * Previously, `ensureIndexForField` received this multi-column comparator to create a + * single-column BTree index on just the first field (e.g. `createdAt`). The BTree stored + * individual field values (numbers), but the comparator treated them as arrays — indexing + * into a number returns `undefined`, so all values compared as equal. This collapsed the + * BTree to a single entry, breaking `takeFromStart()` and causing live queries to return + * 0 results for pre-existing data. + * + * The fix: `ensureIndexForField` now receives `makeComparator(compareOpts)` — a proper + * single-column comparator built from the first orderBy column's compare options. + */ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { eq } from '../src/query/builder/functions.js' +import { BTreeIndex } from '../src/indexes/btree-index.js' +import { PropRef } from '../src/query/ir.js' +import { DEFAULT_COMPARE_OPTIONS, makeComparator } from '../src/utils/comparison.js' +import type { Collection } from '../src/collection/index.js' + +describe(`BTreeIndex multi-column comparator regression`, () => { + it(`multi-column comparator returns NaN for single values (documents the root cause)`, () => { + // Simulates the comparator that order-by.ts creates for multi-column orderBy + const compiledOrderBy = [ + { compareOptions: { ...DEFAULT_COMPARE_OPTIONS, direction: `desc` as const } }, + { compareOptions: { ...DEFAULT_COMPARE_OPTIONS, direction: `desc` as const } }, + ] + + const multiColumnCompare = (a: any, b: any): number => { + const arrayA = a as Array + const arrayB = b as Array + for (let i = 0; i < compiledOrderBy.length; i++) { + const clause = compiledOrderBy[i]! + const compareFn = makeComparator(clause.compareOptions) + const result = compareFn(arrayA[i], arrayB[i]) + if (result !== 0) return result + } + return (arrayA as any).length - (arrayB as any).length + } + + // When called with single numbers (not arrays), number[i] is undefined. + // Comparing undefined vs undefined yields 0, then the length subtraction + // (number.length is undefined) produces NaN. + const result = multiColumnCompare(1735689639000, 1735689638000) + expect(result).toBeNaN() + }) + + it(`BTreeIndex with single-column comparator works correctly`, () => { + // This is what ensureIndexForField should pass after the fix + const singleColumnCompare = makeComparator({ + ...DEFAULT_COMPARE_OPTIONS, + direction: `desc`, + }) + + const index = new BTreeIndex( + 1, + new PropRef([`createdAt`]), + `test-createdAt`, + { compareFn: singleColumnCompare }, + ) + + for (let i = 0; i < 26; i++) { + index.add(`item-${i}` as any, { createdAt: 1735689600000 + i * 1000 }) + } + + expect(index.keyCount).toBe(26) + expect(index.takeFromStart(30, () => true).length).toBe(26) + }) + + it(`live query with multi-column orderBy sees all items after thread switch`, async () => { + interface Msg { + id: string + threadId: string + createdAt: number + } + + let beginFn: () => void + let writeFn: (msg: { type: string; value: Msg }) => void + let commitFn: () => void + + const collection: Collection = createCollection({ + id: `messages`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + beginFn = begin + writeFn = write as any + commitFn = commit + begin() + commit() + markReady() + }, + }, + }) + + await collection.stateWhenReady() + + const thread1 = Array.from({ length: 26 }, (_, i) => ({ + id: `t1-${i}`, + threadId: `t1`, + createdAt: 1735689600000 + i * 1000, + })) + const thread2 = Array.from({ length: 6 }, (_, i) => ({ + id: `t2-${i}`, + threadId: `t2`, + createdAt: 1735689700000 + i * 1000, + })) + + // Insert both threads + beginFn!() + for (const msg of [...thread1, ...thread2]) { + writeFn!({ type: `insert`, value: msg }) + } + commitFn!() + expect(collection.size).toBe(32) + + // Create live query with multi-column orderBy + limit (like useLiveInfiniteQuery does) + const liveQuery = createLiveQueryCollection({ + query: (q: any) => + q + .from({ msg: collection }) + .where(({ msg }: any) => eq(msg.threadId, `t2`)) + .orderBy(({ msg }: any) => msg.createdAt, `desc`) + .orderBy(({ msg }: any) => msg.id, `desc`) + .limit(30), + }) + + await liveQuery.preload() + const results = Array.from(liveQuery) + expect(results.length).toBe(6) + }) +}) From b7655af3f59cdb5b518de078cd98bca7e7f67c67 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:10:09 +0000 Subject: [PATCH 2/6] ci: apply automated fixes --- .../btree-index-wrong-comparator.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/db/tests/btree-index-wrong-comparator.test.ts b/packages/db/tests/btree-index-wrong-comparator.test.ts index 0466c22df..05570023a 100644 --- a/packages/db/tests/btree-index-wrong-comparator.test.ts +++ b/packages/db/tests/btree-index-wrong-comparator.test.ts @@ -21,15 +21,28 @@ import { createLiveQueryCollection } from '../src/query/live-query-collection.js import { eq } from '../src/query/builder/functions.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import { PropRef } from '../src/query/ir.js' -import { DEFAULT_COMPARE_OPTIONS, makeComparator } from '../src/utils/comparison.js' +import { + DEFAULT_COMPARE_OPTIONS, + makeComparator, +} from '../src/utils/comparison.js' import type { Collection } from '../src/collection/index.js' describe(`BTreeIndex multi-column comparator regression`, () => { it(`multi-column comparator returns NaN for single values (documents the root cause)`, () => { // Simulates the comparator that order-by.ts creates for multi-column orderBy const compiledOrderBy = [ - { compareOptions: { ...DEFAULT_COMPARE_OPTIONS, direction: `desc` as const } }, - { compareOptions: { ...DEFAULT_COMPARE_OPTIONS, direction: `desc` as const } }, + { + compareOptions: { + ...DEFAULT_COMPARE_OPTIONS, + direction: `desc` as const, + }, + }, + { + compareOptions: { + ...DEFAULT_COMPARE_OPTIONS, + direction: `desc` as const, + }, + }, ] const multiColumnCompare = (a: any, b: any): number => { From d70aed49339a4351a8ea2b36f562e9a21c306c06 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 23 Mar 2026 14:16:49 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20type=20error=20in=20test=20=E2=80=94?= =?UTF-8?q?=20import=20DEFAULT=5FCOMPARE=5FOPTIONS=20from=20utils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/db/tests/btree-index-wrong-comparator.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/db/tests/btree-index-wrong-comparator.test.ts b/packages/db/tests/btree-index-wrong-comparator.test.ts index 05570023a..3d21554a0 100644 --- a/packages/db/tests/btree-index-wrong-comparator.test.ts +++ b/packages/db/tests/btree-index-wrong-comparator.test.ts @@ -21,10 +21,8 @@ import { createLiveQueryCollection } from '../src/query/live-query-collection.js import { eq } from '../src/query/builder/functions.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import { PropRef } from '../src/query/ir.js' -import { - DEFAULT_COMPARE_OPTIONS, - makeComparator, -} from '../src/utils/comparison.js' +import { makeComparator } from '../src/utils/comparison.js' +import { DEFAULT_COMPARE_OPTIONS } from '../src/utils.js' import type { Collection } from '../src/collection/index.js' describe(`BTreeIndex multi-column comparator regression`, () => { From 37449c68a46dae0acb435aec8c93d8c3898e7ca9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 23 Mar 2026 14:31:42 +0100 Subject: [PATCH 4/6] test: move index tests to deterministic-ordering.test.ts Move the single-column comparator and multi-column orderBy tests into the existing BTreeIndex describe block in deterministic-ordering.test.ts rather than keeping them in a separate file. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../btree-index-wrong-comparator.test.ts | 150 ------------------ .../db/tests/deterministic-ordering.test.ts | 94 +++++++++++ 2 files changed, 94 insertions(+), 150 deletions(-) delete mode 100644 packages/db/tests/btree-index-wrong-comparator.test.ts diff --git a/packages/db/tests/btree-index-wrong-comparator.test.ts b/packages/db/tests/btree-index-wrong-comparator.test.ts deleted file mode 100644 index 3d21554a0..000000000 --- a/packages/db/tests/btree-index-wrong-comparator.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Regression test for multi-column orderBy index comparator bug. - * - * When a query has multiple orderBy columns (e.g. `.orderBy(createdAt, 'desc').orderBy(id, 'desc')`), - * the order-by compiler builds a multi-column comparator that expects array values: - * compare([createdAt, id], [createdAt, id]) - * - * Previously, `ensureIndexForField` received this multi-column comparator to create a - * single-column BTree index on just the first field (e.g. `createdAt`). The BTree stored - * individual field values (numbers), but the comparator treated them as arrays — indexing - * into a number returns `undefined`, so all values compared as equal. This collapsed the - * BTree to a single entry, breaking `takeFromStart()` and causing live queries to return - * 0 results for pre-existing data. - * - * The fix: `ensureIndexForField` now receives `makeComparator(compareOpts)` — a proper - * single-column comparator built from the first orderBy column's compare options. - */ -import { describe, expect, it } from 'vitest' -import { createCollection } from '../src/collection/index.js' -import { createLiveQueryCollection } from '../src/query/live-query-collection.js' -import { eq } from '../src/query/builder/functions.js' -import { BTreeIndex } from '../src/indexes/btree-index.js' -import { PropRef } from '../src/query/ir.js' -import { makeComparator } from '../src/utils/comparison.js' -import { DEFAULT_COMPARE_OPTIONS } from '../src/utils.js' -import type { Collection } from '../src/collection/index.js' - -describe(`BTreeIndex multi-column comparator regression`, () => { - it(`multi-column comparator returns NaN for single values (documents the root cause)`, () => { - // Simulates the comparator that order-by.ts creates for multi-column orderBy - const compiledOrderBy = [ - { - compareOptions: { - ...DEFAULT_COMPARE_OPTIONS, - direction: `desc` as const, - }, - }, - { - compareOptions: { - ...DEFAULT_COMPARE_OPTIONS, - direction: `desc` as const, - }, - }, - ] - - const multiColumnCompare = (a: any, b: any): number => { - const arrayA = a as Array - const arrayB = b as Array - for (let i = 0; i < compiledOrderBy.length; i++) { - const clause = compiledOrderBy[i]! - const compareFn = makeComparator(clause.compareOptions) - const result = compareFn(arrayA[i], arrayB[i]) - if (result !== 0) return result - } - return (arrayA as any).length - (arrayB as any).length - } - - // When called with single numbers (not arrays), number[i] is undefined. - // Comparing undefined vs undefined yields 0, then the length subtraction - // (number.length is undefined) produces NaN. - const result = multiColumnCompare(1735689639000, 1735689638000) - expect(result).toBeNaN() - }) - - it(`BTreeIndex with single-column comparator works correctly`, () => { - // This is what ensureIndexForField should pass after the fix - const singleColumnCompare = makeComparator({ - ...DEFAULT_COMPARE_OPTIONS, - direction: `desc`, - }) - - const index = new BTreeIndex( - 1, - new PropRef([`createdAt`]), - `test-createdAt`, - { compareFn: singleColumnCompare }, - ) - - for (let i = 0; i < 26; i++) { - index.add(`item-${i}` as any, { createdAt: 1735689600000 + i * 1000 }) - } - - expect(index.keyCount).toBe(26) - expect(index.takeFromStart(30, () => true).length).toBe(26) - }) - - it(`live query with multi-column orderBy sees all items after thread switch`, async () => { - interface Msg { - id: string - threadId: string - createdAt: number - } - - let beginFn: () => void - let writeFn: (msg: { type: string; value: Msg }) => void - let commitFn: () => void - - const collection: Collection = createCollection({ - id: `messages`, - getKey: (item) => item.id, - startSync: true, - sync: { - sync: ({ begin, write, commit, markReady }) => { - beginFn = begin - writeFn = write as any - commitFn = commit - begin() - commit() - markReady() - }, - }, - }) - - await collection.stateWhenReady() - - const thread1 = Array.from({ length: 26 }, (_, i) => ({ - id: `t1-${i}`, - threadId: `t1`, - createdAt: 1735689600000 + i * 1000, - })) - const thread2 = Array.from({ length: 6 }, (_, i) => ({ - id: `t2-${i}`, - threadId: `t2`, - createdAt: 1735689700000 + i * 1000, - })) - - // Insert both threads - beginFn!() - for (const msg of [...thread1, ...thread2]) { - writeFn!({ type: `insert`, value: msg }) - } - commitFn!() - expect(collection.size).toBe(32) - - // Create live query with multi-column orderBy + limit (like useLiveInfiniteQuery does) - const liveQuery = createLiveQueryCollection({ - query: (q: any) => - q - .from({ msg: collection }) - .where(({ msg }: any) => eq(msg.threadId, `t2`)) - .orderBy(({ msg }: any) => msg.createdAt, `desc`) - .orderBy(({ msg }: any) => msg.id, `desc`) - .limit(30), - }) - - await liveQuery.preload() - const results = Array.from(liveQuery) - expect(results.length).toBe(6) - }) -}) diff --git a/packages/db/tests/deterministic-ordering.test.ts b/packages/db/tests/deterministic-ordering.test.ts index d353b4d87..c0ce29ce5 100644 --- a/packages/db/tests/deterministic-ordering.test.ts +++ b/packages/db/tests/deterministic-ordering.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest' import { SortedMap } from '../src/SortedMap' import { BTreeIndex } from '../src/indexes/btree-index' import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { eq } from '../src/query/builder/functions.js' import { PropRef } from '../src/query/ir' +import { makeComparator } from '../src/utils/comparison.js' +import { DEFAULT_COMPARE_OPTIONS } from '../src/utils.js' import { mockSyncCollectionOptions } from './utils' +import type { Collection } from '../src/collection/index.js' /** * These tests verify deterministic ordering behavior when values compare as equal. @@ -216,6 +221,95 @@ describe(`Deterministic Ordering`, () => { const secondBatch = index.take(3, 1) expect(secondBatch).toEqual([`d`, `e`, `f`]) }) + + it(`should use single-column comparator correctly with desc direction`, () => { + const singleColumnCompare = makeComparator({ + ...DEFAULT_COMPARE_OPTIONS, + direction: `desc`, + }) + + const index = new BTreeIndex( + 1, + new PropRef([`createdAt`]), + `createdAt_desc`, + { compareFn: singleColumnCompare }, + ) + + for (let i = 0; i < 26; i++) { + index.add(`item-${i}` as any, { + createdAt: 1735689600000 + i * 1000, + }) + } + + expect(index.keyCount).toBe(26) + expect(index.takeFromStart(30, () => true).length).toBe(26) + }) + + it(`should correctly index all items when using a multi-column orderBy query`, async () => { + interface Msg { + id: string + threadId: string + createdAt: number + } + + let beginFn: () => void + let writeFn: (msg: { type: string; value: Msg }) => void + let commitFn: () => void + + const collection: Collection = createCollection< + Msg, + string + >({ + id: `multi-col-orderby-messages`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + beginFn = begin + writeFn = write as any + commitFn = commit + begin() + commit() + markReady() + }, + }, + }) + + await collection.stateWhenReady() + + const thread1 = Array.from({ length: 26 }, (_, i) => ({ + id: `t1-${i}`, + threadId: `t1`, + createdAt: 1735689600000 + i * 1000, + })) + const thread2 = Array.from({ length: 6 }, (_, i) => ({ + id: `t2-${i}`, + threadId: `t2`, + createdAt: 1735689700000 + i * 1000, + })) + + beginFn!() + for (const msg of [...thread1, ...thread2]) { + writeFn!({ type: `insert`, value: msg }) + } + commitFn!() + expect(collection.size).toBe(32) + + // Multi-column orderBy with where and limit + const liveQuery = createLiveQueryCollection({ + query: (q: any) => + q + .from({ msg: collection }) + .where(({ msg }: any) => eq(msg.threadId, `t2`)) + .orderBy(({ msg }: any) => msg.createdAt, `desc`) + .orderBy(({ msg }: any) => msg.id, `desc`) + .limit(30), + }) + + await liveQuery.preload() + const results = Array.from(liveQuery) + expect(results.length).toBe(6) + }) }) describe(`Collection iteration`, () => { From 007e48efaa0778e677806874b9d5107581028b1a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:32:50 +0000 Subject: [PATCH 5/6] ci: apply automated fixes --- .../db/tests/deterministic-ordering.test.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/db/tests/deterministic-ordering.test.ts b/packages/db/tests/deterministic-ordering.test.ts index c0ce29ce5..8011e339e 100644 --- a/packages/db/tests/deterministic-ordering.test.ts +++ b/packages/db/tests/deterministic-ordering.test.ts @@ -256,24 +256,23 @@ describe(`Deterministic Ordering`, () => { let writeFn: (msg: { type: string; value: Msg }) => void let commitFn: () => void - const collection: Collection = createCollection< - Msg, - string - >({ - id: `multi-col-orderby-messages`, - getKey: (item) => item.id, - startSync: true, - sync: { - sync: ({ begin, write, commit, markReady }) => { - beginFn = begin - writeFn = write as any - commitFn = commit - begin() - commit() - markReady() + const collection: Collection = createCollection( + { + id: `multi-col-orderby-messages`, + getKey: (item) => item.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + beginFn = begin + writeFn = write as any + commitFn = commit + begin() + commit() + markReady() + }, }, }, - }) + ) await collection.stateWhenReady() From 75eea156747c6a5d893bc17c3a7ecf13cecdb901 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 24 Mar 2026 09:43:38 +0100 Subject: [PATCH 6/6] chore: add changeset for BTree index comparator fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-btree-multi-column-comparator.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-btree-multi-column-comparator.md diff --git a/.changeset/fix-btree-multi-column-comparator.md b/.changeset/fix-btree-multi-column-comparator.md new file mode 100644 index 000000000..ff6a61c31 --- /dev/null +++ b/.changeset/fix-btree-multi-column-comparator.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix BTree index receiving the wrong comparator when a query uses multiple `orderBy` columns. The multi-column array comparator was passed to `ensureIndexForField` to create a single-column index, causing the BTree to treat all indexed values as equal. This collapsed the index to a single entry, making `takeFromStart()` return at most 1 key and breaking live query subscriptions that relied on the index for pagination (e.g. `useLiveInfiniteQuery` with `.orderBy(col1).orderBy(col2).limit(n)`). The fix passes a proper single-column comparator built from the first `orderBy` column's compare options.