From 38dc453c026a8b1c977f670abf0aaf0602f9c080 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 31 Mar 2026 15:41:57 +0200 Subject: [PATCH 1/4] Reproduce problem without index --- packages/db/tests/query/order-by.test.ts | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index 131c9d1ef..0b45fb351 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -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 () => { From 5c1860d9b49ae85125f164f4b96bd5b13a9acca2 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 31 Mar 2026 16:15:44 +0200 Subject: [PATCH 2/4] 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) --- packages/db/src/query/compiler/order-by.ts | 16 ++++++++++------ packages/db/src/query/effect.ts | 2 +- .../db/src/query/live/collection-subscriber.ts | 10 +++++----- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index ec8fa1839..80262ab9e 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -292,12 +292,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) + } + } } } } diff --git a/packages/db/src/query/effect.ts b/packages/db/src/query/effect.ts index 10857d06e..bcd957528 100644 --- a/packages/db/src/query/effect.ts +++ b/packages/db/src/query/effect.ts @@ -883,7 +883,7 @@ class EffectPipelineRunner { 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 diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 8eda5cc88..e83cb8885 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -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 } From eabc81ffe74b2b6a9d76287963c42607f65f6714 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 31 Mar 2026 16:22:06 +0200 Subject: [PATCH 3/4] chore: add changeset for orderBy + limit no-index fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-orderby-limit-no-index.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-orderby-limit-no-index.md diff --git a/.changeset/fix-orderby-limit-no-index.md b/.changeset/fix-orderby-limit-no-index.md new file mode 100644 index 000000000..f495b710c --- /dev/null +++ b/.changeset/fix-orderby-limit-no-index.md @@ -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. From e39c09919869b29e2579ea2c7de0153ffe382dc2 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 31 Mar 2026 16:52:51 +0200 Subject: [PATCH 4/4] 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) --- packages/db/src/query/compiler/joins.ts | 8 ++++++++ packages/db/src/query/compiler/order-by.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/db/src/query/compiler/joins.ts b/packages/db/src/query/compiler/joins.ts index 69b833cf0..ea3e8bc70 100644 --- a/packages/db/src/query/compiler/joins.ts +++ b/packages/db/src/query/compiler/joins.ts @@ -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() } }), diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index 80262ab9e..6744d5c4f 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -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])