diff --git a/.changeset/spicy-roses-hide.md b/.changeset/spicy-roses-hide.md new file mode 100644 index 000000000..4e8deea2e --- /dev/null +++ b/.changeset/spicy-roses-hide.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-db': minor +--- + +Add `useLiveInfiniteQuery` as a Svelte binding over the shared live-query window controller. diff --git a/packages/svelte-db/src/index.ts b/packages/svelte-db/src/index.ts index a07f98a64..b7a01e00a 100644 --- a/packages/svelte-db/src/index.ts +++ b/packages/svelte-db/src/index.ts @@ -1,5 +1,6 @@ // Re-export all public APIs export * from './useLiveQuery.svelte.js' +export * from './useLiveInfiniteQuery.svelte.js' // Re-export everything from @tanstack/db export * from '@tanstack/db' diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts new file mode 100644 index 000000000..c3aa60c94 --- /dev/null +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -0,0 +1,293 @@ +import { + createLiveQueryCollection, + createLiveQueryWindowController, + isCollection, +} from '@tanstack/db' +import { untrack } from 'svelte' +// Type-only: used in `ReturnType` below. +import type { + UseLiveQueryReturnWithCollection, + useLiveQuery, +} from './useLiveQuery.svelte.js' +import type { + Collection, + Context, + InferResultType, + InitialQueryBuilder, + NonSingleResult, + QueryBuilder, + UtilsRecord, +} from '@tanstack/db' + +const DEFAULT_PAGE_SIZE = 20 +const DEFAULT_GC_TIME_MS = 1 + +type MaybeGetter = T | (() => T) + +type InternalCollection = Collection + +type WindowedCollection = InternalCollection & { + utils: { + setWindow: (options: { + offset: number + limit: number + }) => true | Promise + getWindow?: () => { offset: number; limit: number } | undefined + } +} + +type ResolvedInput = + | { kind: `collection`; collection: InternalCollection } + | { + kind: `query` + query: (q: InitialQueryBuilder) => QueryBuilder + } + +type InfiniteQueryOptions = { + pageSize?: number + initialPageParam?: number +} + +function hasSetWindow( + collection: InternalCollection, +): collection is WindowedCollection { + return typeof collection.utils.setWindow === `function` +} + +function normalizePageSize(pageSize: number | undefined): number { + if ( + pageSize === undefined || + !Number.isSafeInteger(pageSize) || + pageSize <= 0 + ) { + return DEFAULT_PAGE_SIZE + } + return pageSize +} + +function resolveInput( + input: unknown, +): ResolvedInput { + if (isCollection(input)) { + return { kind: `collection`, collection: input } + } + + if (typeof input !== `function`) { + throw new Error( + `useLiveInfiniteQuery: First argument must be either a pre-created live query collection or a query function. ` + + `Received: ${typeof input}`, + ) + } + + // A zero-argument function can be a reactive collection getter. Query + // callbacks conventionally accept the builder, so avoid probing those. + if (input.length === 0) { + try { + const collection = (input as () => unknown)() + if (isCollection(collection)) { + return { kind: `collection`, collection } + } + } catch { + // Query callbacks that close over their builder can still have arity 0. + } + } + + return { + kind: `query`, + query: input as (q: InitialQueryBuilder) => QueryBuilder, + } +} + +export type LiveInfiniteQueryConfig = InfiniteQueryOptions & { + /** + * @deprecated Pagination uses the shared controller's peek-ahead strategy. + * This remains for compatibility with TanStack Query conventions. + */ + getNextPageParam?: ( + lastPage: Array, + allPages: Array>, + lastPageParam: number, + allPageParams: Array, + ) => number | undefined +} + +export type UseLiveInfiniteQueryConfig = + LiveInfiniteQueryConfig[number]> + +export type UseLiveInfiniteQueryReturn = Omit< + ReturnType>, + `data` +> & { + data: InferResultType + pages: Array[number]>> + pageParams: Array + fetchNextPage: () => Promise + hasNextPage: boolean + isFetchingNextPage: boolean + error: unknown +} + +export type UseLiveInfiniteQueryReturnWithCollection< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +> = Omit< + UseLiveQueryReturnWithCollection>, + `data` +> & { + data: Array + pages: Array> + pageParams: Array + fetchNextPage: () => Promise + hasNextPage: boolean + isFetchingNextPage: boolean + error: unknown +} + +type EnabledLiveQueryReturn = ReturnType< + typeof useLiveQuery +> + +/** + * Create a Svelte-native reactive view over the shared live-query window + * controller. The query must include an `orderBy` clause. + */ +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: MaybeGetter< + Collection & NonSingleResult + >, + config: LiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturnWithCollection + +export function useLiveInfiniteQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + config: UseLiveInfiniteQueryConfig, + deps?: Array<() => unknown>, +): UseLiveInfiniteQueryReturn + +export function useLiveInfiniteQuery( + queryFnOrCollection: unknown, + config: InfiniteQueryOptions, + deps: Array<() => unknown> = [], +): UseLiveInfiniteQueryReturn { + let validatedCollection: InternalCollection | null = null + + const pageSize = $derived(normalizePageSize(config.pageSize)) + const initialPageParam = $derived(config.initialPageParam ?? 0) + + const controller = $derived.by(() => { + for (const dependency of deps) dependency() + + const input = resolveInput(queryFnOrCollection) + if (input.kind === `collection`) { + const collection = input.collection + if (!hasSetWindow(collection)) { + 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.`, + ) + } + + if (validatedCollection !== collection) { + validatedCollection = collection + const currentWindow = collection.utils.getWindow?.() + const expectedLimit = pageSize + 1 + if ( + currentWindow && + (currentWindow.offset !== 0 || currentWindow.limit !== expectedLimit) + ) { + console.warn( + `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + + `but the hook expects {offset: 0, limit: ${expectedLimit}}. Adjusting window now.`, + ) + } + } + return createLiveQueryWindowController(collection, { + pageSize, + initialPageParam, + }) + } + + const collection = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + input + .query(q) + .limit(pageSize + 1) + .offset(0), + startSync: false, + gcTime: DEFAULT_GC_TIME_MS, + }) + return createLiveQueryWindowController(collection, { + pageSize, + initialPageParam, + }) + }) + + let snapshot = $state.raw(untrack(() => controller.getSnapshot())) + + $effect(() => { + const currentController = controller + snapshot = currentController.getSnapshot() + const unsubscribe = currentController.subscribe(() => { + snapshot = currentController.getSnapshot() + }) + snapshot = currentController.getSnapshot() + + return () => { + unsubscribe() + currentController.dispose() + } + }) + + const fetchNextPage = () => controller.fetchNextPage() + + return { + get state() { + return snapshot.state as EnabledLiveQueryReturn[`state`] + }, + get data() { + return snapshot.data as InferResultType + }, + get collection() { + return snapshot.collection as EnabledLiveQueryReturn[`collection`] + }, + get status() { + return snapshot.status as EnabledLiveQueryReturn[`status`] + }, + get isLoading() { + return snapshot.isLoading + }, + get isReady() { + return snapshot.isReady + }, + get isIdle() { + return snapshot.isIdle + }, + get isError() { + return snapshot.isError + }, + get isCleanedUp() { + return snapshot.isCleanedUp + }, + get pages() { + return snapshot.pages as Array[number]>> + }, + get pageParams() { + return snapshot.pageParams as Array + }, + get hasNextPage() { + return snapshot.hasNextPage + }, + get isFetchingNextPage() { + return snapshot.isFetchingNextPage + }, + get error() { + return snapshot.error + }, + fetchNextPage, + } +} diff --git a/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts new file mode 100644 index 000000000..46283eb72 --- /dev/null +++ b/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushSync } from 'svelte' +import { createCollection, createLiveQueryCollection, gt } from '@tanstack/db' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery.svelte.js' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import type { + InitialQueryBuilder, + LiveQueryCollectionUtils, +} from '@tanstack/db' + +type Post = { + id: string + title: string + createdAt: number +} + +function createPosts(count: number): Array { + return Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), + title: `Post ${index + 1}`, + createdAt: count - index, + })) +} + +function createPostsCollection(id: string, count: number) { + return createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id, + getKey: (post) => post.id, + initialData: createPosts(count), + }), + ) +} + +function usePostsInfiniteQuery( + posts: ReturnType, + config: { pageSize?: number; initialPageParam?: number }, +) { + return useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q.from({ posts }).orderBy(({ posts: post }) => post.createdAt, `desc`), + config, + ) +} + +function useFilteredPostsInfiniteQuery( + posts: ReturnType, + minimum: () => number, +) { + return useLiveInfiniteQuery( + (q: InitialQueryBuilder) => + q + .from({ posts }) + .where(({ posts: post }) => gt(post.createdAt, minimum())) + .orderBy(({ posts: post }) => post.createdAt, `desc`), + { pageSize: 3 }, + [minimum], + ) +} + +describe(`useLiveInfiniteQuery`, () => { + let cleanup: (() => void) | undefined + + afterEach(() => { + cleanup?.() + cleanup = undefined + vi.restoreAllMocks() + }) + + function mountPostsQuery( + posts: ReturnType, + config: { pageSize?: number; initialPageParam?: number }, + ): ReturnType { + let query: ReturnType | undefined + cleanup = $effect.root(() => { + query = usePostsInfiniteQuery(posts, config) + }) + flushSync() + if (!query) throw new Error(`Failed to mount infinite query`) + return query + } + + it(`delegates page windows and snapshots to the shared controller`, async () => { + const posts = createPostsCollection(`svelte-infinite-controller`, 12) + const query = mountPostsQuery(posts, { + pageSize: 5, + initialPageParam: 3, + }) + + expect(query.data).toHaveLength(5) + expect(query.pages.map((page) => page.length)).toEqual([5]) + expect(query.pageParams).toEqual([3]) + expect(query.hasNextPage).toBe(true) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit: 6 }) + + await query.fetchNextPage() + flushSync() + + expect(query.data).toHaveLength(10) + expect(query.pages.map((page) => page.length)).toEqual([5, 5]) + expect(query.pageParams).toEqual([3, 4]) + expect(query.hasNextPage).toBe(true) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit: 11 }) + + await query.fetchNextPage() + flushSync() + + expect(query.data).toHaveLength(12) + expect(query.pages.map((page) => page.length)).toEqual([5, 5, 2]) + expect(query.hasNextPage).toBe(false) + }) + + it(`keeps loaded pages live when rows enter or leave the window`, async () => { + const posts = createPostsCollection(`svelte-infinite-live-rows`, 8) + const query = mountPostsQuery(posts, { pageSize: 3 }) + + await query.fetchNextPage() + flushSync() + expect(query.data.map((post) => post.id)).toEqual([ + `1`, + `2`, + `3`, + `4`, + `5`, + `6`, + ]) + + posts.utils.begin() + posts.utils.write({ + type: `insert`, + value: { id: `new`, title: `Newest`, createdAt: 100 }, + }) + posts.utils.commit() + flushSync() + + expect(query.data.map((post) => post.id)).toEqual([ + `new`, + `1`, + `2`, + `3`, + `4`, + `5`, + ]) + expect(query.pages.map((page) => page.length)).toEqual([3, 3]) + }) + + it(`recreates the controller at the first page when a dependency changes`, async () => { + const posts = createPostsCollection(`svelte-infinite-dependency`, 10) + let query: ReturnType | undefined + let setMinimum: ((value: number) => void) | undefined + + cleanup = $effect.root(() => { + let minimum = $state(0) + query = useFilteredPostsInfiniteQuery(posts, () => minimum) + setMinimum = (value) => { + minimum = value + } + }) + flushSync() + if (!query || !setMinimum) throw new Error(`Failed to mount infinite query`) + + await query.fetchNextPage() + flushSync() + expect(query.pages).toHaveLength(2) + + setMinimum(5) + flushSync() + + expect(query.pages).toHaveLength(1) + expect(query.data.map((post) => post.createdAt)).toEqual([10, 9, 8]) + }) + + it.each([ + { label: `empty`, count: 0, pageSize: 5, pageLengths: [0], limit: 6 }, + { label: `single row`, count: 1, pageSize: 5, pageLengths: [1], limit: 6 }, + { + label: `zero page size`, + count: 1, + pageSize: 0, + pageLengths: [1], + limit: 21, + }, + ])( + `handles $label pagination boundaries`, + ({ label, count, pageSize, pageLengths, limit }) => { + const posts = createPostsCollection(`svelte-infinite-${label}`, count) + const query = mountPostsQuery(posts, { pageSize }) + + expect(query.pages.map((page) => page.length)).toEqual(pageLengths) + expect(query.hasNextPage).toBe(false) + expect( + (query.collection.utils as LiveQueryCollectionUtils).getWindow(), + ).toEqual({ offset: 0, limit }) + }, + ) + + it(`accepts a reactive getter for a pre-created ordered collection`, async () => { + const posts = createPostsCollection(`svelte-infinite-precreated`, 7) + const livePosts = createLiveQueryCollection({ + query: (q) => + q + .from({ posts }) + .orderBy(({ posts: post }) => post.createdAt, `desc`) + .limit(2) + .offset(1), + }) + await livePosts.preload() + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + + cleanup = $effect.root(() => { + const query = useLiveInfiniteQuery(() => livePosts, { + pageSize: 3, + getNextPageParam: (lastPage) => lastPage[0]?.createdAt, + }) + flushSync() + + expect(query.collection).toBe(livePosts) + expect(query.data.map((post) => post.id)).toEqual([`1`, `2`, `3`]) + expect(query.state.get(`1`)?.title).toBe(`Post 1`) + expect(query.hasNextPage).toBe(true) + expect(warning).toHaveBeenCalledOnce() + expect(livePosts.utils.getWindow()).toEqual({ offset: 0, limit: 4 }) + }) + }) +})