Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/fix-orderby-limit-no-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/db': patch
---

fix: orderBy + limit queries crash when no index exists

When auto-indexing is disabled (the default), queries with `orderBy` and `limit` where the limit exceeds the available data would crash with "Ordered snapshot was requested but no index was found". The on-demand loader now correctly skips cursor-based loading when no index is available.
8 changes: 8 additions & 0 deletions packages/db/src/query/compiler/joins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ function processJoin(

if (!loaded) {
// Snapshot wasn't sent because it could not be loaded from the indexes
const collectionId = followRefCollection.id
const fieldPath = followRefResult.path.join(`.`)
console.warn(
`[TanStack DB]${collectionId ? ` [${collectionId}]` : ``} Join requires an index on "${fieldPath}" for efficient loading. ` +
`Falling back to loading all data. ` +
`Consider creating an index on the collection with collection.createIndex((row) => row.${fieldPath}) ` +
`or enable auto-indexing with autoIndex: 'eager' and a defaultIndexType.`,
)
lazySourceSubscription.requestSnapshot()
}
}),
Expand Down
27 changes: 21 additions & 6 deletions packages/db/src/query/compiler/order-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,17 @@ export function processOrderBy(
index = undefined
}

if (!index) {
const collectionId = followRefCollection.id
const fieldPath = followRefResult.path.join(`.`)
console.warn(
`[TanStack DB]${collectionId ? ` [${collectionId}]` : ``} orderBy with limit requires an index on "${fieldPath}" for efficient lazy loading. ` +
`Falling back to loading all data. ` +
`Consider creating an index on the collection with collection.createIndex((row) => row.${fieldPath}) ` +
`or enable auto-indexing with autoIndex: 'eager' and a defaultIndexType.`,
)
}

orderByAlias =
firstOrderByExpression.path.length > 1
? String(firstOrderByExpression.path[0])
Expand Down Expand Up @@ -292,12 +303,16 @@ export function processOrderBy(

// Set up lazy loading callback to track how much more data is needed
// This is used by loadMoreIfNeeded to determine if more data should be loaded
setSizeCallback = (getSize: () => number) => {
optimizableOrderByCollections[targetCollectionId]![`dataNeeded`] =
() => {
const size = getSize()
return Math.max(0, orderByOptimizationInfo!.limit - size)
}
// Only enable when an index exists — without an index, lazy loading can't work
// and all data is loaded eagerly via requestSnapshot instead.
if (index) {
setSizeCallback = (getSize: () => number) => {
optimizableOrderByCollections[targetCollectionId]![`dataNeeded`] =
() => {
const size = getSize()
return Math.max(0, orderByOptimizationInfo!.limit - size)
}
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/query/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> {
for (const [, orderByInfo] of Object.entries(
this.optimizableOrderByCollections,
)) {
if (!orderByInfo.dataNeeded) continue
if (!orderByInfo.dataNeeded || !orderByInfo.index) continue

if (this.pendingOrderedLoadPromise) {
// Wait for in-flight loads to complete before requesting more
Expand Down
10 changes: 5 additions & 5 deletions packages/db/src/query/live/collection-subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,12 @@ export class CollectionSubscriber<
return true
}

const { dataNeeded } = orderByInfo
const { dataNeeded, index } = orderByInfo

if (!dataNeeded) {
// dataNeeded is not set when there's no index (e.g., non-ref expression).
// In this case, we've already loaded all data via requestSnapshot
// and don't need to lazily load more.
if (!dataNeeded || !index) {
// dataNeeded is not set when there's no index (e.g., non-ref expression
// or auto-indexing is disabled). Without an index, lazy loading can't work —
// all data was already loaded eagerly via requestSnapshot.
return true
}

Expand Down
59 changes: 59 additions & 0 deletions packages/db/tests/query/order-by.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,65 @@ function createOrderByTests(autoIndex: `off` | `eager`): void {
])
})

it(`works with orderBy + limit when limit exceeds available data and no index exists`, async () => {
// When limit > number of rows, the topK operator is not full after
// the initial snapshot. The on-demand loader must not attempt
// cursor-based loading (requestLimitedSnapshot) when there is no index.
const collection = createLiveQueryCollection((q) =>
q
.from({ employees: employeesCollection })
.orderBy(({ employees }) => employees.salary, `desc`)
.limit(20) // Much larger than the 5 employees
.select(({ employees }) => ({
id: employees.id,
name: employees.name,
salary: employees.salary,
})),
)
await collection.preload()

const results = Array.from(collection.values())
expect(results).toHaveLength(5)
expect(results.map((r) => r.salary)).toEqual([
65_000, 60_000, 55_000, 52_000, 50_000,
])
})

it(`handles delete from topK when limit exceeds available data and no index exists`, async () => {
// After a delete, the topK becomes even less full. The on-demand loader
// must gracefully handle this without attempting cursor-based loading.
const collection = createLiveQueryCollection((q) =>
q
.from({ employees: employeesCollection })
.orderBy(({ employees }) => employees.salary, `desc`)
.limit(20)
.select(({ employees }) => ({
id: employees.id,
name: employees.name,
salary: employees.salary,
})),
)
await collection.preload()

const results = Array.from(collection.values())
expect(results).toHaveLength(5)

// Delete Diana (highest salary) — topK shrinks, triggering loadMoreIfNeeded
const dianaData = employeeData.find((e) => e.id === 4)!
employeesCollection.utils.begin()
employeesCollection.utils.write({
type: `delete`,
value: dianaData,
})
employeesCollection.utils.commit()

const newResults = Array.from(collection.values())
expect(newResults).toHaveLength(4)
expect(newResults.map((r) => r.salary)).toEqual([
60_000, 55_000, 52_000, 50_000,
])
})

itWhenAutoIndexEager(
`applies incremental insert of a new row inside the topK but after max sent value correctly`,
async () => {
Expand Down
Loading