diff --git a/.changeset/spicy-roses-hide.md b/.changeset/spicy-roses-hide.md new file mode 100644 index 000000000..5bb063f82 --- /dev/null +++ b/.changeset/spicy-roses-hide.md @@ -0,0 +1,59 @@ +--- +'@tanstack/svelte-db': minor +--- + +Add `useLiveInfiniteQuery` rune for infinite scrolling with live updates. + +The new `useLiveInfiniteQuery` provides an infinite query pattern similar to TanStack Query's `useInfiniteQuery`, but integrated with TanStack DB's reactive local collections. It maintains a reactive window into your data, allowing for efficient pagination and automatic updates as data changes. + +**Key features:** + +- **Automatic Live Updates**: Reactive integration with local collections using Svelte runes. +- **Efficient Pagination**: Uses a dynamic window mechanism to track visible data without re-executing complex queries. +- **Automatic Page Detection**: Includes a built-in peek-ahead strategy to detect if more pages are available without manual `getNextPageParam` logic. +- **Flexible Rendering**: Provides both a flattened `data` array and a structured `pages` array. + +**Example usage:** + +```svelte + + +{#if query.isLoading} +

Loading...

+{:else} +
+ {#each query.pages as page} + {#each page as post (post.id)} + + {/each} + {/each} + + {#if query.hasNextPage} + + {/if} +
+{/if} +``` + +**Requirements:** + +- The query must include an `.orderBy()` clause to support the underlying windowing mechanism. +- Supports both offset-based and cursor-based sync implementations via the standard TanStack DB sync protocol. diff --git a/packages/svelte-db/src/index.ts b/packages/svelte-db/src/index.ts index a07f98a64..b7a01e00a 100644 --- a/packages/svelte-db/src/index.ts +++ b/packages/svelte-db/src/index.ts @@ -1,5 +1,6 @@ // Re-export all public APIs export * from './useLiveQuery.svelte.js' +export * from './useLiveInfiniteQuery.svelte.js' // Re-export everything from @tanstack/db export * from '@tanstack/db' diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts new file mode 100644 index 000000000..07493a57f --- /dev/null +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -0,0 +1,329 @@ +import { CollectionImpl } from '@tanstack/db' +import { untrack } from 'svelte' +import { useLiveQuery } from './useLiveQuery.svelte.js' +import type { MaybeGetter } from './useLiveQuery.svelte.js' +import type { + Collection, + Context, + InferResultType, + InitialQueryBuilder, + LiveQueryCollectionUtils, + NonSingleResult, + QueryBuilder, +} from '@tanstack/db' + +/** + * Type guard to check if utils object has setWindow method (LiveQueryCollectionUtils) + */ +function isLiveQueryCollectionUtils( + utils: unknown, +): utils is LiveQueryCollectionUtils { + return typeof (utils as any).setWindow === `function` +} + +/** + * Normalizes the input into a stable value and type flag. + * Handles: Collection, () => Collection (getter), or (q) => Query (fn). + */ +function resolveInput(input: any) { + let unwrapped = input + let isCollection = unwrapped instanceof CollectionImpl + + if (!isCollection && typeof unwrapped === `function`) { + try { + // Try to see if it's a getter for a collection + const potentiallyColl = unwrapped() + if (potentiallyColl instanceof CollectionImpl) { + unwrapped = potentiallyColl + isCollection = true + } + } catch { + // It's likely a query function that expects arguments + } + } + + if (!isCollection && typeof unwrapped !== `function`) { + throw new Error( + `useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` + + `or a query function. Received: ${typeof unwrapped}`, + ) + } + + return { unwrapped, isCollection } +} + +export type UseLiveInfiniteQueryConfig = { + pageSize?: number + initialPageParam?: number + /** + * @deprecated This callback is not used by the current implementation. + * Pagination is determined internally via a peek-ahead strategy. + * Provided for API compatibility with TanStack Query conventions. + */ + getNextPageParam?: ( + lastPage: Array[number]>, + allPages: Array[number]>>, + lastPageParam: number, + allPageParams: Array, + ) => number | undefined +} + +export type UseLiveInfiniteQueryReturn = Omit< + ReturnType>, + `data` +> & { + data: InferResultType + pages: Array[number]>> + pageParams: Array + fetchNextPage: () => void + hasNextPage: boolean + isFetchingNextPage: boolean +} + +/** + * Pure utility to slice data into pages based on count and size + */ +function paginate( + data: Array, + pageSize: number, + pageCount: number, + initialParam: number, +) { + const pages: Array> = [] + const pageParams: Array = [] + + for (let i = 0; i < pageCount; i++) { + const start = i * pageSize + const end = (i + 1) * pageSize + pages.push(data.slice(start, end)) + pageParams.push(initialParam + i) + } + + return { pages, pageParams } +} + +/** + * Create an infinite query using a query function with live updates + * + * Uses `utils.setWindow()` to dynamically adjust the limit/offset window + * without recreating the live query collection on each page change. + * + * @param queryFnOrCollection - Query function or pre-created collection + * @param config - Configuration including pageSize and getNextPageParam + * @param deps - Array of reactive dependencies that trigger query re-execution when changed + * @returns Object with pages, data, and pagination controls + * + * @remarks + * **IMPORTANT - Destructuring in Svelte 5:** + * Direct destructuring breaks reactivity. To destructure, wrap with `$derived`: + * + * ❌ **Incorrect** - Loses reactivity: + * ```ts + * const { data, pages, fetchNextPage } = useLiveInfiniteQuery(...) + * ``` + * + * ✅ **Correct** - Maintains reactivity: + * ```ts + * // Option 1: Use dot notation (recommended) + * const query = useLiveInfiniteQuery(...) + * // Access: query.data, query.pages, query.fetchNextPage() + * + * // Option 2: Wrap with $derived for destructuring + * const query = useLiveInfiniteQuery(...) + * const { data, pages, fetchNextPage } = $derived(query) + * ``` + * + * This is a fundamental Svelte 5 limitation, not a library bug. + */ + +// Overload for pre-created collection (non-single result) +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: MaybeGetter< + Collection & NonSingleResult + >, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturn + +// Overload for query function +export function useLiveInfiniteQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + config: UseLiveInfiniteQueryConfig, + deps?: Array<() => unknown>, +): UseLiveInfiniteQueryReturn + +// Implementation +export function useLiveInfiniteQuery( + queryFnOrCollection: any, + config: UseLiveInfiniteQueryConfig, + deps: Array<() => unknown> = [], +): UseLiveInfiniteQueryReturn { + const pageSize = $derived(config.pageSize ?? 20) + const initialPageParam = $derived(config.initialPageParam ?? 0) + + // 1. Resolve input reactively + const input = $derived(resolveInput(queryFnOrCollection)) + + // 2. Local pagination state + let loadedPageCount = $state(1) + let isFetchingNextPage = $state(false) + let currentCollectionInstance: any = null + let hasValidatedCollection = false + + // 3. Underlying live query + const query = useLiveQuery(() => { + const { isCollection: isColl, unwrapped } = input + if (isColl) return unwrapped + + return (q: InitialQueryBuilder) => + unwrapped(q) + .limit(pageSize + 1) + .offset(0) + }, deps) + + // 4. Reset pagination on collection change + $effect(() => { + if (query.collection !== currentCollectionInstance) { + untrack(() => { + currentCollectionInstance = query.collection + hasValidatedCollection = false + loadedPageCount = 1 + }) + } + }) + + // 5. Window adjustment effect + $effect(() => { + const { collection, isReady } = query + if (!isReady) return + + const utils = collection.utils + const expectedOffset = 0 + const expectedLimit = loadedPageCount * (pageSize + 1) // +1 per page for peek ahead consistency + + // Check if collection has orderBy (required for setWindow) + if (!isLiveQueryCollectionUtils(utils)) { + // For pre-created collections, throw an error if no orderBy + if (input.isCollection) { + throw new Error( + `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work.` + + `Please add .orderBy() to your createLiveQueryCollection query.`, + ) + } + return + } + + // Validation warning for pre-created collections + if (input.isCollection && !hasValidatedCollection) { + const win = utils.getWindow() + if ( + win && + (win.offset !== expectedOffset || win.limit !== expectedLimit) + ) { + console.warn( + `useLiveInfiniteQuery: Pre-created collection has window {offset: ${win.offset}, limit: ${win.limit}} ` + + `but hook expects {offset: 0, limit: ${expectedLimit}}. Adjusting now.`, + ) + } + hasValidatedCollection = true + } + + let cancelled = false + const result = utils.setWindow({ + offset: expectedOffset, + limit: expectedLimit, + }) + + if (result !== true) { + isFetchingNextPage = true + result + .catch((err: unknown) => { + if (!cancelled) console.error(`useLiveInfiniteQuery failed:`, err) + }) + .finally(() => { + if (!cancelled) isFetchingNextPage = false + }) + } else { + isFetchingNextPage = false + } + + return () => { + cancelled = true + } + }) + + // 6. Data derivation + const result = $derived.by(() => { + const dataArray = (Array.isArray(query.data) ? query.data : []) as Array< + InferResultType[number] + > + + const requestedCount = loadedPageCount * pageSize + const { pages, pageParams } = paginate( + dataArray, + pageSize, + loadedPageCount, + initialPageParam, + ) + + return { + pages, + pageParams, + data: dataArray.slice(0, requestedCount) as InferResultType, + hasNextPage: dataArray.length > requestedCount, + } + }) + + const fetchNextPage = () => { + if (result.hasNextPage && !isFetchingNextPage) { + loadedPageCount++ + } + } + + // 7. Public API with concise delegation + return { + get state() { + return query.state as Map + }, + get collection() { + return query.collection as any + }, + get status() { + return query.status + }, + get isLoading() { + return query.isLoading + }, + get isReady() { + return query.isReady + }, + get isIdle() { + return query.isIdle + }, + get isError() { + return query.isError + }, + get isCleanedUp() { + return query.isCleanedUp + }, + get data() { + return result.data + }, + get pages() { + return result.pages + }, + get pageParams() { + return result.pageParams + }, + get hasNextPage() { + return result.hasNextPage + }, + get isFetchingNextPage() { + return isFetchingNextPage + }, + fetchNextPage, + } as UseLiveInfiniteQueryReturn +} diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 76dedd3c7..5de82e66c 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -59,9 +59,9 @@ export interface UseLiveQueryReturnWithCollection< isCleanedUp: boolean } -type MaybeGetter = T | (() => T) +export type MaybeGetter = T | (() => T) -function toValue(value: MaybeGetter): T { +export function toValue(value: MaybeGetter): T { if (typeof value === `function`) { return (value as () => T)() } @@ -465,6 +465,7 @@ export function useLiveQuery( currentUnsubscribe() currentUnsubscribe = null } + status = `cleaned-up` as const } }) diff --git a/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts new file mode 100644 index 000000000..0db0a8e66 --- /dev/null +++ b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts @@ -0,0 +1,1583 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { flushSync } from 'svelte' +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + eq, +} from '@tanstack/db' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery.svelte.js' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' +import type { InitialQueryBuilder, LoadSubsetOptions } from '@tanstack/db' + +type Post = { + id: string + title: string + content: string + createdAt: number + category: string +} + +function createMockPosts(count: number): Array { + const posts: Array = [] + for (let i = 1; i <= count; i++) { + posts.push({ + id: `${i}`, + title: `Post ${i}`, + content: `Content ${i}`, + createdAt: 1000000 - i * 1000, // Descending order + category: i % 2 === 0 ? `tech` : `life`, + }) + } + return posts +} + +type OnDemandCollectionOptions = { + id: string + allPosts: Array + autoIndex?: `off` | `eager` + asyncDelay?: number +} + +function createOnDemandCollection(opts: OnDemandCollectionOptions) { + const loadSubsetCalls: Array = [] + const { id, allPosts, autoIndex, asyncDelay } = opts + + const collection = createCollection({ + id, + getKey: (post: Post) => post.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: autoIndex ?? `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady, begin, write, commit }) => { + markReady() + + return { + loadSubset: (subsetOpts: LoadSubsetOptions) => { + loadSubsetCalls.push({ ...subsetOpts }) + + let filtered = [...allPosts].sort( + (a, b) => b.createdAt - a.createdAt, + ) + + if (subsetOpts.cursor) { + const whereFromFn = createFilterFunctionFromExpression( + subsetOpts.cursor.whereFrom, + ) + filtered = filtered.filter(whereFromFn) + } + + if (subsetOpts.limit !== undefined) { + filtered = filtered.slice(0, subsetOpts.limit) + } + + function writeAll(): void { + begin() + for (const post of filtered) { + write({ type: `insert`, value: post }) + } + commit() + } + + if (asyncDelay !== undefined) { + return new Promise((resolve) => { + setTimeout(() => { + writeAll() + resolve() + }, asyncDelay) + }) + } + + writeAll() + return true + }, + } + }, + }, + }) + + return { collection, loadSubsetCalls } +} + +describe(`useLiveInfiniteQuery`, () => { + let cleanup: (() => void) | null = null + + afterEach(() => { + cleanup?.() + }) + + it(`should fetch initial page of data`, () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `initial-page-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .select(({ posts: p }) => ({ + id: p.id, + title: p.title, + createdAt: p.createdAt, + })), + { + pageSize: 10, + }, + ) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(10) + expect(query.data).toHaveLength(10) + expect(query.hasNextPage).toBe(true) + expect(query.pages[0]![0]).toMatchObject({ + id: `1`, + title: `Post 1`, + }) + }) + }) + + it(`should fetch multiple pages`, () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `multiple-pages-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.pages[0]).toHaveLength(10) + expect(query.pages[1]).toHaveLength(10) + expect(query.data).toHaveLength(20) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(3) + expect(query.data).toHaveLength(30) + expect(query.hasNextPage).toBe(true) + }) + }) + + it(`should detect when no more pages available`, () => { + const posts = createMockPosts(25) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `no-more-pages-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.pages[1]).toHaveLength(10) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(3) + expect(query.pages[2]).toHaveLength(5) + expect(query.data).toHaveLength(25) + expect(query.hasNextPage).toBe(false) + }) + }) + + it(`should handle empty results`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `empty-results-test`, + getKey: (post: Post) => post.id, + initialData: [], + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(0) + expect(query.data).toHaveLength(0) + expect(query.hasNextPage).toBe(false) + }) + }) + + it(`should update pages when underlying data changes`, () => { + const posts = createMockPosts(30) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `live-updates-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `new-1`, + title: `New Post`, + content: `New Content`, + createdAt: 1000001, + category: `tech`, + }, + }) + collection.utils.commit() + + flushSync() + + expect(query.pages[0]![0]).toMatchObject({ + id: `new-1`, + title: `New Post`, + }) + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + expect(query.pages[0]).toHaveLength(10) + expect(query.pages[1]).toHaveLength(10) + }) + }) + + it(`should work with where clauses`, () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `where-clause-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .where(({ posts: p }) => eq(p.category, `tech`)) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 5, + }, + ) + + flushSync() + + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(5) + + query.pages[0]!.forEach((post: Post) => { + expect(post.category).toBe(`tech`) + }) + + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(10) + }) + }) + + it(`should re-execute query when dependencies change`, () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `deps-change-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + let category = $state(`tech`) + + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .where(({ posts: p }) => eq(p.category, category)) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 5, + }, + [() => category], + ) + + flushSync() + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + + category = `life` + flushSync() + + expect(query.pages).toHaveLength(1) + query.pages[0]!.forEach((post: Post) => { + expect(post.category).toBe(`life`) + }) + }) + }) + + it(`should track pageParams correctly`, () => { + const posts = createMockPosts(30) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `page-params-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + initialPageParam: 0, + }, + ) + + flushSync() + + expect(query.pageParams).toEqual([0]) + + query.fetchNextPage() + flushSync() + + expect(query.pageParams).toEqual([0, 1]) + + query.fetchNextPage() + flushSync() + + expect(query.pageParams).toEqual([0, 1, 2]) + }) + }) + + it(`should accept pre-created live query collection`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `pre-created-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(5), + }) + + await liveQueryCollection.preload() + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 10, + }) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(10) + expect(query.data).toHaveLength(10) + expect(query.hasNextPage).toBe(true) + expect(query.pages[0]![0]).toMatchObject({ + id: `1`, + title: `Post 1`, + }) + }) + }) + + it(`should work with on-demand collection via peek-ahead`, () => { + const PAGE_SIZE = 10 + const { collection } = createOnDemandCollection({ + id: `peek-ahead-boundary-test`, + allPosts: createMockPosts(PAGE_SIZE + 1), + }) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: PAGE_SIZE, + }, + ) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.hasNextPage).toBe(true) + expect(query.data).toHaveLength(PAGE_SIZE) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(PAGE_SIZE) + }) + }) + + it(`should handle deletions across pages`, () => { + const posts = createMockPosts(25) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `deletions-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + const firstItemId = query.data[0]!.id + + collection.utils.begin() + collection.utils.write({ + type: `delete`, + value: posts[0]!, + }) + collection.utils.commit() + + flushSync() + + expect(query.data[0]!.id).not.toBe(firstItemId) + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + expect(query.pages[0]).toHaveLength(10) + expect(query.pages[1]).toHaveLength(10) + }) + }) + + it(`should handle deletion from partial page with descending order`, () => { + const posts = createMockPosts(5) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `partial-page-deletion-desc-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 20, + }, + ) + + flushSync() + + expect(query.pages).toHaveLength(1) + expect(query.data).toHaveLength(5) + + const firstItemId = query.data[0]!.id + expect(firstItemId).toBe(`1`) + + collection.utils.begin() + collection.utils.write({ + type: `delete`, + value: posts[0]!, + }) + collection.utils.commit() + + flushSync() + + expect(query.data).toHaveLength(4) + expect(query.data.find((p: Post) => p.id === firstItemId)).toBeUndefined() + expect(query.data[0]!.id).toBe(`2`) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(4) + }) + }) + + it(`should handle deletion from partial page with ascending order`, () => { + const posts = createMockPosts(5) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `partial-page-deletion-asc-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `asc`), + { + pageSize: 20, + }, + ) + + flushSync() + + expect(query.pages).toHaveLength(1) + expect(query.data).toHaveLength(5) + + const firstItemId = query.data[0]!.id + expect(firstItemId).toBe(`5`) + + collection.utils.begin() + collection.utils.write({ + type: `delete`, + value: posts[4]!, + }) + collection.utils.commit() + + flushSync() + + expect(query.data).toHaveLength(4) + expect(query.data.find((p: Post) => p.id === firstItemId)).toBeUndefined() + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(4) + }) + }) + + it(`should handle exact page size boundaries`, () => { + const posts = createMockPosts(20) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `exact-boundary-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.pages[1]).toHaveLength(10) + expect(query.hasNextPage).toBe(false) + expect(query.data).toHaveLength(20) + }) + }) + + it(`should not fetch when already fetching`, () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `concurrent-fetch-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + expect(query.pages).toHaveLength(1) + + query.fetchNextPage() + flushSync() + expect(query.pages).toHaveLength(2) + + query.fetchNextPage() + flushSync() + expect(query.pages).toHaveLength(3) + + query.fetchNextPage() + flushSync() + expect(query.pages).toHaveLength(4) + + expect(query.pages).toHaveLength(4) + expect(query.data).toHaveLength(40) + }) + }) + + it(`should not fetch when hasNextPage is false`, () => { + const posts = createMockPosts(5) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `no-fetch-when-done-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + expect(query.hasNextPage).toBe(false) + expect(query.pages).toHaveLength(1) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(1) + }) + }) + + it(`should support custom initialPageParam`, () => { + const posts = createMockPosts(30) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `initial-param-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + initialPageParam: 100, + }, + ) + + flushSync() + + expect(query.pageParams).toEqual([100]) + + query.fetchNextPage() + flushSync() + + expect(query.pageParams).toEqual([100, 101]) + }) + }) + + it(`should detect hasNextPage change when new items are synced`, () => { + const posts = createMockPosts(20) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `sync-detection-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.hasNextPage).toBe(false) + expect(query.data).toHaveLength(20) + + collection.utils.begin() + for (let i = 0; i < 5; i++) { + collection.utils.write({ + type: `insert`, + value: { + id: `new-${i}`, + title: `New Post ${i}`, + content: `Content ${i}`, + createdAt: Date.now() + i, + category: `tech`, + }, + }) + } + collection.utils.commit() + + flushSync() + + expect(query.hasNextPage).toBe(true) + expect(query.data).toHaveLength(20) + expect(query.pages).toHaveLength(2) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(3) + expect(query.pages[2]).toHaveLength(5) + expect(query.data).toHaveLength(25) + expect(query.hasNextPage).toBe(false) + }) + }) + + it(`should set isFetchingNextPage to false when data is immediately available`, () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `immediate-data-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (queryBuilder: InitialQueryBuilder) => + queryBuilder + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 10, + }, + ) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.isFetchingNextPage).toBe(false) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.isFetchingNextPage).toBe(false) + }) + }) + + it(`should detect hasNextPage via peek-ahead with exactly pageSize+1 items in on-demand collection`, () => { + const PAGE_SIZE = 10 + const { collection } = createOnDemandCollection({ + id: `peek-ahead-boundary-test-on-demand`, + allPosts: createMockPosts(PAGE_SIZE + 1), + }) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (queryBuilder: InitialQueryBuilder) => + queryBuilder + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: PAGE_SIZE, + }, + ) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.hasNextPage).toBe(true) + expect(query.data).toHaveLength(PAGE_SIZE) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(PAGE_SIZE) + }) + }) + + it(`should request limit+1 (peek-ahead) from loadSubset for hasNextPage detection`, () => { + const PAGE_SIZE = 10 + const { collection, loadSubsetCalls } = createOnDemandCollection({ + id: `peek-ahead-limit-test`, + allPosts: createMockPosts(PAGE_SIZE), + }) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: PAGE_SIZE, + }, + ) + + flushSync() + expect(query.isReady).toBe(true) + + const callWithLimit = loadSubsetCalls.find( + (call) => call.limit !== undefined, + ) + expect(callWithLimit).toBeDefined() + expect(callWithLimit!.limit).toBe(PAGE_SIZE + 1) + expect(query.hasNextPage).toBe(false) + expect(query.data).toHaveLength(PAGE_SIZE) + }) + }) + + it(`should work with on-demand collection and fetch multiple pages`, () => { + const PAGE_SIZE = 10 + const { collection } = createOnDemandCollection({ + id: `on-demand-e2e-test`, + allPosts: createMockPosts(25), + autoIndex: `eager`, + }) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (queryBuilder: InitialQueryBuilder) => + queryBuilder + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: PAGE_SIZE, + }, + ) + + flushSync() + expect(query.isReady).toBe(true) + + expect(query.pages).toHaveLength(1) + expect(query.data).toHaveLength(PAGE_SIZE) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(3) + expect(query.data).toHaveLength(25) + expect(query.pages[2]).toHaveLength(5) + expect(query.hasNextPage).toBe(false) + }) + }) + + it(`should work with on-demand collection with async loadSubset`, async () => { + const PAGE_SIZE = 10 + const { collection } = createOnDemandCollection({ + id: `on-demand-async-test`, + allPosts: createMockPosts(25), + autoIndex: `eager`, + asyncDelay: 10, + }) + + const query = await new Promise((resolve) => { + const rootCleanup = $effect.root(() => { + const q = useLiveInfiniteQuery( + (queryBuilder: InitialQueryBuilder) => + queryBuilder + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: PAGE_SIZE, + }, + ) + $effect(() => { + if (q.isReady && q.data.length === PAGE_SIZE) { + resolve(q) + } + }) + return () => {} + }) + cleanup = rootCleanup + }) + + expect(query.pages).toHaveLength(1) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + expect(query.isFetchingNextPage).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 50)) + flushSync() + + expect(query.data).toHaveLength(20) + expect(query.pages).toHaveLength(2) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + await new Promise((resolve) => setTimeout(resolve, 50)) + flushSync() + + expect(query.data).toHaveLength(25) + expect(query.pages).toHaveLength(3) + expect(query.hasNextPage).toBe(false) + }) + + it(`should track isFetchingNextPage when async loading is triggered`, async () => { + const PAGE_SIZE = 10 + const allPosts = createMockPosts(30) + const { collection } = createOnDemandCollection({ + id: `async-loading-test-robust`, + allPosts, + asyncDelay: 50, + }) + + const query = await new Promise((resolve) => { + const rootCleanup = $effect.root(() => { + const q = useLiveInfiniteQuery( + (queryBuilder: InitialQueryBuilder) => + queryBuilder + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: PAGE_SIZE, + }, + ) + $effect(() => { + if (q.isReady && !q.isFetchingNextPage) { + resolve(q) + } + }) + return () => {} + }) + cleanup = rootCleanup + }) + + expect(query.pages).toHaveLength(1) + expect(query.data).toHaveLength(PAGE_SIZE) + + query.fetchNextPage() + // Should be fetching now + flushSync() + expect(query.isFetchingNextPage).toBe(true) + + // Wait for loadSubset (50ms) + buffer + await new Promise((resolve) => setTimeout(resolve, 150)) + flushSync() + + expect(query.isFetchingNextPage).toBe(false) + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + }) + + describe(`pre-created collections`, () => { + it(`should fetch multiple pages with pre-created collection`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `pre-created-multi-page-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(10) + .offset(0), + }) + + await liveQueryCollection.preload() + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 10, + }) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.pages[0]).toHaveLength(10) + expect(query.pages[1]).toHaveLength(10) + expect(query.data).toHaveLength(20) + expect(query.hasNextPage).toBe(true) + }) + }) + + it(`should reset pagination when collection instance changes`, () => { + const posts1 = createMockPosts(30) + const collection1 = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `pre-created-reset-1`, + getKey: (post: Post) => post.id, + initialData: posts1, + }), + ) + + const liveQueryCollection1 = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection1 }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(10) + .offset(0), + }) + + const posts2 = createMockPosts(40) + const collection2 = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `pre-created-reset-2`, + getKey: (post: Post) => post.id, + initialData: posts2, + }), + ) + + const liveQueryCollection2 = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection2 }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(10) + .offset(0), + }) + + cleanup = $effect.root(() => { + let coll = $state(liveQueryCollection1) + + const query = useLiveInfiniteQuery(() => coll, { + pageSize: 10, + }) + + flushSync() + + expect(query.isReady).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + + coll = liveQueryCollection2 + flushSync() + + expect(query.pages).toHaveLength(1) + expect(query.data).toHaveLength(10) + }) + }) + + it(`should throw error if collection lacks orderBy`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `no-orderby-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => q.from({ posts: collection }), + }) + + await liveQueryCollection.preload() + + expect(() => { + $effect.root(() => { + useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 10, + }) + flushSync() + }) + }).toThrow(/orderBy/) + }) + + it(`should throw error if first argument is not a collection or function`, () => { + expect(() => { + $effect.root(() => { + useLiveInfiniteQuery(`not a collection or function` as any, { + pageSize: 10, + }) + flushSync() + }) + }).toThrow(/must be either a pre-created live query collection/) + }) + + it(`should work correctly even if pre-created collection has different initial limit`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `mismatched-window-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(5) + .offset(0), + }) + + await liveQueryCollection.preload() + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 10, + }) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(10) + expect(query.data).toHaveLength(10) + expect(query.hasNextPage).toBe(true) + }) + }) + + it(`should handle live updates with pre-created collection`, async () => { + const posts = createMockPosts(30) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `pre-created-live-updates-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(10) + .offset(0), + }) + + await liveQueryCollection.preload() + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery(liveQueryCollection, { + pageSize: 10, + }) + + flushSync() + + expect(query.isReady).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { + id: `new-1`, + title: `New Post`, + content: `New Content`, + createdAt: 1000001, + category: `tech`, + }, + }) + collection.utils.commit() + + flushSync() + + expect(query.pages[0]![0]).toMatchObject({ + id: `new-1`, + title: `New Post`, + }) + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(20) + }) + }) + + it(`should maintain reactivity when destructuring return values with $derived`, () => { + const posts = createMockPosts(20) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `destructure-reactivity-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 5, + }, + ) + + // Destructure with $derived + const { data, hasNextPage, fetchNextPage } = $derived(query) + + flushSync() + + expect(data).toHaveLength(5) + expect(hasNextPage).toBe(true) + + fetchNextPage() + flushSync() + + // Should be reactive + expect(data).toHaveLength(10) + }) + }) + + it(`should react to dynamic pageSize changes`, () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `dynamic-pagesize-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + cleanup = $effect.root(() => { + let pageSize = $state(5) + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + get pageSize() { + return pageSize + }, + }, + ) + + flushSync() + expect(query.pages[0]).toHaveLength(5) + expect(query.data).toHaveLength(5) + + // Change pageSize reactively + pageSize = 10 + flushSync() + + expect(query.pages[0]).toHaveLength(10) + expect(query.data).toHaveLength(10) + }) + }) + + it(`should handle cleanup of infinite query`, () => { + const posts = createMockPosts(10) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `cleanup-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + let query: any + const rootCleanup = $effect.root(() => { + query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 5, + }, + ) + return () => {} + }) + + flushSync() + expect(query.isCleanedUp).toBe(false) + + rootCleanup() + flushSync() + + expect(query.isCleanedUp).toBe(true) + }) + + it(`should work with router loader pattern (preloaded collection)`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `router-loader-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const loaderQuery = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(20), + }) + + await loaderQuery.preload() + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery(loaderQuery, { + pageSize: 20, + }) + + flushSync() + + expect(query.isReady).toBe(true) + expect(query.pages).toHaveLength(1) + expect(query.pages[0]).toHaveLength(20) + expect(query.data).toHaveLength(20) + expect(query.hasNextPage).toBe(true) + + query.fetchNextPage() + flushSync() + + expect(query.pages).toHaveLength(2) + expect(query.data).toHaveLength(40) + }) + }) + }) + + it(`should maintain peek-ahead limit consistency when fetching subsequent pages`, () => { + const PAGE_SIZE = 5 + const allPosts = createMockPosts(20) + const loadSubsetCalls: Array = [] + + const collection = createCollection({ + id: `peek-ahead-consistency`, + getKey: (p) => p.id, + syncMode: `on-demand`, + startSync: true, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady, begin, write, commit }) => { + markReady() + return { + loadSubset: (opts: LoadSubsetOptions) => { + loadSubsetCalls.push({ ...opts }) + // Page-based calculation similar to what a user might do + const limit = opts.limit! + // Use PAGE_SIZE for page calculation + const page = Math.floor(opts.offset! / PAGE_SIZE) + 1 + + // Backend behavior: returns items for the requested page using the PROVIDED limit as page size + // This is common in APIs that use the limit parameter to define the page size for that request. + const start = (page - 1) * limit + const filtered = allPosts.slice(start, start + limit) + + begin() + for (const post of filtered) { + write({ type: `insert`, value: post }) + } + commit() + return true + }, + } + }, + }, + }) + + collection.createIndex((p) => p.createdAt, { indexType: BTreeIndex }) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: PAGE_SIZE, + }, + ) + + flushSync() + expect(query.hasNextPage).toBe(true) + expect(query.data).toHaveLength(5) + + // First call should have limit 6 (pageSize + 1) + expect(loadSubsetCalls[0]!.limit).toBe(6) + + query.fetchNextPage() + flushSync() + + // When fetching page 2, we should still request with peek-ahead. + // For loadedPageCount=2, we expect total limit to be 12 (2 * (pageSize + 1)) + // CollectionSubscriber will then request (12 - currentSize) = 12 - 6 = 6 items. + // If we requested limit 5 here, we would only get 4 new items due to overlap, + // resulting in total size 10 and hasNextPage=false. + expect(loadSubsetCalls[1]!.limit).toBe(6) + expect(query.data).toHaveLength(10) + expect(query.hasNextPage).toBe(true) + }) + }) +})