From a19067e62cb80c39ca6c5cfb5a2a237e07d61d99 Mon Sep 17 00:00:00 2001 From: miguelrk Date: Tue, 7 Apr 2026 16:12:14 -0600 Subject: [PATCH] feat(vue-db): implement `useLiveInfiniteQuery` for vue 3 --- .changeset/vue-infinite-query.md | 56 + packages/vue-db/src/index.ts | 1 + packages/vue-db/src/useLiveInfiniteQuery.ts | 237 +++ packages/vue-db/src/useLiveQuery.ts | 6 +- .../vue-db/tests/useLiveInfiniteQuery.test.ts | 1584 +++++++++++++++++ packages/vue-db/tests/useLiveQuery.test.ts | 6 +- 6 files changed, 1884 insertions(+), 6 deletions(-) create mode 100644 .changeset/vue-infinite-query.md create mode 100644 packages/vue-db/src/useLiveInfiniteQuery.ts create mode 100644 packages/vue-db/tests/useLiveInfiniteQuery.test.ts 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 + + + +``` + +**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()