diff --git a/.changeset/vue-infinite-query.md b/.changeset/vue-infinite-query.md
new file mode 100644
index 000000000..ac72dcefb
--- /dev/null
+++ b/.changeset/vue-infinite-query.md
@@ -0,0 +1,56 @@
+---
+'@tanstack/vue-db': minor
+---
+
+Add `useLiveInfiniteQuery` composable 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 Vue 3 composables (ref, computed, watchEffect).
+- **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` ref and a structured `pages` ref.
+
+**Example usage:**
+
+```vue
+
+
+
+ Loading...
+
+
+
+
+
+
+ {{ isFetchingNextPage ? 'Loading...' : 'Load More' }}
+
+
+
+```
+
+**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/vue-db/src/index.ts b/packages/vue-db/src/index.ts
index 681078b9c..901699306 100644
--- a/packages/vue-db/src/index.ts
+++ b/packages/vue-db/src/index.ts
@@ -1,5 +1,6 @@
// Re-export all public APIs
export * from './useLiveQuery'
+export * from './useLiveInfiniteQuery'
// Re-export everything from @tanstack/db
export * from '@tanstack/db'
diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts
new file mode 100644
index 000000000..4a5af912a
--- /dev/null
+++ b/packages/vue-db/src/useLiveInfiniteQuery.ts
@@ -0,0 +1,237 @@
+import { computed, ref, toValue, watch, watchEffect } from 'vue'
+import { CollectionImpl } from '@tanstack/db'
+import { useLiveQuery } from './useLiveQuery'
+import type {
+ Collection,
+ CollectionStatus,
+ Context,
+ GetResult,
+ InferResultType,
+ InitialQueryBuilder,
+ LiveQueryCollectionUtils,
+ NonSingleResult,
+ QueryBuilder,
+} from '@tanstack/db'
+import type { ComputedRef, MaybeRefOrGetter } from 'vue'
+
+/**
+ * Type guard to check if utils object has setWindow method (LiveQueryCollectionUtils)
+ */
+const isLiveQueryCollectionUtils = (
+ utils: unknown,
+): utils is LiveQueryCollectionUtils => {
+ return typeof (utils as any).setWindow === `function`
+}
+
+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 interface UseLiveInfiniteQueryReturn {
+ state: ComputedRef>>
+ data: ComputedRef>
+ collection: ComputedRef, string | number, {}> | null>
+ status: ComputedRef
+ isLoading: ComputedRef
+ isReady: ComputedRef
+ isIdle: ComputedRef
+ isError: ComputedRef
+ isCleanedUp: ComputedRef
+ pages: ComputedRef[number]>>>
+ pageParams: ComputedRef>
+ fetchNextPage: () => void
+ hasNextPage: ComputedRef
+ isFetchingNextPage: ComputedRef
+}
+
+// Overload for query function
+export function useLiveInfiniteQuery(
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder,
+ config: UseLiveInfiniteQueryConfig,
+ deps?: Array>,
+): UseLiveInfiniteQueryReturn
+
+// Overload for pre-created collection (non-single result)
+export function useLiveInfiniteQuery<
+ TResult extends object,
+ TKey extends string | number,
+ TUtils extends Record,
+>(
+ liveQueryCollection: MaybeRefOrGetter<
+ Collection & NonSingleResult
+ >,
+ config: UseLiveInfiniteQueryConfig,
+): UseLiveInfiniteQueryReturn
+
+// Implementation
+export function useLiveInfiniteQuery(
+ queryFnOrCollection: any,
+ config: UseLiveInfiniteQueryConfig,
+ deps: Array> = [],
+): UseLiveInfiniteQueryReturn {
+ const pageSize = config.pageSize || 20
+ const initialPageParam = config.initialPageParam ?? 0
+
+ // Detect if input is a collection (or ref to collection) vs query function
+ // NOTE: Don't call toValue on functions - toValue treats functions as getters
+ const isCollectionInput =
+ typeof queryFnOrCollection !== `function` &&
+ toValue(queryFnOrCollection) instanceof CollectionImpl
+
+ if (!isCollectionInput && typeof queryFnOrCollection !== `function`) {
+ throw new Error(
+ `useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` +
+ `or a query function. Received: ${typeof queryFnOrCollection}`,
+ )
+ }
+
+ const loadedPageCount = ref(1)
+ const isFetchingNextPage = ref(false)
+ let hasValidatedCollection = false
+
+ // Delegate to useLiveQuery for the underlying subscription
+ // For query functions, add peek-ahead limit (+1) for hasNextPage detection
+ const queryResult = isCollectionInput
+ ? useLiveQuery(queryFnOrCollection)
+ : useLiveQuery(
+ (q: any) =>
+ queryFnOrCollection(q)
+ .limit(pageSize + 1)
+ .offset(0),
+ deps,
+ )
+
+ // Reset pagination when collection instance changes (deps change, collection swap, etc.)
+ watch(queryResult.collection, () => {
+ loadedPageCount.value = 1
+ hasValidatedCollection = false
+ })
+
+ // Adjust window when pagination state changes
+ watchEffect((onInvalidate) => {
+ const currentCollection = queryResult.collection.value
+ if (!currentCollection) return
+
+ if (!isCollectionInput && !queryResult.isReady.value) return
+
+ const utils = (currentCollection as any).utils
+ const expectedOffset = 0
+ const expectedLimit = loadedPageCount.value * pageSize + 1 // +1 for peek ahead
+
+ if (!isLiveQueryCollectionUtils(utils)) {
+ if (isCollectionInput) {
+ 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
+ }
+
+ // For pre-created collections, validate window on first check
+ if (isCollectionInput && !hasValidatedCollection) {
+ const currentWindow = utils.getWindow()
+ if (
+ currentWindow &&
+ (currentWindow.offset !== expectedOffset ||
+ currentWindow.limit !== expectedLimit)
+ ) {
+ console.warn(
+ `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` +
+ `but hook expects {offset: ${expectedOffset}, limit: ${expectedLimit}}. Adjusting window now.`,
+ )
+ }
+ hasValidatedCollection = true
+ }
+
+ let cancelled = false
+ const result = utils.setWindow({
+ offset: expectedOffset,
+ limit: expectedLimit,
+ })
+
+ if (result !== true) {
+ isFetchingNextPage.value = true
+ result
+ .catch((error: unknown) => {
+ if (!cancelled)
+ console.error(`useLiveInfiniteQuery: setWindow failed:`, error)
+ })
+ .finally(() => {
+ if (!cancelled) isFetchingNextPage.value = false
+ })
+ } else {
+ isFetchingNextPage.value = false
+ }
+
+ onInvalidate(() => {
+ cancelled = true
+ })
+ })
+
+ // Derive pages, pageParams, hasNextPage, and flat data from query results
+ const paginatedData = computed(() => {
+ const rawData = queryResult.data.value
+ const dataArray = (
+ Array.isArray(rawData) ? rawData : []
+ ) as InferResultType
+ const totalItemsRequested = loadedPageCount.value * pageSize
+
+ const hasMore = dataArray.length > totalItemsRequested
+
+ const pagesResult: Array[number]>> = []
+ const pageParamsResult: Array = []
+
+ for (let i = 0; i < loadedPageCount.value; i++) {
+ const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize)
+ pagesResult.push(pageData)
+ pageParamsResult.push(initialPageParam + i)
+ }
+
+ const flatDataResult = dataArray.slice(
+ 0,
+ totalItemsRequested,
+ ) as InferResultType
+
+ return {
+ pages: pagesResult,
+ pageParams: pageParamsResult,
+ hasNextPage: hasMore,
+ flatData: flatDataResult,
+ }
+ })
+
+ const fetchNextPage = () => {
+ if (!paginatedData.value.hasNextPage || isFetchingNextPage.value) return
+ loadedPageCount.value++
+ }
+
+ return {
+ state: queryResult.state,
+ data: computed(() => paginatedData.value.flatData),
+ collection: queryResult.collection,
+ status: queryResult.status,
+ isLoading: queryResult.isLoading,
+ isReady: queryResult.isReady,
+ isIdle: queryResult.isIdle,
+ isError: queryResult.isError,
+ isCleanedUp: queryResult.isCleanedUp,
+ pages: computed(() => paginatedData.value.pages),
+ pageParams: computed(() => paginatedData.value.pageParams),
+ fetchNextPage,
+ hasNextPage: computed(() => paginatedData.value.hasNextPage),
+ isFetchingNextPage: computed(() => isFetchingNextPage.value),
+ } as unknown as UseLiveInfiniteQueryReturn
+}
diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts
index 479c92b2b..66433702e 100644
--- a/packages/vue-db/src/useLiveQuery.ts
+++ b/packages/vue-db/src/useLiveQuery.ts
@@ -40,7 +40,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'
export interface UseLiveQueryReturn {
state: ComputedRef>>
data: ComputedRef>
- collection: ComputedRef, string | number, {}>>
+ collection: ComputedRef, string | number, {}> | null>
status: ComputedRef
isLoading: ComputedRef
isReady: ComputedRef
@@ -56,7 +56,7 @@ export interface UseLiveQueryReturnWithCollection<
> {
state: ComputedRef>
data: ComputedRef>
- collection: ComputedRef>
+ collection: ComputedRef | null>
status: ComputedRef
isLoading: ComputedRef
isReady: ComputedRef
@@ -72,7 +72,7 @@ export interface UseLiveQueryReturnWithSingleResultCollection<
> {
state: ComputedRef>
data: ComputedRef
- collection: ComputedRef & SingleResult>
+ collection: ComputedRef<(Collection & SingleResult) | null>
status: ComputedRef
isLoading: ComputedRef
isReady: ComputedRef
diff --git a/packages/vue-db/tests/useLiveInfiniteQuery.test.ts b/packages/vue-db/tests/useLiveInfiniteQuery.test.ts
new file mode 100644
index 000000000..451d3f830
--- /dev/null
+++ b/packages/vue-db/tests/useLiveInfiniteQuery.test.ts
@@ -0,0 +1,1584 @@
+import { describe, expect, it } from 'vitest'
+import { nextTick, ref } from 'vue'
+import {
+ BTreeIndex,
+ createCollection,
+ createLiveQueryCollection,
+ eq,
+} from '@tanstack/db'
+import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events'
+import type { LoadSubsetOptions } from '@tanstack/db'
+
+type Post = {
+ id: string
+ title: string
+ content: string
+ createdAt: number
+ category: string
+}
+
+const 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
+}
+
+const 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)
+ }
+
+ const 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 }
+}
+
+async function waitForVueUpdate() {
+ await nextTick()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+}
+
+async function waitFor(fn: () => void, timeout = 2000, interval = 20) {
+ const start = Date.now()
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ while (true) {
+ try {
+ fn()
+ return
+ } catch (err) {
+ if (Date.now() - start > timeout) throw err
+ await new Promise((resolve) => setTimeout(resolve, interval))
+ }
+ }
+}
+
+describe(`useLiveInfiniteQuery`, () => {
+ it(`should fetch initial page of data`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-initial-page-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .select(({ posts: p }) => ({
+ id: p.id,
+ title: p.title,
+ createdAt: p.createdAt,
+ })),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(10)
+ expect(result.data.value).toHaveLength(10)
+ expect(result.hasNextPage.value).toBe(true)
+
+ expect(result.pages.value[0]![0]).toMatchObject({
+ id: `1`,
+ title: `Post 1`,
+ })
+ })
+
+ it(`should fetch multiple pages`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-multiple-pages-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.pages.value[0]).toHaveLength(10)
+ expect(result.pages.value[1]).toHaveLength(10)
+ expect(result.data.value).toHaveLength(20)
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(3)
+ })
+
+ expect(result.data.value).toHaveLength(30)
+ expect(result.hasNextPage.value).toBe(true)
+ })
+
+ it(`should detect when no more pages available`, async () => {
+ const posts = createMockPosts(25)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-no-more-pages-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.pages.value[1]).toHaveLength(10)
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(3)
+ })
+
+ expect(result.pages.value[2]).toHaveLength(5)
+ expect(result.data.value).toHaveLength(25)
+ expect(result.hasNextPage.value).toBe(false)
+ })
+
+ it(`should handle empty results`, async () => {
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-empty-results-test`,
+ getKey: (post: Post) => post.id,
+ initialData: [],
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(0)
+ expect(result.data.value).toHaveLength(0)
+ expect(result.hasNextPage.value).toBe(false)
+ })
+
+ it(`should update pages when underlying data changes`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-live-updates-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.data.value).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()
+
+ await waitFor(() => {
+ expect(result.pages.value[0]![0]).toMatchObject({
+ id: `new-1`,
+ title: `New Post`,
+ })
+ })
+
+ expect(result.pages.value).toHaveLength(2)
+ expect(result.data.value).toHaveLength(20)
+ expect(result.pages.value[0]).toHaveLength(10)
+ expect(result.pages.value[1]).toHaveLength(10)
+ })
+
+ it(`should handle deletions across pages`, async () => {
+ const posts = createMockPosts(25)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-deletions-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.data.value).toHaveLength(20)
+ const firstItemId = result.data.value[0]!.id
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[0]!,
+ })
+ collection.utils.commit()
+
+ await waitFor(() => {
+ expect(result.data.value[0]!.id).not.toBe(firstItemId)
+ })
+
+ expect(result.pages.value).toHaveLength(2)
+ expect(result.data.value).toHaveLength(20)
+ expect(result.pages.value[0]).toHaveLength(10)
+ expect(result.pages.value[1]).toHaveLength(10)
+ })
+
+ it(`should handle deletion from partial page with descending order`, async () => {
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-partial-page-deletion-desc-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 20,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 20 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.data.value).toHaveLength(5)
+ expect(result.hasNextPage.value).toBe(false)
+
+ const firstItemId = result.data.value[0]!.id
+ expect(firstItemId).toBe(`1`)
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[0]!,
+ })
+ collection.utils.commit()
+
+ await waitFor(() => {
+ expect(result.data.value).toHaveLength(4)
+ })
+
+ expect(
+ result.data.value.find((p) => p.id === firstItemId),
+ ).toBeUndefined()
+ expect(result.data.value[0]!.id).toBe(`2`)
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(4)
+ expect(result.hasNextPage.value).toBe(false)
+ })
+
+ it(`should handle deletion from partial page with ascending order`, async () => {
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-partial-page-deletion-asc-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `asc`),
+ {
+ pageSize: 20,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 20 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.data.value).toHaveLength(5)
+ expect(result.hasNextPage.value).toBe(false)
+
+ const firstItemId = result.data.value[0]!.id
+ expect(firstItemId).toBe(`5`)
+
+ collection.utils.begin()
+ collection.utils.write({
+ type: `delete`,
+ value: posts[4]!,
+ })
+ collection.utils.commit()
+
+ await waitFor(() => {
+ expect(result.data.value).toHaveLength(4)
+ })
+
+ expect(
+ result.data.value.find((p) => p.id === firstItemId),
+ ).toBeUndefined()
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(4)
+ expect(result.hasNextPage.value).toBe(false)
+ })
+
+ it(`should work with where clauses`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-where-clause-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .where(({ posts: p }) => eq(p.category, `tech`))
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 5 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(5)
+
+ result.pages.value[0]!.forEach((post) => {
+ expect(post.category).toBe(`tech`)
+ })
+
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.data.value).toHaveLength(10)
+ })
+
+ it(`should re-execute query when dependencies change`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-deps-change-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const category = ref(`tech`)
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .where(({ posts: p }) => eq(p.category, category.value))
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 5,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 5 ? lastPage.length : undefined,
+ },
+ [category],
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ // Change category to life
+ category.value = `life`
+
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(1)
+ })
+
+ result.pages.value[0]!.forEach((post) => {
+ expect(post.category).toBe(`life`)
+ })
+ })
+
+ it(`should track pageParams correctly`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-page-params-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ initialPageParam: 0,
+ getNextPageParam: (lastPage, _allPages, lastPageParam) =>
+ lastPage.length === 10 ? lastPageParam + 1 : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pageParams.value).toEqual([0])
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pageParams.value).toEqual([0, 1])
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pageParams.value).toEqual([0, 1, 2])
+ })
+ })
+
+ it(`should handle exact page size boundaries`, async () => {
+ const posts = createMockPosts(20)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-exact-boundary-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage, allPages) => {
+ if (lastPage.length < 10) return undefined
+ return allPages.flat().length
+ },
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.pages.value[1]).toHaveLength(10)
+ expect(result.hasNextPage.value).toBe(false)
+ expect(result.data.value).toHaveLength(20)
+ })
+
+ it(`should not fetch when already fetching`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-concurrent-fetch-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(3)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(4)
+ })
+
+ expect(result.pages.value).toHaveLength(4)
+ expect(result.data.value).toHaveLength(40)
+ })
+
+ it(`should not fetch when hasNextPage is false`, async () => {
+ const posts = createMockPosts(5)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-no-fetch-when-done-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.hasNextPage.value).toBe(false)
+ expect(result.pages.value).toHaveLength(1)
+
+ result.fetchNextPage()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(result.pages.value).toHaveLength(1)
+ })
+
+ it(`should support custom initialPageParam`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-initial-param-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ initialPageParam: 100,
+ getNextPageParam: (lastPage, _allPages, lastPageParam) =>
+ lastPage.length === 10 ? lastPageParam + 1 : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pageParams.value).toEqual([100])
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pageParams.value).toEqual([100, 101])
+ })
+ })
+
+ it(`should detect hasNextPage change when new items are synced`, async () => {
+ const posts = createMockPosts(20)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-sync-detection-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.hasNextPage.value).toBe(false)
+ expect(result.data.value).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()
+
+ await waitFor(() => {
+ expect(result.hasNextPage.value).toBe(true)
+ })
+
+ expect(result.data.value).toHaveLength(20)
+ expect(result.pages.value).toHaveLength(2)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(3)
+ })
+
+ expect(result.pages.value[2]).toHaveLength(5)
+ expect(result.data.value).toHaveLength(25)
+ expect(result.hasNextPage.value).toBe(false)
+ })
+
+ it(`should set isFetchingNextPage to false when data is immediately available`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-immediate-data-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.isFetchingNextPage.value).toBe(false)
+
+ result.fetchNextPage()
+ await waitForVueUpdate()
+
+ expect(result.pages.value).toHaveLength(2)
+ expect(result.isFetchingNextPage.value).toBe(false)
+ })
+
+ it(`should request limit+1 (peek-ahead) from loadSubset for hasNextPage detection`, async () => {
+ const PAGE_SIZE = 10
+ const { collection, loadSubsetCalls } = createOnDemandCollection({
+ id: `vue-peek-ahead-limit-test`,
+ allPosts: createMockPosts(PAGE_SIZE),
+ })
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ const callWithLimit = loadSubsetCalls.find(
+ (call) => call.limit !== undefined,
+ )
+ expect(callWithLimit).toBeDefined()
+ expect(callWithLimit!.limit).toBe(PAGE_SIZE + 1)
+
+ expect(result.hasNextPage.value).toBe(false)
+ expect(result.data.value).toHaveLength(PAGE_SIZE)
+ })
+
+ it(`should detect hasNextPage via peek-ahead with exactly pageSize+1 items in on-demand collection`, async () => {
+ const PAGE_SIZE = 10
+ const { collection } = createOnDemandCollection({
+ id: `vue-peek-ahead-boundary-test`,
+ allPosts: createMockPosts(PAGE_SIZE + 1),
+ })
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.hasNextPage.value).toBe(true)
+ expect(result.data.value).toHaveLength(PAGE_SIZE)
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(PAGE_SIZE)
+ })
+
+ it(`should work with on-demand collection and fetch multiple pages`, async () => {
+ const PAGE_SIZE = 10
+ const { collection, loadSubsetCalls } = createOnDemandCollection({
+ id: `vue-on-demand-e2e-test`,
+ allPosts: createMockPosts(25),
+ autoIndex: `eager`,
+ })
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.data.value).toHaveLength(PAGE_SIZE)
+ expect(result.hasNextPage.value).toBe(true)
+ expect(result.data.value[0]!.id).toBe(`1`)
+ expect(result.data.value[9]!.id).toBe(`10`)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(loadSubsetCalls.length).toBeGreaterThan(1)
+ expect(result.data.value).toHaveLength(20)
+ expect(result.hasNextPage.value).toBe(true)
+ expect(result.pages.value[1]![0]!.id).toBe(`11`)
+ expect(result.pages.value[1]![9]!.id).toBe(`20`)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(3)
+ })
+
+ expect(result.data.value).toHaveLength(25)
+ expect(result.pages.value[2]).toHaveLength(5)
+ expect(result.hasNextPage.value).toBe(false)
+ expect(result.pages.value[2]![0]!.id).toBe(`21`)
+ expect(result.pages.value[2]![4]!.id).toBe(`25`)
+ })
+
+ it(`should work with on-demand collection with async loadSubset`, async () => {
+ const PAGE_SIZE = 10
+ const { collection, loadSubsetCalls } = createOnDemandCollection({
+ id: `vue-on-demand-async-test`,
+ allPosts: createMockPosts(25),
+ autoIndex: `eager`,
+ asyncDelay: 10,
+ })
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: PAGE_SIZE,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === PAGE_SIZE ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ await waitFor(() => {
+ expect(result.data.value).toHaveLength(PAGE_SIZE)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.hasNextPage.value).toBe(true)
+
+ const initialCallCount = loadSubsetCalls.length
+
+ result.fetchNextPage()
+ await nextTick()
+
+ await waitFor(
+ () => {
+ expect(result.data.value).toHaveLength(20)
+ },
+ 5000,
+ )
+
+ expect(result.pages.value).toHaveLength(2)
+ expect(loadSubsetCalls.length).toBeGreaterThan(initialCallCount)
+ expect(result.hasNextPage.value).toBe(true)
+
+ const callCountBeforePage3 = loadSubsetCalls.length
+
+ result.fetchNextPage()
+
+ await waitFor(
+ () => {
+ expect(result.data.value).toHaveLength(25)
+ },
+ 5000,
+ )
+
+ expect(result.pages.value).toHaveLength(3)
+ expect(result.pages.value[2]).toHaveLength(5)
+ expect(loadSubsetCalls.length).toBeGreaterThan(callCountBeforePage3)
+ expect(result.hasNextPage.value).toBe(false)
+ })
+
+ it(`should track isFetchingNextPage when async loading is triggered`, async () => {
+ const allPosts = createMockPosts(30)
+
+ const collection = createCollection({
+ id: `vue-async-loading-test`,
+ getKey: (post: Post) => post.id,
+ syncMode: `on-demand`,
+ startSync: true,
+ autoIndex: `eager`,
+ defaultIndexType: BTreeIndex,
+ sync: {
+ sync: ({ markReady, begin, write, commit }) => {
+ begin()
+ const initialPosts = allPosts.slice(0, 15)
+ for (const post of initialPosts) {
+ write({ type: `insert`, value: post })
+ }
+ commit()
+ markReady()
+
+ return {
+ loadSubset: (opts: LoadSubsetOptions) => {
+ let filtered = allPosts
+
+ if (opts.where) {
+ const filterFn = createFilterFunctionFromExpression(opts.where)
+ filtered = filtered.filter(filterFn)
+ }
+
+ if (opts.orderBy && opts.orderBy.length > 0) {
+ filtered = filtered.sort((a, b) => b.createdAt - a.createdAt)
+ }
+
+ if (opts.cursor) {
+ const { whereFrom, whereCurrent } = opts.cursor
+ try {
+ const whereFromFn =
+ createFilterFunctionFromExpression(whereFrom)
+ const fromData = filtered.filter(whereFromFn)
+
+ const whereCurrentFn =
+ createFilterFunctionFromExpression(whereCurrent)
+ const currentData = filtered.filter(whereCurrentFn)
+
+ const seenIds = new Set()
+ filtered = []
+ for (const item of currentData) {
+ if (!seenIds.has(item.id)) {
+ seenIds.add(item.id)
+ filtered.push(item)
+ }
+ }
+ const limitedFromData = opts.limit
+ ? fromData.slice(0, opts.limit)
+ : fromData
+ for (const item of limitedFromData) {
+ if (!seenIds.has(item.id)) {
+ seenIds.add(item.id)
+ filtered.push(item)
+ }
+ }
+ filtered.sort((a, b) => b.createdAt - a.createdAt)
+ } catch (e) {
+ throw new Error(`Test loadSubset: cursor parsing failed`, {
+ cause: e,
+ })
+ }
+ } else if (opts.limit !== undefined) {
+ filtered = filtered.slice(0, opts.limit)
+ }
+
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ begin()
+ for (const post of filtered) {
+ write({ type: `insert`, value: post })
+ }
+ commit()
+ resolve()
+ }, 50)
+ })
+ },
+ }
+ },
+ },
+ })
+
+ const result = useLiveInfiniteQuery(
+ (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`),
+ {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ },
+ )
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ await waitFor(() => {
+ expect(result.isFetchingNextPage.value).toBe(false)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+
+ result.fetchNextPage()
+ await nextTick()
+
+ await waitFor(
+ () => {
+ expect(result.isFetchingNextPage.value).toBe(true)
+ },
+ 500,
+ )
+
+ await waitFor(
+ () => {
+ expect(result.isFetchingNextPage.value).toBe(false)
+ },
+ 5000,
+ )
+
+ expect(result.pages.value).toHaveLength(2)
+ expect(result.data.value).toHaveLength(20)
+ }, 10000)
+
+ describe(`pre-created collections`, () => {
+ it(`should accept pre-created live query collection`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-pre-created-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(5),
+ })
+
+ await liveQueryCollection.preload()
+
+ const result = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(10)
+ expect(result.data.value).toHaveLength(10)
+ expect(result.hasNextPage.value).toBe(true)
+
+ expect(result.pages.value[0]![0]).toMatchObject({
+ id: `1`,
+ title: `Post 1`,
+ })
+ })
+
+ it(`should fetch multiple pages with pre-created collection`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-pre-created-multi-page-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ const result = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.pages.value[0]).toHaveLength(10)
+ expect(result.pages.value[1]).toHaveLength(10)
+ expect(result.data.value).toHaveLength(20)
+ expect(result.hasNextPage.value).toBe(true)
+ })
+
+ it(`should reset pagination when collection instance changes`, async () => {
+ const posts1 = createMockPosts(30)
+ const collection1 = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-pre-created-reset-1`,
+ getKey: (post: Post) => post.id,
+ initialData: posts1,
+ }),
+ )
+
+ const liveQueryCollection1 = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection1 })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection1.preload()
+
+ const posts2 = createMockPosts(40)
+ const collection2 = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-pre-created-reset-2`,
+ getKey: (post: Post) => post.id,
+ initialData: posts2,
+ }),
+ )
+
+ const liveQueryCollection2 = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection2 })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection2.preload()
+
+ const collectionRef = ref(liveQueryCollection1) as any
+
+ const result = useLiveInfiniteQuery(collectionRef, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.data.value).toHaveLength(20)
+
+ // Switch to second collection
+ collectionRef.value = liveQueryCollection2
+
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(1)
+ })
+
+ expect(result.data.value).toHaveLength(10)
+ })
+
+ it(`should throw error if collection lacks orderBy`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-no-orderby-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) => q.from({ posts: collection }),
+ })
+
+ await liveQueryCollection.preload()
+
+ expect(() => {
+ useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ }).toThrow(/ORDER BY/)
+ })
+
+ it(`should throw error if first argument is not a collection or function`, () => {
+ expect(() => {
+ useLiveInfiniteQuery(`not a collection or function` as any, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ }).toThrow(/must be either a pre-created live query collection/)
+
+ expect(() => {
+ useLiveInfiniteQuery(123 as any, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ }).toThrow(/must be either a pre-created live query collection/)
+
+ expect(() => {
+ useLiveInfiniteQuery(null as any, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+ }).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: `vue-mismatched-window-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(5)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ const result = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(10)
+ expect(result.data.value).toHaveLength(10)
+ expect(result.hasNextPage.value).toBe(true)
+ })
+
+ it(`should handle live updates with pre-created collection`, async () => {
+ const posts = createMockPosts(30)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-pre-created-live-updates-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const liveQueryCollection = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(10)
+ .offset(0),
+ })
+
+ await liveQueryCollection.preload()
+
+ const result = useLiveInfiniteQuery(liveQueryCollection, {
+ pageSize: 10,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 10 ? lastPage.length : undefined,
+ })
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.data.value).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()
+
+ await waitFor(() => {
+ expect(result.pages.value[0]![0]).toMatchObject({
+ id: `new-1`,
+ title: `New Post`,
+ })
+ })
+
+ expect(result.pages.value).toHaveLength(2)
+ expect(result.data.value).toHaveLength(20)
+ })
+
+ it(`should work with router loader pattern (preloaded collection)`, async () => {
+ const posts = createMockPosts(50)
+ const collection = createCollection(
+ mockSyncCollectionOptions({
+ autoIndex: `eager`,
+ id: `vue-router-loader-test`,
+ getKey: (post: Post) => post.id,
+ initialData: posts,
+ }),
+ )
+
+ const loaderQuery = createLiveQueryCollection({
+ query: (q) =>
+ q
+ .from({ posts: collection })
+ .orderBy(({ posts: p }) => p.createdAt, `desc`)
+ .limit(20),
+ })
+
+ await loaderQuery.preload()
+
+ const result = useLiveInfiniteQuery(loaderQuery, {
+ pageSize: 20,
+ getNextPageParam: (lastPage) =>
+ lastPage.length === 20 ? lastPage.length : undefined,
+ })
+
+ await waitFor(() => {
+ expect(result.isReady.value).toBe(true)
+ })
+
+ expect(result.pages.value).toHaveLength(1)
+ expect(result.pages.value[0]).toHaveLength(20)
+ expect(result.data.value).toHaveLength(20)
+ expect(result.hasNextPage.value).toBe(true)
+
+ result.fetchNextPage()
+ await waitFor(() => {
+ expect(result.pages.value).toHaveLength(2)
+ })
+
+ expect(result.data.value).toHaveLength(40)
+ })
+ })
+})
diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts
index 57b8ae57b..17832ca95 100644
--- a/packages/vue-db/tests/useLiveQuery.test.ts
+++ b/packages/vue-db/tests/useLiveQuery.test.ts
@@ -489,7 +489,7 @@ describe(`Query Collections`, () => {
// Grouped query derived from initial query
const { state: groupedState } = useLiveQuery((q) =>
q
- .from({ queryResult: initialCollection.value })
+ .from({ queryResult: initialCollection.value! })
.groupBy(({ queryResult }) => queryResult.team)
.select(({ queryResult }) => ({
team: queryResult.team,
@@ -813,7 +813,7 @@ describe(`Query Collections`, () => {
id: `3`,
name: `John Smith`,
})
- expect(returnedCollection.value.id).toBe(liveQueryCollection1.id)
+ expect(returnedCollection.value!.id).toBe(liveQueryCollection1.id)
// Switch to the second collection by updating the reactive ref
currentCollection.value = liveQueryCollection2 as any
@@ -830,7 +830,7 @@ describe(`Query Collections`, () => {
id: `5`,
name: `Bob Dylan`,
})
- expect(returnedCollection.value.id).toBe(liveQueryCollection2.id)
+ expect(returnedCollection.value!.id).toBe(liveQueryCollection2.id)
// Verify we no longer have data from the first collection
expect(state.value.get(`3`)).toBeUndefined()