/** * Infinite Query Factory - Generic wrapper for React Query paginated canister data * * Creates unified fetch/hook/invalidate functions for any paginated canister method. * Works with any Reactor instance. * * @example * const postsQuery = createInfiniteQuery(reactor, { * functionName: "get_posts", * initialPageParam: 0, * getArgs: (cursor) => [{ cursor, limit: 10 }], * getNextPageParam: (lastPage) => lastPage.nextCursor, * }) * * // In component * const { data, fetchNextPage, hasNextPage } = postsQuery.useInfiniteQuery() * * // Flatten all pages * const allPosts = data?.pages.flatMap(page => page.posts) * * // Invalidate cache * postsQuery.invalidate() */ import type { Reactor, FunctionName, ReactorArgs, BaseActor, TransformKey, ReactorReturnOk, ReactorReturnErr, } from "@ic-reactor/core" import { QueryKey, useInfiniteQuery, InfiniteData, UseInfiniteQueryResult, UseInfiniteQueryOptions, QueryFunctionContext, FetchInfiniteQueryOptions, InfiniteQueryObserverOptions, } from "@tanstack/react-query" import { CallConfig } from "@icp-sdk/core/agent" import { NoInfer } from "./types" import { mergeFactoryQueryKey } from "./utils" type InfiniteQueryFactoryFn< A, M extends FunctionName, T extends TransformKey, TPageParam, TSelected, > = { ( getArgs: (pageParam: TPageParam) => ReactorArgs ): InfiniteQueryResult< InfiniteQueryPageData, TPageParam, TSelected, InfiniteQueryError > } // ============================================================================ // Type Definitions // ============================================================================ /** The raw page data type returned by the query function */ export type InfiniteQueryPageData< A = BaseActor, M extends FunctionName = FunctionName, T extends TransformKey = "candid", > = ReactorReturnOk /** The error type for infinite queries */ export type InfiniteQueryError< A = BaseActor, M extends FunctionName = FunctionName, T extends TransformKey = "candid", > = ReactorReturnErr // ============================================================================ // Configuration Types // ============================================================================ /** * Configuration for createActorInfiniteQuery. * Extends InfiniteQueryObserverOptions to accept standard TanStack Query * infinite-query options at the create level (e.g. refetchInterval, * refetchOnMount, refetchOnWindowFocus, retry, gcTime, networkMode). * * @template A - The actor interface type * @template M - The method name on the actor * @template T - The transformation key (identity, display, etc.) * @template TPageParam - The type of the page parameter * @template TSelected - The type returned after select transformation */ export interface InfiniteQueryConfig< A = BaseActor, M extends FunctionName = FunctionName, T extends TransformKey = "candid", TPageParam = unknown, TSelected = InfiniteData, TPageParam>, > extends Omit< InfiniteQueryObserverOptions< InfiniteQueryPageData, InfiniteQueryError, TSelected, QueryKey, TPageParam >, "queryKey" | "queryFn" > { /** The method to call on the canister */ functionName: M /** Call configuration for the actor method */ callConfig?: CallConfig /** Custom query key (optional, auto-generated if not provided) */ queryKey?: QueryKey /** Initial page parameter */ initialPageParam: TPageParam /** Function to get args from page parameter */ getArgs: (pageParam: TPageParam) => ReactorArgs /** Function to determine next page parameter */ getNextPageParam: ( lastPage: InfiniteQueryPageData, allPages: InfiniteQueryPageData[], lastPageParam: TPageParam, allPageParams: TPageParam[] ) => TPageParam | undefined | null /** Function to determine previous page parameter (for bi-directional) */ getPreviousPageParam?: ( firstPage: InfiniteQueryPageData, allPages: InfiniteQueryPageData[], firstPageParam: TPageParam, allPageParams: TPageParam[] ) => TPageParam | undefined | null } /** * Configuration for createActorInfiniteQueryFactory (without initialPageParam, getArgs determined at call time). */ export type InfiniteQueryFactoryConfig< A = BaseActor, M extends FunctionName = FunctionName, T extends TransformKey = "candid", TPageParam = unknown, TSelected = InfiniteData, TPageParam>, > = Omit, "getArgs"> & { /** * Optional key-args derivation for factory calls. * Receives the resolved args from `getArgs(initialPageParam)` and should return * a stable serializable representation of the logical query identity * (typically excluding pagination/cursor fields). */ getKeyArgs?: (args: ReactorArgs) => unknown } // ============================================================================ // Hook Interface // ============================================================================ /** * useInfiniteQuery hook with chained select support. */ export interface UseInfiniteQueryWithSelect< TPageData, TPageParam, TSelected = InfiniteData, TError = Error, > { // Overload 1: Without select - returns TSelected ( options?: Omit< UseInfiniteQueryOptions< TPageData, TError, TSelected, QueryKey, TPageParam >, | "select" | "queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam" | "getPreviousPageParam" > ): UseInfiniteQueryResult // Overload 2: With select - chains on top and returns TFinal ( options: Omit< UseInfiniteQueryOptions, | "queryKey" | "queryFn" | "select" | "initialPageParam" | "getNextPageParam" | "getPreviousPageParam" > & { select: (data: TSelected) => TFinal } ): UseInfiniteQueryResult } // ============================================================================ // Result Interface // ============================================================================ /** * Result from createActorInfiniteQuery * * @template TPageData - The raw page data type * @template TPageParam - The page parameter type * @template TSelected - The type after select transformation * @template TError - The error type */ export interface InfiniteQueryResult< TPageData, TPageParam, TSelected = InfiniteData, TError = Error, > { /** Fetch first page in loader (uses ensureQueryData for cache-first) */ fetch: () => Promise /** React hook for components - supports pagination */ useInfiniteQuery: UseInfiniteQueryWithSelect< TPageData, TPageParam, TSelected, TError > /** Invalidate the cache (refetches if query is active) */ invalidate: () => Promise /** Get query key (for advanced React Query usage) */ getQueryKey: () => QueryKey /** * Read data directly from cache without fetching. * Returns undefined if data is not in cache. */ getCacheData: { (): TSelected | undefined (select: (data: TSelected) => TFinal): TFinal | undefined } } // ============================================================================ // Internal Implementation // ============================================================================ const createInfiniteQueryImpl = < A, M extends FunctionName = FunctionName, T extends TransformKey = "candid", TPageParam = unknown, TSelected = InfiniteData, TPageParam>, >( reactor: Reactor, config: InfiniteQueryConfig ): InfiniteQueryResult< InfiniteQueryPageData, TPageParam, TSelected, InfiniteQueryError > => { type TPageData = InfiniteQueryPageData type TError = InfiniteQueryError type TInfiniteData = InfiniteData const { functionName, callConfig, queryKey: customQueryKey, initialPageParam, getArgs, getNextPageParam, getPreviousPageParam, maxPages, staleTime = 5 * 60 * 1000, select, ...rest } = config // Get query key from actor manager const getQueryKey = (): QueryKey => { return reactor.generateQueryKey( { functionName, queryKey: customQueryKey, }, callConfig ) } // Query function - accepts QueryFunctionContext const queryFn = async ( context: QueryFunctionContext ): Promise => { // pageParam is typed as unknown in QueryFunctionContext, but we know its type const pageParam = context.pageParam as TPageParam const args = getArgs(pageParam) const result = await reactor.callMethod({ functionName, args, callConfig, }) return result } // Get infinite query options for fetchInfiniteQuery const getInfiniteQueryOptions = (): FetchInfiniteQueryOptions< TPageData, TError, TPageData, QueryKey, TPageParam > => ({ queryKey: getQueryKey(), queryFn, initialPageParam, getNextPageParam, }) // Fetch function for loaders (cache-first, fetches first page) const fetch = async (): Promise => { // Check cache first const cachedData = reactor.queryClient.getQueryData(getQueryKey()) as | TInfiniteData | undefined if (cachedData !== undefined) { return select ? select(cachedData) : (cachedData as TSelected) } // Fetch if not in cache const result = await reactor.queryClient.fetchInfiniteQuery( getInfiniteQueryOptions() ) // Result is already InfiniteData format return select ? select(result) : (result as unknown as TSelected) } // Implementation const useInfiniteQueryHook: UseInfiniteQueryWithSelect< TPageData, TPageParam, TSelected, TError > = (options: any): any => { // Chain the selects: raw -> config.select -> options.select const chainedSelect = (rawData: TInfiniteData) => { const firstPass = select ? select(rawData) : rawData if (options?.select) { return options.select(firstPass) } return firstPass } return useInfiniteQuery( { queryKey: getQueryKey(), queryFn, initialPageParam, getNextPageParam, getPreviousPageParam, maxPages, staleTime, ...rest, ...options, select: chainedSelect, } as any, reactor.queryClient ) } // Invalidate function const invalidate = async (): Promise => { const queryKey = getQueryKey() await reactor.queryClient.invalidateQueries({ queryKey }) } // Get data from cache without fetching const getCacheData: any = (selectFn?: (data: TSelected) => any) => { const queryKey = getQueryKey() const cachedRawData = reactor.queryClient.getQueryData( queryKey ) as TInfiniteData if (cachedRawData === undefined) { return undefined } // Apply config.select to raw cache data const selectedData = ( select ? select(cachedRawData) : cachedRawData ) as TSelected // Apply optional select parameter if (selectFn) { return selectFn(selectedData) } return selectedData } return { fetch, useInfiniteQuery: useInfiniteQueryHook, invalidate, getQueryKey, getCacheData, } } // ============================================================================ // Factory Function // ============================================================================ export function createInfiniteQuery< A, T extends TransformKey, M extends FunctionName = FunctionName, TPageParam = unknown, TSelected = InfiniteData, TPageParam>, >( reactor: Reactor, config: InfiniteQueryConfig, M, T, TPageParam, TSelected> ): InfiniteQueryResult< InfiniteQueryPageData, TPageParam, TSelected, InfiniteQueryError > { return createInfiniteQueryImpl( reactor, config as InfiniteQueryConfig ) } // ============================================================================ // Factory with Dynamic Args // ============================================================================ /** * Create an infinite query factory that accepts getArgs at call time. * Useful when pagination logic varies by context. * * @template A - The actor interface type * @template M - The method name on the actor * @template T - The transformation key (identity, display, etc.) * @template TPageParam - The page parameter type * @template TSelected - The type returned after select transformation * * @param reactor - The Reactor instance * @param config - Infinite query configuration (without getArgs) * @returns A function that accepts getArgs and returns an ActorInfiniteQueryResult * * @example * const getPostsQuery = createActorInfiniteQueryFactory(reactor, { * functionName: "get_posts", * initialPageParam: 0, * getKeyArgs: (args) => { * const [{ userId }] = args * return [{ userId }] * }, * getNextPageParam: (lastPage) => lastPage.nextCursor, * }) * * // Create query with specific args builder * const userPostsQuery = getPostsQuery((cursor) => [{ userId, cursor, limit: 10 }]) * const { data, fetchNextPage } = userPostsQuery.useInfiniteQuery() */ export function createInfiniteQueryFactory< A, T extends TransformKey, M extends FunctionName = FunctionName, TPageParam = unknown, TSelected = InfiniteData, TPageParam>, >( reactor: Reactor, config: InfiniteQueryFactoryConfig, M, T, TPageParam, TSelected> ): InfiniteQueryFactoryFn { const factory: InfiniteQueryFactoryFn = ( getArgs: (pageParam: TPageParam) => ReactorArgs ) => { const initialArgs = getArgs(config.initialPageParam) const keyArgs = config.getKeyArgs?.(initialArgs) ?? initialArgs const queryKey = mergeFactoryQueryKey(config.queryKey, undefined, keyArgs) return createInfiniteQueryImpl(reactor, { ...(({ getKeyArgs: _getKeyArgs, ...rest }) => rest)( config as InfiniteQueryFactoryConfig ), queryKey, getArgs, }) } return factory }