Skip to content

Commit f4a9bd2

Browse files
kevin-dpclaude
andauthored
fix: skip on-demand loading when no index exists for orderBy + limit queries (#1437)
* Reproduce problem without index * fix: skip on-demand loading when no index exists for orderBy + limit queries When auto-indexing is disabled (the new default), queries with orderBy + limit would crash because loadMoreIfNeeded attempted cursor-based loading via requestLimitedSnapshot without an index. Now dataNeeded is only set when an index exists, and loadMoreIfNeeded also guards against missing indexes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add changeset for orderBy + limit no-index fix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: warn when orderBy+limit or lazy join falls back to full load due to missing index Adds console warnings when no index is found for: - orderBy + limit queries (order-by.ts) - lazy join loading (joins.ts) Both suggest creating an explicit index or enabling autoIndex: 'eager'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e8e029e commit f4a9bd2

File tree

6 files changed

+101
-12
lines changed

6 files changed

+101
-12
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
fix: orderBy + limit queries crash when no index exists
6+
7+
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.

packages/db/src/query/compiler/joins.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,14 @@ function processJoin(
324324

325325
if (!loaded) {
326326
// Snapshot wasn't sent because it could not be loaded from the indexes
327+
const collectionId = followRefCollection.id
328+
const fieldPath = followRefResult.path.join(`.`)
329+
console.warn(
330+
`[TanStack DB]${collectionId ? ` [${collectionId}]` : ``} Join requires an index on "${fieldPath}" for efficient loading. ` +
331+
`Falling back to loading all data. ` +
332+
`Consider creating an index on the collection with collection.createIndex((row) => row.${fieldPath}) ` +
333+
`or enable auto-indexing with autoIndex: 'eager' and a defaultIndexType.`,
334+
)
327335
lazySourceSubscription.requestSnapshot()
328336
}
329337
}),

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,17 @@ export function processOrderBy(
191191
index = undefined
192192
}
193193

194+
if (!index) {
195+
const collectionId = followRefCollection.id
196+
const fieldPath = followRefResult.path.join(`.`)
197+
console.warn(
198+
`[TanStack DB]${collectionId ? ` [${collectionId}]` : ``} orderBy with limit requires an index on "${fieldPath}" for efficient lazy loading. ` +
199+
`Falling back to loading all data. ` +
200+
`Consider creating an index on the collection with collection.createIndex((row) => row.${fieldPath}) ` +
201+
`or enable auto-indexing with autoIndex: 'eager' and a defaultIndexType.`,
202+
)
203+
}
204+
194205
orderByAlias =
195206
firstOrderByExpression.path.length > 1
196207
? String(firstOrderByExpression.path[0])
@@ -292,12 +303,16 @@ export function processOrderBy(
292303

293304
// Set up lazy loading callback to track how much more data is needed
294305
// This is used by loadMoreIfNeeded to determine if more data should be loaded
295-
setSizeCallback = (getSize: () => number) => {
296-
optimizableOrderByCollections[targetCollectionId]![`dataNeeded`] =
297-
() => {
298-
const size = getSize()
299-
return Math.max(0, orderByOptimizationInfo!.limit - size)
300-
}
306+
// Only enable when an index exists — without an index, lazy loading can't work
307+
// and all data is loaded eagerly via requestSnapshot instead.
308+
if (index) {
309+
setSizeCallback = (getSize: () => number) => {
310+
optimizableOrderByCollections[targetCollectionId]![`dataNeeded`] =
311+
() => {
312+
const size = getSize()
313+
return Math.max(0, orderByOptimizationInfo!.limit - size)
314+
}
315+
}
301316
}
302317
}
303318
}

packages/db/src/query/effect.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ class EffectPipelineRunner<TRow extends object, TKey extends string | number> {
883883
for (const [, orderByInfo] of Object.entries(
884884
this.optimizableOrderByCollections,
885885
)) {
886-
if (!orderByInfo.dataNeeded) continue
886+
if (!orderByInfo.dataNeeded || !orderByInfo.index) continue
887887

888888
if (this.pendingOrderedLoadPromise) {
889889
// Wait for in-flight loads to complete before requesting more

packages/db/src/query/live/collection-subscriber.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,12 @@ export class CollectionSubscriber<
332332
return true
333333
}
334334

335-
const { dataNeeded } = orderByInfo
335+
const { dataNeeded, index } = orderByInfo
336336

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

packages/db/tests/query/order-by.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,65 @@ function createOrderByTests(autoIndex: `off` | `eager`): void {
561561
])
562562
})
563563

564+
it(`works with orderBy + limit when limit exceeds available data and no index exists`, async () => {
565+
// When limit > number of rows, the topK operator is not full after
566+
// the initial snapshot. The on-demand loader must not attempt
567+
// cursor-based loading (requestLimitedSnapshot) when there is no index.
568+
const collection = createLiveQueryCollection((q) =>
569+
q
570+
.from({ employees: employeesCollection })
571+
.orderBy(({ employees }) => employees.salary, `desc`)
572+
.limit(20) // Much larger than the 5 employees
573+
.select(({ employees }) => ({
574+
id: employees.id,
575+
name: employees.name,
576+
salary: employees.salary,
577+
})),
578+
)
579+
await collection.preload()
580+
581+
const results = Array.from(collection.values())
582+
expect(results).toHaveLength(5)
583+
expect(results.map((r) => r.salary)).toEqual([
584+
65_000, 60_000, 55_000, 52_000, 50_000,
585+
])
586+
})
587+
588+
it(`handles delete from topK when limit exceeds available data and no index exists`, async () => {
589+
// After a delete, the topK becomes even less full. The on-demand loader
590+
// must gracefully handle this without attempting cursor-based loading.
591+
const collection = createLiveQueryCollection((q) =>
592+
q
593+
.from({ employees: employeesCollection })
594+
.orderBy(({ employees }) => employees.salary, `desc`)
595+
.limit(20)
596+
.select(({ employees }) => ({
597+
id: employees.id,
598+
name: employees.name,
599+
salary: employees.salary,
600+
})),
601+
)
602+
await collection.preload()
603+
604+
const results = Array.from(collection.values())
605+
expect(results).toHaveLength(5)
606+
607+
// Delete Diana (highest salary) — topK shrinks, triggering loadMoreIfNeeded
608+
const dianaData = employeeData.find((e) => e.id === 4)!
609+
employeesCollection.utils.begin()
610+
employeesCollection.utils.write({
611+
type: `delete`,
612+
value: dianaData,
613+
})
614+
employeesCollection.utils.commit()
615+
616+
const newResults = Array.from(collection.values())
617+
expect(newResults).toHaveLength(4)
618+
expect(newResults.map((r) => r.salary)).toEqual([
619+
60_000, 55_000, 52_000, 50_000,
620+
])
621+
})
622+
564623
itWhenAutoIndexEager(
565624
`applies incremental insert of a new row inside the topK but after max sent value correctly`,
566625
async () => {

0 commit comments

Comments
 (0)