diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md new file mode 100644 index 000000000..37d80a22d --- /dev/null +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -0,0 +1,12 @@ +--- +'@tanstack/db': patch +'@tanstack/db-ivm': patch +--- + +Fix infinite loop in ORDER BY + LIMIT queries when WHERE filters out most data. + +**The problem**: Query asks for "top 10 where category='rare'" but only 3 rare items exist locally. System keeps asking "give me more!" but local index has nothing else. Loop forever. + +**The fix**: Added `localIndexExhausted` flag. When local index says "nothing left," we remember and stop asking. Flag resets when genuinely new data arrives from sync layer. + +Also adds safety iteration limits as backstops (D2: 100k, maybeRunGraph: 10k, requestLimitedSnapshot: 10k). diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 8451b2aff..f8c5233d8 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -57,7 +57,19 @@ export class D2 implements ID2 { } run(): void { + // Safety limit to prevent infinite loops in case of circular data flow + // or other bugs that cause operators to perpetually produce output. + // For legitimate pipelines, data should flow through in finite steps. + const MAX_RUN_ITERATIONS = 100000 + let iterations = 0 + while (this.pendingWork()) { + if (++iterations > MAX_RUN_ITERATIONS) { + throw new Error( + `D2 graph execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + + `This may indicate an infinite loop in the dataflow graph.`, + ) + } this.step() } } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 44c9af49f..785f9e846 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -410,13 +410,15 @@ export class CollectionSubscription * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater. * This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values. * Note 2: it does not send keys that have already been sent before. + * + * @returns true if local data was found and sent, false if the local index was exhausted */ requestLimitedSnapshot({ orderBy, limit, minValues, offset, - }: RequestLimitedSnapshotOptions) { + }: RequestLimitedSnapshotOptions): boolean { if (!limit) throw new Error(`limit is required`) if (!this.orderByIndex) { @@ -502,8 +504,24 @@ export class CollectionSubscription ? compileExpression(new PropRef(orderByExpression.path), true) : null + // Safety limit to prevent infinite loops if the index iteration or filtering + // logic has issues. The loop should naturally terminate when the index is + // exhausted, but this provides a backstop. 10000 iterations is generous + // for any legitimate use case. + const MAX_SNAPSHOT_ITERATIONS = 10000 + let snapshotIterations = 0 + let hitIterationLimit = false + while (valuesNeeded() > 0 && !collectionExhausted()) { - const insertedKeys = new Set() // Track keys we add to `changes` in this iteration + if (++snapshotIterations > MAX_SNAPSHOT_ITERATIONS) { + console.error( + `[TanStack DB] requestLimitedSnapshot exceeded ${MAX_SNAPSHOT_ITERATIONS} iterations. ` + + `This may indicate an infinite loop in index iteration or filtering. ` + + `Breaking out to prevent app freeze. Collection: ${this.collection.id}`, + ) + hitIterationLimit = true + break + } for (const key of keys) { const value = this.collection.get(key)! @@ -515,7 +533,6 @@ export class CollectionSubscription // Extract the indexed value (e.g., salary) from the row, not the full row // This is needed for index.take() to work correctly with the BTree comparator biggestObservedValue = valueExtractor ? valueExtractor(value) : value - insertedKeys.add(key) // Track this key } keys = index.take(valuesNeeded(), biggestObservedValue, filterFn) @@ -597,6 +614,14 @@ export class CollectionSubscription // Track this loadSubset call this.loadedSubsets.push(loadOptions) this.trackLoadSubsetPromise(syncResult) + + // Return whether local data was found and iteration completed normally. + // Return false if: + // - No local data was found (index exhausted) + // - Iteration limit was hit (abnormal exit) + // Either case signals that the caller should stop trying to load more. + // The async loadSubset may still return data later. + return changes.length > 0 && !hitIterationLimit } // TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 21cd04d1d..9be18da77 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -336,8 +336,35 @@ export class CollectionConfigBuilder< // Always run the graph if subscribed (eager execution) if (syncState.subscribedToAllCollections) { + // Safety limit to prevent infinite loops when data loading and graph processing + // create a feedback cycle. This can happen when: + // 1. OrderBy/limit queries filter out most data, causing dataNeeded() > 0 + // 2. Loading more data triggers updates that get filtered out + // 3. The cycle continues indefinitely + // 10000 iterations is generous for legitimate use cases but prevents hangs. + const MAX_GRAPH_ITERATIONS = 10000 + let iterations = 0 + while (syncState.graph.pendingWork()) { - syncState.graph.run() + if (++iterations > MAX_GRAPH_ITERATIONS) { + this.transitionToError( + `Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + + `This likely indicates an infinite loop caused by data loading ` + + `triggering continuous graph updates.`, + ) + return + } + + try { + syncState.graph.run() + } catch (error) { + // D2 graph throws when it exceeds its internal iteration limit + // Transition to error state so callers can detect incomplete data + this.transitionToError( + error instanceof Error ? error.message : String(error), + ) + return + } // Flush accumulated changes after each graph step to commit them as one transaction. // This ensures intermediate join states (like null on one side) don't cause // duplicate key errors when the full join result arrives in the same step. diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index ec4876b74..428a562a2 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -37,6 +37,12 @@ export class CollectionSubscriber< // can potentially send the same item to D2 multiple times. private sentToD2Keys = new Set() + // Track when the local index has been exhausted for the current cursor position. + // When true, loadMoreIfNeeded will not try to load more data until new data arrives. + // This prevents infinite loops when the TopK can't be filled because WHERE filters + // out all available data. + private localIndexExhausted = false + constructor( private alias: string, private collectionId: string, @@ -222,11 +228,31 @@ export class CollectionSubscriber< const sendChangesInRange = ( changes: Iterable>, ) => { + // Check for inserts before splitting updates, to determine if we should + // reset localIndexExhausted. We reset on inserts because: + // + // 1. splitUpdates (below) converts updates to delete+insert pairs for D2, + // but those "fake" inserts shouldn't reset the flag - they don't represent + // new rows that could fill the TopK. + // + // 2. "Reset only on inserts" is correct because updates to existing rows + // don't add new rows to scan in the local index. The updated row is + // already being processed in the current graph run. + // + // 3. Edge case: "update makes row match WHERE" is handled correctly because + // the subscription's filterAndFlipChanges converts "update for unseen key" + // to "insert" before we receive it here. So if a row that was previously + // filtered out by WHERE now matches after an update, it arrives as an + // insert and correctly resets the flag. + const changesArray = Array.isArray(changes) ? changes : [...changes] + const hasOriginalInserts = changesArray.some((c) => c.type === `insert`) + // Split live updates into a delete of the old value and an insert of the new value - const splittedChanges = splitUpdates(changes) + const splittedChanges = splitUpdates(changesArray) this.sendChangesToPipelineWithTracking( splittedChanges, subscriptionHolder.current!, + hasOriginalInserts, ) } @@ -301,11 +327,25 @@ export class CollectionSubscriber< return true } + // If we've already exhausted the local index, don't try to load more. + // This prevents infinite loops when the TopK can't be filled because + // the WHERE clause filters out all available local data. + // The flag is reset when new data arrives from the sync layer. + if (this.localIndexExhausted) { + return true + } + // `dataNeeded` probes the orderBy operator to see if it needs more data // if it needs more data, it returns the number of items it needs const n = dataNeeded() if (n > 0) { - this.loadNextItems(n, subscription) + const foundLocalData = this.loadNextItems(n, subscription) + if (!foundLocalData) { + // No local data found - mark the index as exhausted so we don't + // keep trying in subsequent graph iterations. The sync layer's + // loadSubset has been called and may return data asynchronously. + this.localIndexExhausted = true + } } return true } @@ -313,6 +353,7 @@ export class CollectionSubscriber< private sendChangesToPipelineWithTracking( changes: Iterable>, subscription: CollectionSubscription, + hasOriginalInserts?: boolean, ) { const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { @@ -320,7 +361,20 @@ export class CollectionSubscriber< return } - const trackedChanges = this.trackSentValues(changes, orderByInfo.comparator) + // Reset localIndexExhausted when genuinely new data arrives from the sync layer. + // This allows loadMoreIfNeeded to try loading again since there's new data. + // We only reset on ORIGINAL inserts - not fake inserts from splitUpdates. + // splitUpdates converts updates to delete+insert for D2, but those shouldn't + // reset the flag since they don't represent new data that could fill the TopK. + const changesArray = Array.isArray(changes) ? changes : [...changes] + if (hasOriginalInserts) { + this.localIndexExhausted = false + } + + const trackedChanges = this.trackSentValues( + changesArray, + orderByInfo.comparator, + ) // Cache the loadMoreIfNeeded callback on the subscription using a symbol property. // This ensures we pass the same function instance to the scheduler each time, @@ -342,10 +396,14 @@ export class CollectionSubscriber< // Loads the next `n` items from the collection // starting from the biggest item it has sent - private loadNextItems(n: number, subscription: CollectionSubscription) { + // Returns true if local data was found, false if the local index is exhausted + private loadNextItems( + n: number, + subscription: CollectionSubscription, + ): boolean { const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { - return + return false } const { orderBy, valueExtractorForRawRow, offset } = orderByInfo const biggestSentRow = this.biggest @@ -369,7 +427,8 @@ export class CollectionSubscriber< // Take the `n` items after the biggest sent value // Pass the current window offset to ensure proper deduplication - subscription.requestLimitedSnapshot({ + // Returns true if local data was found + return subscription.requestLimitedSnapshot({ orderBy: normalizedOrderBy, limit: n, minValues, diff --git a/packages/db/tests/infinite-loop-prevention.test.ts b/packages/db/tests/infinite-loop-prevention.test.ts new file mode 100644 index 000000000..f1a9ef0eb --- /dev/null +++ b/packages/db/tests/infinite-loop-prevention.test.ts @@ -0,0 +1,474 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection, gt } from '../src/query/index.js' +import { CollectionSubscription } from '../src/collection/subscription.js' +import { mockSyncCollectionOptions } from './utils.js' + +/** + * Tests for infinite loop prevention in ORDER BY + LIMIT queries. + * + * The issue: When a live query has ORDER BY + LIMIT, the TopK operator + * requests data until it has `limit` items. If the WHERE clause filters + * out most data, the TopK may never be filled, causing loadMoreIfNeeded + * to be called repeatedly in an infinite loop. + * + * The infinite loop specifically occurs when: + * 1. Initial load exhausts the local index (TopK still needs more items) + * 2. Updates arrive (e.g., from Electric sync layer converting duplicate inserts to updates) + * 3. maybeRunGraph processes the update and calls loadMoreIfNeeded + * 4. loadMoreIfNeeded sees dataNeeded() > 0, calls loadNextItems + * 5. loadNextItems finds nothing (index exhausted), but without tracking this, + * the next iteration repeats steps 3-5 indefinitely + * + * The fix: CollectionSubscriber tracks when the local index is exhausted + * via `localIndexExhausted` flag, preventing repeated load attempts. + * The flag resets when new inserts arrive, allowing the system to try again. + */ + +type TestItem = { + id: number + value: number + category: string +} + +describe(`Infinite loop prevention`, () => { + // The infinite loop bug occurs when: + // 1. Query has ORDER BY + LIMIT + WHERE that filters most data + // 2. Sync layer (like Electric) continuously sends updates + // 3. These updates trigger pendingWork() to remain true during maybeRunGraph + // 4. Without the localIndexExhausted fix, loadMoreIfNeeded keeps trying to load + // from the exhausted local index + // + // These tests verify the localIndexExhausted flag works correctly: + // - Prevents repeated load attempts when the local index is exhausted + // - Resets when new inserts arrive, allowing the system to try again + + it(`should not infinite loop when WHERE filters out most data for ORDER BY + LIMIT query`, async () => { + // This test verifies that the localIndexExhausted optimization prevents + // unnecessary load attempts when the TopK can't be filled. + // + // The scenario: + // 1. Query wants 10 items with value > 90 + // 2. Only 2 items match (values 95 and 100) + // 3. Without the fix, loadMoreIfNeeded would keep trying to load more + // 4. With the fix, localIndexExhausted stops unnecessary attempts + + const initialData: Array = [] + for (let i = 1; i <= 20; i++) { + initialData.push({ + id: i, + value: i * 5, // values: 5, 10, 15, ... 95, 100 + category: i <= 10 ? `A` : `B`, + }) + } + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `infinite-loop-test`, + getKey: (item: TestItem) => item.id, + initialData, + }), + ) + + await sourceCollection.preload() + + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 90)) + .orderBy(({ items }) => items.value, `desc`) + .limit(10) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + // Should complete without hanging or hitting safeguard + await liveQueryCollection.preload() + + // Verify results + const results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(2) + expect(results.map((r) => r.value)).toEqual([100, 95]) + + // Verify not in error state (didn't hit safeguard) + expect( + liveQueryCollection.status, + `Query should not be in error state`, + ).not.toBe(`error`) + }) + + it(`should resume loading when new matching data arrives`, async () => { + // Start with data that doesn't match WHERE clause + const initialData: Array = [ + { id: 1, value: 10, category: `A` }, + { id: 2, value: 20, category: `A` }, + { id: 3, value: 30, category: `A` }, + ] + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `resume-loading-test`, + getKey: (item: TestItem) => item.id, + initialData, + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query wants items with value > 50, ordered by value, limit 5 + // Initially 0 items match + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 50)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Should have 0 items initially + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(0) + + // Now add items that match the WHERE clause + utils.begin() + utils.write({ type: `insert`, value: { id: 4, value: 60, category: `B` } }) + utils.write({ type: `insert`, value: { id: 5, value: 70, category: `B` } }) + utils.commit() + + // Wait for changes to propagate + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Should now have 2 items (localIndexExhausted was reset by new inserts) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(2) + expect(results.map((r) => r.value)).toEqual([70, 60]) + }) + + it(`should handle updates that move items out of WHERE clause`, async () => { + // All items initially match WHERE clause + const initialData: Array = [ + { id: 1, value: 100, category: `A` }, + { id: 2, value: 90, category: `A` }, + { id: 3, value: 80, category: `A` }, + { id: 4, value: 70, category: `A` }, + { id: 5, value: 60, category: `A` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `update-out-of-where-test`, + getKey: (item: TestItem) => item.id, + initialData, + }), + ) + + await sourceCollection.preload() + + // Query: WHERE value > 50, ORDER BY value DESC, LIMIT 3 + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 50)) + .orderBy(({ items }) => items.value, `desc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Should have top 3: 100, 90, 80 + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.value)).toEqual([100, 90, 80]) + + // Update items to move them OUT of WHERE clause + // This could trigger the infinite loop if not handled properly + sourceCollection.update(1, (draft) => { + draft.value = 40 // Now < 50, filtered out + }) + sourceCollection.update(2, (draft) => { + draft.value = 30 // Now < 50, filtered out + }) + + // Wait for changes to propagate + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Should now have: 80, 70, 60 (items 3, 4, 5) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.value)).toEqual([80, 70, 60]) + }) + + it(`should not infinite loop when updates arrive after local index is exhausted`, async () => { + // This test simulates the Electric scenario where: + // 1. Initial data loads, but TopK can't be filled (WHERE filters too much) + // 2. Updates arrive from sync layer (like Electric converting duplicate inserts to updates) + // 3. Without the fix, each update would trigger loadMoreIfNeeded which tries + // to load from the exhausted local index, causing an infinite loop + // + // The fix: localIndexExhausted flag prevents repeated load attempts. + // The flag only resets when NEW INSERTS arrive (not updates/deletes). + + const initialData: Array = [] + for (let i = 1; i <= 10; i++) { + initialData.push({ + id: i, + value: i * 10, // values: 10, 20, 30, ... 100 + category: `A`, + }) + } + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `electric-update-loop-test`, + getKey: (item: TestItem) => item.id, + initialData, + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 + // Only item with value=100 matches, but we want 5 items + // This exhausts the local index after the first item + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 95)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + // Preload should complete without hanging + const preloadPromise = liveQueryCollection.preload() + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject(new Error(`Timeout during preload - possible infinite loop`)), + 5000, + ), + ) + + await expect( + Promise.race([preloadPromise, timeoutPromise]), + ).resolves.toBeUndefined() + + // Should have 1 item (value=100) + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(100) + + // Now simulate Electric sending updates (like duplicate insert → update conversion) + // Without the fix, this would trigger infinite loop because: + // 1. Update arrives, triggers maybeRunGraph + // 2. loadMoreIfNeeded sees dataNeeded() > 0 (TopK still needs 4 more) + // 3. loadNextItems finds nothing (index exhausted) + // 4. Without localIndexExhausted flag, loop would repeat indefinitely + const updatePromise = (async () => { + // Send several updates that don't change the result set + // These simulate Electric's duplicate handling + for (let i = 0; i < 5; i++) { + utils.begin() + // Update an item that doesn't match WHERE - this shouldn't affect results + // but could trigger the infinite loop bug + utils.write({ + type: `update`, + value: { id: 5, value: 50 + i, category: `A` }, // Still doesn't match WHERE + }) + utils.commit() + + // Small delay between updates to simulate real Electric behavior + await new Promise((resolve) => setTimeout(resolve, 10)) + } + })() + + const updateTimeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject(new Error(`Timeout during updates - possible infinite loop`)), + 5000, + ), + ) + + await expect( + Promise.race([updatePromise, updateTimeoutPromise]), + ).resolves.toBeUndefined() + + // Results should still be the same (updates didn't add matching items) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(100) + }) + + it(`should reset localIndexExhausted when new inserts arrive`, async () => { + // This test verifies that the localIndexExhausted flag properly resets + // when new inserts arrive, allowing the system to load more data + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `reset-exhausted-flag-test`, + getKey: (item: TestItem) => item.id, + initialData: [{ id: 1, value: 100, category: `A` }], + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query: WHERE value > 50, ORDER BY value DESC, LIMIT 5 + // Initially only 1 item matches, but we want 5 + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 50)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Should have 1 item initially + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + + // Send updates (should NOT reset the flag, should NOT trigger more loads) + utils.begin() + utils.write({ type: `update`, value: { id: 1, value: 101, category: `A` } }) + utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Still 1 item (updated value) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(101) + + // Now send NEW INSERTS - this SHOULD reset the flag and load more + utils.begin() + utils.write({ type: `insert`, value: { id: 2, value: 90, category: `B` } }) + utils.write({ type: `insert`, value: { id: 3, value: 80, category: `B` } }) + utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Now should have 3 items (new inserts reset the flag, allowing more to load) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.value)).toEqual([101, 90, 80]) + }) + + it(`should limit requestLimitedSnapshot calls when index is exhausted`, async () => { + // This test verifies that the localIndexExhausted optimization actually limits + // how many times we try to load from an exhausted index. + // + // We patch CollectionSubscription.prototype.requestLimitedSnapshot to count calls, + // then send multiple updates and verify the call count stays low (not unbounded). + + // Patch prototype before creating anything + let requestLimitedSnapshotCallCount = 0 + const originalRequestLimitedSnapshot = + CollectionSubscription.prototype.requestLimitedSnapshot + + CollectionSubscription.prototype.requestLimitedSnapshot = function ( + ...args: Array + ) { + requestLimitedSnapshotCallCount++ + return originalRequestLimitedSnapshot.apply(this, args as any) + } + + try { + const initialData: Array = [ + { id: 1, value: 100, category: `A` }, // Only this matches WHERE > 95 + { id: 2, value: 50, category: `A` }, + { id: 3, value: 40, category: `A` }, + ] + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `limited-snapshot-calls-test`, + getKey: (item: TestItem) => item.id, + initialData, + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 + // Only 1 item matches (value=100), but we want 5 + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 95)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Record how many calls happened during initial load + const initialLoadCalls = requestLimitedSnapshotCallCount + + // Should have 1 item initially + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(100) + + // Send 20 updates that match the WHERE clause + // Without the fix, each update would trigger loadMoreIfNeeded which would + // call requestLimitedSnapshot. With the fix, localIndexExhausted prevents + // repeated calls. + for (let i = 0; i < 20; i++) { + utils.begin() + utils.write({ + type: `update`, + value: { id: 1, value: 100 + i, category: `A` }, + }) + utils.commit() + await new Promise((resolve) => setTimeout(resolve, 5)) + } + + // Wait for all processing to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Calculate calls after the updates + const callsAfterUpdates = + requestLimitedSnapshotCallCount - initialLoadCalls + + // With the fix, requestLimitedSnapshot should be called very few times + // after the initial load (ideally 0 since index was already exhausted) + // Without the fix, it would be called ~20 times (once per update) + expect(callsAfterUpdates).toBeLessThan(5) + + // Results should show the latest value + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBeGreaterThanOrEqual(100) + } finally { + // Restore original method + CollectionSubscription.prototype.requestLimitedSnapshot = + originalRequestLimitedSnapshot + } + }) + + // NOTE: The actual Electric infinite loop involves async timing that's hard to reproduce + // in unit tests. The test above verifies the optimization limits repeated calls, + // which is the core behavior the localIndexExhausted flag provides. +})