Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-btree-multi-column-comparator.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion packages/db/src/query/compiler/order-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand Down
93 changes: 93 additions & 0 deletions packages/db/tests/deterministic-ordering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Msg, string> = 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`, () => {
Expand Down
Loading