Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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-boundary-expansion-multi-orderby.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/db": patch
---

fix(db): expand boundary items for multi-column orderBy in loadNextItems
20 changes: 20 additions & 0 deletions packages/db/src/collection/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,26 @@ export class CollectionSubscription
keys = index.take(valuesNeeded(), biggestObservedValue!, filterFn)
}

// Boundary expansion for multi-column orderBy:
// BTree orders by (first_col, _id) only. Items sharing the same first-column
// value as biggestObservedValue but not selected by BTree may rank higher by
// the full comparator. Send all of them so D2 picks the correct top-K.
if (orderBy.length > 1 && biggestObservedValue !== undefined && valueExtractor) {
const alreadyAddedKeys = new Set(changes.map((c) => c.key))
const boundaryChanges = this.collection.currentStateAsChanges({
where: eq(orderByExpression, new Value(biggestObservedValue)),
})
if (boundaryChanges) {
for (const { key, value } of boundaryChanges) {
if (!alreadyAddedKeys.has(key) && !this.sentKeys.has(key)) {
if (value !== undefined && (whereFilterFn?.(value) ?? true)) {
changes.push({ type: `insert`, key, value })
}
}
}
}
}

// Track row count for offset-based pagination (before sending to callback)
// Use the current count as the offset for this load
const currentOffset = this.limitedSnapshotRowCount
Expand Down
154 changes: 154 additions & 0 deletions packages/db/tests/boundary-expansion-multi-orderby.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest'
import { createCollection } from '../src/collection/index.js'
import { createLiveQueryCollection, eq } from '../src/query/index.js'
import { flushPromises, mockSyncCollectionOptions } from './utils.js'

type Item = {
id: string
category: string
name: string
}

describe(`Boundary expansion for multi-column orderBy`, () => {
it(`should include all boundary items when paginating with multi-column orderBy`, async () => {
// Items with same category (first orderBy col) but different names (second orderBy col).
// BTree indexes by (category, _id) only. When paginating, items sharing the
// boundary category but with different _id ordering may be missed by BTree's take().
// The boundary expansion step should send them so D2 picks the correct top-K.
const initialData: Array<Item> = [
{ id: `a1`, category: `A`, name: `Zeta` },
{ id: `a2`, category: `A`, name: `Alpha` },
{ id: `a3`, category: `A`, name: `Beta` },
{ id: `b1`, category: `B`, name: `Gamma` },
{ id: `b2`, category: `B`, name: `Delta` },
{ id: `c1`, category: `C`, name: `Epsilon` },
]

const sourceCollection = createCollection(
mockSyncCollectionOptions({
id: `boundary-multi-orderby-source`,
getKey: (item: Item) => item.id,
initialData,
autoIndex: `eager`,
}),
)

await sourceCollection.preload()

const liveQuery = createLiveQueryCollection((q) =>
q
.from({ items: sourceCollection })
.orderBy(({ items }) => items.category, `asc`)
.orderBy(({ items }) => items.name, `asc`)
.limit(4)
.select(({ items }) => ({
id: items.id,
category: items.category,
name: items.name,
})),
)

await liveQuery.preload()
await flushPromises()

const results = Array.from(liveQuery.values())

// Full multi-column sort (category asc, name asc):
// A-Alpha (a2), A-Beta (a3), A-Zeta (a1), B-Delta (b2), B-Gamma (b1), C-Epsilon (c1)
// Top 4 should be: A-Alpha, A-Beta, A-Zeta, B-Delta
expect(results).toHaveLength(4)
expect(results.map((r) => r.id)).toEqual([`a2`, `a3`, `a1`, `b2`])
})

it(`should return correct results when all items share the same first orderBy value`, async () => {
const initialData: Array<Item> = [
{ id: `x3`, category: `X`, name: `Cherry` },
{ id: `x1`, category: `X`, name: `Apple` },
{ id: `x2`, category: `X`, name: `Banana` },
{ id: `x4`, category: `X`, name: `Date` },
{ id: `x5`, category: `X`, name: `Elderberry` },
]

const sourceCollection = createCollection(
mockSyncCollectionOptions({
id: `boundary-same-first-col`,
getKey: (item: Item) => item.id,
initialData,
autoIndex: `eager`,
}),
)

await sourceCollection.preload()

const liveQuery = createLiveQueryCollection((q) =>
q
.from({ items: sourceCollection })
.orderBy(({ items }) => items.category, `asc`)
.orderBy(({ items }) => items.name, `asc`)
.limit(3)
.select(({ items }) => ({
id: items.id,
category: items.category,
name: items.name,
})),
)

await liveQuery.preload()
await flushPromises()

const results = Array.from(liveQuery.values())

// All share category X, sorted by name: Apple (x1), Banana (x2), Cherry (x3), Date (x4), Elderberry (x5)
// Top 3: Apple, Banana, Cherry
expect(results).toHaveLength(3)
expect(results.map((r) => r.id)).toEqual([`x1`, `x2`, `x3`])
})

it(`should handle multi-column orderBy with where filter correctly`, async () => {
const initialData: Array<Item & { active: boolean }> = [
{ id: `a1`, category: `A`, name: `Zeta`, active: true },
{ id: `a2`, category: `A`, name: `Alpha`, active: false },
{ id: `a3`, category: `A`, name: `Beta`, active: true },
{ id: `b1`, category: `B`, name: `Gamma`, active: true },
{ id: `b2`, category: `B`, name: `Delta`, active: true },
{ id: `c1`, category: `C`, name: `Epsilon`, active: true },
]

const sourceCollection = createCollection(
mockSyncCollectionOptions({
id: `boundary-multi-orderby-where`,
getKey: (item: (typeof initialData)[0]) => item.id,
initialData,
autoIndex: `eager`,
}),
)

await sourceCollection.preload()

const liveQuery = createLiveQueryCollection((q) =>
q
.from({ items: sourceCollection })
.where(({ items }) => eq(items.active, true))
.orderBy(({ items }) => items.category, `asc`)
.orderBy(({ items }) => items.name, `asc`)
.limit(3)
.select(({ items }) => ({
id: items.id,
category: items.category,
name: items.name,
})),
)

await liveQuery.preload()
await flushPromises()

const results = Array.from(liveQuery.values())

// Active items sorted by (category asc, name asc):
// A-Beta (a3), A-Zeta (a1), B-Delta (b2), B-Gamma (b1), C-Epsilon (c1)
// (a2 Alpha is inactive, filtered out)
// Top 3: A-Beta, A-Zeta, B-Delta
expect(results).toHaveLength(3)
expect(results.map((r) => r.id)).toEqual([`a3`, `a1`, `b2`])
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.