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. 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 ec8fa1839..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]) @@ -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) + } + } } } } 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 } 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 () => {