Skip to content

Commit f60384b

Browse files
kevin-dpclaudeautofix-ci[bot]
authored
fix: use single-column comparator for BTree index in multi-column orderBy (#1401)
* fix: use single-column comparator for BTree index in multi-column orderBy 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) <[email protected]> * ci: apply automated fixes * fix: type error in test — import DEFAULT_COMPARE_OPTIONS from utils Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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) <[email protected]> * ci: apply automated fixes * chore: add changeset for BTree index comparator fix Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent a6eb289 commit f60384b

3 files changed

Lines changed: 106 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
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.

packages/db/src/query/compiler/order-by.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,19 @@ export function processOrderBy(
158158
)
159159

160160
if (fieldName) {
161+
// Use a single-column comparator for the index, not the
162+
// multi-column `compare` function. The multi-column comparator
163+
// expects array values [col1, col2, ...] but the index stores
164+
// individual field values. Passing `compare` here causes the
165+
// BTree to treat all single values as equal (since number[0]
166+
// === undefined for both sides of the comparison).
167+
const firstColumnCompareFn = makeComparator(compareOpts)
161168
ensureIndexForField(
162169
fieldName,
163170
followRefResult.path,
164171
followRefCollection,
165172
compareOpts,
166-
compare,
173+
firstColumnCompareFn,
167174
)
168175
}
169176

packages/db/tests/deterministic-ordering.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest'
22
import { SortedMap } from '../src/SortedMap'
33
import { BTreeIndex } from '../src/indexes/btree-index'
44
import { createCollection } from '../src/collection/index.js'
5+
import { createLiveQueryCollection } from '../src/query/live-query-collection.js'
6+
import { eq } from '../src/query/builder/functions.js'
57
import { PropRef } from '../src/query/ir'
8+
import { makeComparator } from '../src/utils/comparison.js'
9+
import { DEFAULT_COMPARE_OPTIONS } from '../src/utils.js'
610
import { mockSyncCollectionOptions } from './utils'
11+
import type { Collection } from '../src/collection/index.js'
712

813
/**
914
* These tests verify deterministic ordering behavior when values compare as equal.
@@ -216,6 +221,94 @@ describe(`Deterministic Ordering`, () => {
216221
const secondBatch = index.take(3, 1)
217222
expect(secondBatch).toEqual([`d`, `e`, `f`])
218223
})
224+
225+
it(`should use single-column comparator correctly with desc direction`, () => {
226+
const singleColumnCompare = makeComparator({
227+
...DEFAULT_COMPARE_OPTIONS,
228+
direction: `desc`,
229+
})
230+
231+
const index = new BTreeIndex(
232+
1,
233+
new PropRef([`createdAt`]),
234+
`createdAt_desc`,
235+
{ compareFn: singleColumnCompare },
236+
)
237+
238+
for (let i = 0; i < 26; i++) {
239+
index.add(`item-${i}` as any, {
240+
createdAt: 1735689600000 + i * 1000,
241+
})
242+
}
243+
244+
expect(index.keyCount).toBe(26)
245+
expect(index.takeFromStart(30, () => true).length).toBe(26)
246+
})
247+
248+
it(`should correctly index all items when using a multi-column orderBy query`, async () => {
249+
interface Msg {
250+
id: string
251+
threadId: string
252+
createdAt: number
253+
}
254+
255+
let beginFn: () => void
256+
let writeFn: (msg: { type: string; value: Msg }) => void
257+
let commitFn: () => void
258+
259+
const collection: Collection<Msg, string> = createCollection<Msg, string>(
260+
{
261+
id: `multi-col-orderby-messages`,
262+
getKey: (item) => item.id,
263+
startSync: true,
264+
sync: {
265+
sync: ({ begin, write, commit, markReady }) => {
266+
beginFn = begin
267+
writeFn = write as any
268+
commitFn = commit
269+
begin()
270+
commit()
271+
markReady()
272+
},
273+
},
274+
},
275+
)
276+
277+
await collection.stateWhenReady()
278+
279+
const thread1 = Array.from({ length: 26 }, (_, i) => ({
280+
id: `t1-${i}`,
281+
threadId: `t1`,
282+
createdAt: 1735689600000 + i * 1000,
283+
}))
284+
const thread2 = Array.from({ length: 6 }, (_, i) => ({
285+
id: `t2-${i}`,
286+
threadId: `t2`,
287+
createdAt: 1735689700000 + i * 1000,
288+
}))
289+
290+
beginFn!()
291+
for (const msg of [...thread1, ...thread2]) {
292+
writeFn!({ type: `insert`, value: msg })
293+
}
294+
commitFn!()
295+
expect(collection.size).toBe(32)
296+
297+
// Multi-column orderBy with where and limit
298+
const liveQuery = createLiveQueryCollection({
299+
query: (q: any) =>
300+
q
301+
.from({ msg: collection })
302+
.where(({ msg }: any) => eq(msg.threadId, `t2`))
303+
.orderBy(({ msg }: any) => msg.createdAt, `desc`)
304+
.orderBy(({ msg }: any) => msg.id, `desc`)
305+
.limit(30),
306+
})
307+
308+
await liveQuery.preload()
309+
const results = Array.from(liveQuery)
310+
expect(results.length).toBe(6)
311+
})
219312
})
220313

221314
describe(`Collection iteration`, () => {

0 commit comments

Comments
 (0)