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. 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/deterministic-ordering.test.ts b/packages/db/tests/deterministic-ordering.test.ts index d353b4d87..8011e339e 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,94 @@ 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( + { + 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`, () => {