Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@codeleap/query

Package Overview
Dependencies
Maintainers
2
Versions
67
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@codeleap/query - npm Package Compare versions

Comparing version
5.8.3
to
5.8.4
+38
src/factors/createQueryManager.ts
import { QueryManager } from '../lib'
import { QueryItem, QueryManagerOptions } from '../types'
/**
* Factory function to create a new QueryManager instance
* @template T - The query item type that extends QueryItem
* @template F - The filter type used for list queries
* @param options - Configuration options for the query manager
* @returns New QueryManager instance
*
* @example
* ```typescript
* interface User extends QueryItem {
* name: string
* email: string
* status: 'active' | 'inactive'
* }
*
* interface UserFilters {
* status?: 'active' | 'inactive'
* search?: string
* }
*
* const userQueryManager = createQueryManager<User, UserFilters>({
* name: 'users',
* queryClient,
* listFn: (limit, offset, filters) => api.getUsers({ limit, offset, ...filters }),
* retrieveFn: (id) => api.getUser(id),
* createFn: (data) => api.createUser(data),
* updateFn: (data) => api.updateUser(data.id, data),
* deleteFn: (id) => api.deleteUser(id),
* listLimit: 20
* })
* ```
*/
export const createQueryManager = <T extends QueryItem, F = {}>(options: QueryManagerOptions<T, F>) => {
return new QueryManager<T, F>(options)
}
import { QueryOperations } from '../lib'
import { QueryOperationsOptions } from '../lib/QueryOperations/types'
/**
* Factory function to create a new QueryOperations builder instance
* @param options - Configuration options including the QueryClient
* @returns New QueryOperations instance ready for operation registration
*
* @description
* This is the entry point for creating a new QueryOperations instance. Use this
* function to start building your collection of queries and mutations.
*
* @example
* ```typescript
* import { QueryClient } from '@tanstack/react-query'
*
* const queryClient = new QueryClient()
*
* const userOperations = createQueryOperations({ queryClient })
* .query('getUser', async (id: string) => fetchUser(id))
* .query('getUsers', async (filters?: UserFilters) => fetchUsers(filters))
* .mutation('createUser', async (data: CreateUserData) => createUser(data))
* .mutation('updateUser', async (data: UpdateUserData) => updateUser(data))
* .mutation('deleteUser', async (id: string) => deleteUser(id))
*
* // In components
* function UserList() {
* const usersQuery = userOperations.useQuery('getUsers', { active: true })
* const createMutation = userOperations.useMutation('createUser')
*
* // Both hooks are fully type-safe
* }
* ```
*/
export function createQueryOperations(options: QueryOperationsOptions) {
return new QueryOperations(options)
}
export * from './createQueryManager'
export * from './createQueryOperations'
export * from './QueryClientEnhanced'
export * from './QueryKeys'
export * from './Mutations'
export * from './QueryManager'
export * from './QueryOperations'
import { InfiniteData } from '@tanstack/query-core'
import { QueryKeys } from './QueryKeys'
import { ItemPosition, ListPaginationResponse, PageParam, QueryClient, QueryItem, RemovedItemMap, WithTempId } from '../types'
import deepEqual from 'fast-deep-equal'
/**
* Class for managing mutations and cache updates for React Query list data
* @template T - The query item type that extends QueryItem
* @template F - The filter type used for list queries
*/
export class Mutations<T extends QueryItem, F> {
/**
* Creates a new Mutations instance
* @param queryKeys - The QueryKeys instance for managing query keys
* @param queryClient - The React Query client instance
* @param queryName - The name of the query used for identification
*/
constructor(
private queryKeys: QueryKeys<T, F>,
private queryClient: QueryClient,
private queryName: string
) { }
/**
* Adds a new item to the cached list data
* @param newItem - The new item to add to the list
* @param position - Where to add the item: 'start', 'end', or a RemovedItemMap for specific positions
* @param listFilters - Optional filters to target specific list queries
*
* @example
* ```typescript
* // Add item to the beginning
* mutations.addItem(newUser, 'start')
*
* // Add item to the end with filters
* mutations.addItem(newUser, 'end', { status: 'active' })
*
* // Add item to specific positions (restore from removed item map)
* mutations.addItem(newUser, removedItemMap)
* ```
*/
addItem(newItem: T, position: 'start' | 'end' | RemovedItemMap = 'start', listFilters?: F) {
const isMultiQueryKeys = Array.isArray(position) && position?.length >= 1
if (isMultiQueryKeys) {
for (const [queryKey, itemPosition] of position) {
const currentData = this.queryClient.getQueryData<InfiniteData<ListPaginationResponse<T>, PageParam>>(queryKey)
const updatedPages = [...currentData.pages]
if (itemPosition.pageIndex < updatedPages.length) {
const targetPage = [...updatedPages[itemPosition.pageIndex]]
const insertIndex = Math.min(itemPosition.itemIndex, targetPage.length)
targetPage.splice(insertIndex, 0, newItem)
updatedPages[itemPosition.pageIndex] = targetPage
} else {
const lastPageIndex = updatedPages.length - 1
if (lastPageIndex >= 0) {
updatedPages[lastPageIndex] = [...updatedPages[lastPageIndex], newItem]
} else {
updatedPages.push([newItem])
}
}
const newData = {
...currentData,
pages: updatedPages
}
this.queryClient.setQueryData(queryKey, newData)
}
return
}
const queryKey = this.queryKeys.listKeyWithFilters(listFilters)
const currentData = this.queryClient.getQueryData<InfiniteData<ListPaginationResponse<T>, PageParam>>(queryKey)
if (!currentData) {
const newData = {
pageParams: [0],
pages: [[newItem]]
}
this.queryClient.setQueryData(queryKey, newData)
return
}
const updatedPages = [...currentData.pages]
if (position === 'start') {
if (updatedPages.length > 0) {
updatedPages[0] = [newItem, ...updatedPages[0]]
} else {
updatedPages.push([newItem])
}
} else if (position === 'end') {
if (updatedPages.length > 0) {
const lastPageIndex = updatedPages.length - 1
updatedPages[lastPageIndex] = [...updatedPages[lastPageIndex], newItem]
} else {
updatedPages.push([newItem])
}
}
const newData = {
...currentData,
pages: updatedPages
}
this.queryClient.setQueryData(queryKey, newData)
}
/**
* Removes an item from all cached list queries and returns the positions where it was found
* @param itemId - The ID of the item to remove
* @returns A RemovedItemMap containing the query keys and positions where the item was found, or null if not found
*
* @example
* ```typescript
* const removedPositions = mutations.removeItem('user-123')
*
* // Later, restore the item to its original positions
* if (removedPositions) {
* mutations.addItem(restoredUser, removedPositions)
* }
* ```
*/
removeItem(itemId: QueryItem['id']): RemovedItemMap | null {
this.queryKeys.removeRetrieveQueryData(itemId)
const listQueries = this.queryKeys.getAllListQueries()
const removedItemMap: RemovedItemMap = []
for (const query of listQueries) {
const currentData = query.state?.data
const queryKey = query?.queryKey
if (!currentData) continue
let removedItemPosition: ItemPosition | null = null
for (let pageIndex = 0; pageIndex < currentData?.pages?.length; pageIndex++) {
const page = currentData.pages[pageIndex]
const itemIndex = page.findIndex((item: T) => item?.id === itemId)
if (itemIndex !== -1) {
removedItemPosition = {
pageIndex,
itemIndex,
}
break
}
}
if (!removedItemPosition) continue
removedItemMap.push([queryKey, removedItemPosition])
const updatedPages = currentData.pages.map(page =>
page.filter((item: T) => item?.id !== itemId)
)
const filteredPages = updatedPages.filter(page => page?.length > 0)
const finalPages = filteredPages?.length > 0 ? filteredPages : [[]]
const newPageParams = currentData?.pageParams?.slice(0, finalPages?.length)
const newData = {
...currentData,
pages: finalPages,
pageParams: newPageParams
}
this.queryClient.setQueryData(queryKey, newData)
}
return removedItemMap
}
/**
* Updates existing items in all cached list queries and individual retrieve queries
* @param data - Single item or array of items to update. Items can have tempId for temporary identification
*
* @description
* This method:
* - Finds items by their ID or tempId in all list queries
* - Updates the items only if the data has actually changed (uses deep equality check)
* - Updates both list cache and individual retrieve cache
* - Removes tempId from the final cached data
*
* @example
* ```typescript
* // Update single item
* mutations.updateItems({ id: 'user-123', name: 'Updated Name', tempId: 'temp-1' })
*
* // Update multiple items
* mutations.updateItems([
* { id: 'user-123', name: 'Updated Name' },
* { id: 'user-456', status: 'active' }
* ])
* ```
*/
updateItems(data: WithTempId<T> | WithTempId<T>[]) {
const listQueries = this.queryKeys.getAllListQueries()
const dataArray = Array.isArray(data) ? data : [data]
for (const item of dataArray) {
const { tempId, ...updateData } = item
const cachedQueryKey = this.queryKeys.keys.retrieve(updateData.id)
const cachedItemData = this.queryKeys.getRetrieveData(updateData.id)
if (!deepEqual(cachedItemData, updateData)) {
this.queryClient.setQueryData(cachedQueryKey, updateData)
}
}
const dataMap = Object.fromEntries(dataArray.map(item => [item?.tempId ?? item?.id, item]))
for (const query of listQueries) {
const oldData = query.state?.data
const queryKey = query?.queryKey
if (!oldData) continue
let hasChanges = false
const updatedPages = oldData.pages.map(page => {
let pageChanged = false
const updatedPage = page.map((item) => {
if (dataMap?.[item?.id]) {
const { tempId, ...updateData } = dataMap?.[item?.id]
const needsUpdate = !deepEqual(item, updateData)
if (needsUpdate) {
pageChanged = true
hasChanges = true
return updateData
}
}
return item
})
return pageChanged ? updatedPage : page
})
if (hasChanges) {
this.queryClient.setQueryData(queryKey, {
...oldData,
pages: updatedPages
})
}
}
}
}
/**
* Factory function to create a new Mutations instance
* @template T - The query item type that extends QueryItem
* @template F - The filter type used for list queries
* @param queryKeys - The QueryKeys instance for managing query keys
* @param queryClient - The React Query client instance
* @param queryName - The name of the query used for identification
* @returns New Mutations instance
*
* @example
* ```typescript
* const userQueryKeys = createQueryKeys<User, UserFilters>('users', queryClient)
* const userMutations = createMutations(userQueryKeys, queryClient, 'users')
* ```
*/
export const createMutations = <T extends QueryItem, F>(queryKeys: QueryKeys<T, F>, queryClient: QueryClient, queryName: string) => {
return new Mutations<T, F>(queryKeys, queryClient, queryName)
}
import { waitFor } from '@codeleap/utils'
import { QueryClient, QueryKey, Query, hashKey, QueryCacheNotifyEvent, matchQuery, QueryOptions } from '@tanstack/react-query'
import { DynamicEnhancedQuery, EnhancedQuery, PollQueryOptions, PollingResult, QueryKeyBuilder } from './types'
export class QueryClientEnhanced {
constructor(public client: QueryClient) { }
listenToQuery(key: QueryKey, callback: (e: QueryCacheNotifyEvent) => void) {
const cache = this.client.getQueryCache()
const query = cache.find({ exact: true, queryKey: key })
if (!query) {
return
}
const removeListener = cache.subscribe((e) => {
const matches = matchQuery({ exact: true, queryKey: key }, e.query)
if (matches) {
callback(e)
}
})
return removeListener
}
async pollQuery<T, R>(
key: QueryKey,
options: PollQueryOptions<T, R>,
) {
const { interval, callback, initialData, leading = false } = options
const cache = this.client.getQueryCache()
const initialQuery = cache.find({ exact: true, queryKey: key })
if (!initialQuery) {
return Promise.reject(new Error('Query not found'))
}
let count = 0
let result: PollingResult<R> = {
stop: false,
data: initialData,
}
while (!result?.stop) {
const shouldWait = count > 0 || leading
if (shouldWait) {
await waitFor(interval)
}
this.client.refetchQueries({
exact: true,
queryKey: key,
})
const newQuery = await this.waitForRefresh<T>(key)
const newResult = await callback(newQuery, count, result?.data)
count += 1
result = newResult
}
return result?.data
}
queryProxy<T>(key: QueryKey) {
const getClient = () => this
return new Proxy<EnhancedQuery<T>>({} as EnhancedQuery<T>, {
get(target, p, receiver) {
const client = getClient()
// these don't need the actual query
switch (p) {
case 'key':
return key
case 'getData':
return () => {
return client.client.getQueryData<T>(key)
}
default:
break
}
const cache = client.client.getQueryCache()
const query = cache.find({ exact: true, queryKey: key })
if (!query) {
console.warn(`Attempt to access property ${String(p)} on undefined query with key`, key)
return undefined
}
switch (p) {
case 'waitForRefresh':
return () => {
return client.waitForRefresh<T>(key)
}
case 'listen':
return (callback: (e: QueryCacheNotifyEvent) => void) => {
return client.listenToQuery(key, callback)
}
case 'ensureData':
return (options) => {
return client.client.ensureQueryData<T>({
queryKey: key,
...options
})
}
case 'refresh':
return async () => {
client.client.refetchQueries({
exact: true,
queryKey: key,
})
const newQuery = await client.waitForRefresh<T>(key)
return newQuery.state.data
}
case 'poll':
return (options: PollQueryOptions<T, any>) => {
return client.pollQuery(key, options)
}
default:
return Reflect.get(query, p, receiver)
}
},
})
}
waitForRefresh<T>(key: QueryKey) {
const initialQuery = this.client.getQueryCache().find({ exact: true, queryKey: key })
if (!initialQuery) {
return Promise.reject(new Error('Query not found'))
}
const updateTime = initialQuery.state.dataUpdatedAt
const errorTime = initialQuery.state.errorUpdatedAt
return new Promise<Query<T>>((resolve, reject) => {
const removeListener = this.listenToQuery(key, (e) => {
const query = e.query
const isNewer = query.state.dataUpdatedAt > updateTime || query.state.errorUpdatedAt > errorTime
const isIdle = query.state.fetchStatus === 'idle'
const isSuccess = query.state.status === 'success'
const isError = query.state.status === 'error'
const isResolved = isSuccess || isError
if (isNewer && isIdle && isResolved) {
if (isSuccess) {
resolve(query)
} else {
reject()
}
removeListener()
}
})
})
}
queryKey<Data>(k: QueryKey, options?: QueryOptions<Data>) {
if (options) {
this.client.setQueryDefaults(k, options)
const cache = this.client.getQueryCache()
const q = new Query({
client: this.client,
queryKey: k,
queryHash: hashKey(k),
...options,
})
cache.add(q)
}
return this.queryProxy<Data>(k)
}
dynamicQueryKey<Data, BuilderArgs extends any[] = any[]>(k: QueryKeyBuilder<BuilderArgs>) {
const getClient = () => this
return new Proxy<DynamicEnhancedQuery<Data, BuilderArgs>>({} as DynamicEnhancedQuery<Data, BuilderArgs>, {
get(target, p, receiver) {
return (...params: BuilderArgs) => {
const key = k(...params)
const proxy = getClient().queryProxy<Data>(key)
return Reflect.get(proxy, p, receiver)
}
},
})
}
}
import {
QueryKey,
Query,
QueryCacheNotifyEvent,
EnsureQueryDataOptions,
} from '@tanstack/react-query'
export type QueryKeyBuilder<Args extends any[] = any[]> = (...args: Args) => QueryKey
export type PollingResult<T> = {
stop: boolean
data: T
}
export type PollingCallback<T, R> = (query: Query<T>, count: number, prev?: R) => Promise<PollingResult<R>>
export type PollQueryOptions<T, R> = {
interval: number
callback: PollingCallback<T, R>
leading?: boolean
initialData?: R
}
export interface EnhancedQuery<T> extends Query<T> {
waitForRefresh(): Promise<Query<T>>
listen(callback: (e: QueryCacheNotifyEvent) => void): () => void
refresh(): Promise<T>
poll<R>(
options: PollQueryOptions<T, R>
): Promise<R>
getData(): T
ensureData(options?: Partial<EnsureQueryDataOptions<T, Error, T, QueryKey, never>>): Promise<T>
key: QueryKey
}
export type DynamicEnhancedQuery<T, BuilderArgs extends any[]> = {
[P in keyof EnhancedQuery<T>]: (...args: BuilderArgs) => EnhancedQuery<T>[P]
}
import { CancelOptions, InfiniteData, InvalidateOptions, InvalidateQueryFilters, Query, QueryFilters, QueryKey, RefetchOptions, RefetchQueryFilters } from '@tanstack/react-query'
import { useMemo } from 'react'
import { TypeGuards } from '@codeleap/types'
import { ListPaginationResponse, ListSelector, PageParam, QueryClient, QueryItem } from '../types'
import deepEqual from 'fast-deep-equal'
/**
* Class for managing React Query keys and operations for a specific query type
* @template T - The query item type that extends QueryItem
* @template F - The filter type used for list queries
*/
export class QueryKeys<T extends QueryItem, F> {
/**
* Creates a new QueryKeys instance
* @param queryName - The name of the query used as base for all keys
* @param queryClient - The React Query client instance
*/
constructor(
private queryName: string,
private queryClient: QueryClient
) { }
/**
* Gets the base query keys for different operations
* @returns Object containing base query keys for list, retrieve, create, update, and delete operations
*/
get keys() {
return {
// queries
list: [this.queryName, 'list'] as QueryKey,
retrieve: (id: QueryItem['id']) => [this.queryName, 'retrieve', id] as QueryKey,
// mutations
create: [this.queryName, 'create'] as QueryKey,
update: [this.queryName, 'update'] as QueryKey,
delete: [this.queryName, 'delete'] as QueryKey,
}
}
/**
* Generates a list query key with optional filters
* @param filters - Optional filters to include in the query key
* @returns Query key array with or without filters
*/
listKeyWithFilters(filters?: F): QueryKey {
const hasValidFilters = filters != null && (
typeof filters !== 'object'
? Boolean(filters)
: Object.values(filters).some(value =>
value != null && (typeof value !== 'string' || value.trim() !== '')
)
)
return hasValidFilters ? [...this.keys.list, filters] : this.keys.list
}
/**
* React hook that returns a memoized list query key with filters
* @param filters - Optional filters to include in the query key
* @returns Memoized query key array
*/
useListKeyWithFilters(filters?: F) {
return useMemo(() => {
return this.listKeyWithFilters(filters)
}, [filters])
}
/**
* React hook that returns a memoized retrieve query key
* @param id - The ID of the item to retrieve
* @returns Memoized query key array for retrieve operation
*/
useRetrieveKey(id: QueryItem['id']) {
return useMemo(() => {
return this.keys.retrieve(id)
}, [id])
}
/**
* Predicate function to check if a query belongs to this query name (all operations)
* @private
* @param queryName - The query name to match against
* @param query - The query object to check
* @returns True if the query matches the query name
*/
private predicateQueryKeyAll(queryName: string, query: Query<unknown, Error, unknown, QueryKey>) {
const queryKey = query?.queryKey?.join('/')
return queryKey?.includes(queryName)
}
/**
* Predicate function to check if a query is a list query for this query name
* @private
* @param queryName - The query name to match against
* @param query - The query object to check
* @param toIgnoreQueryKeys - Query keys to ignore in the matching
* @returns True if the query is a list query and not in the ignore list
*/
private predicateQueryKeyList(queryName: string, query: Query<unknown, Error, unknown, QueryKey>, toIgnoreQueryKeys?: QueryKey | QueryKey[]) {
const queryKey = query?.queryKey?.join('/')
if (!TypeGuards.isNil(toIgnoreQueryKeys)) {
const ignoreQueryKeys = Array.isArray(toIgnoreQueryKeys?.[0]) ? toIgnoreQueryKeys : [toIgnoreQueryKeys]
if (ignoreQueryKeys.some(key => deepEqual(query?.queryKey, key))) {
return false
}
}
const isListQueryKey = queryKey?.includes(queryName) && queryKey?.includes('list')
return isListQueryKey
}
/**
* Invalidates all queries for this query name
* @param filters - Optional filters to apply to the invalidation
* @param options - Optional invalidation options
* @returns Promise that resolves when invalidation is complete
*/
async invalidateAll(filters?: InvalidateQueryFilters<QueryKey>, options?: InvalidateOptions) {
return this.queryClient.invalidateQueries({
...filters,
predicate: (query) => this.predicateQueryKeyAll(this.queryName, query),
}, options)
}
/**
* Invalidates list queries, optionally with specific filters
* @param listFilters - Optional filters to target specific list queries
* @param ignoreQueryKeys - Query keys to ignore during invalidation
* @param filters - Optional filters to apply to the invalidation
* @param options - Optional invalidation options
* @returns Promise that resolves when invalidation is complete
*/
async invalidateList(listFilters?: F, ignoreQueryKeys?: QueryKey | QueryKey[], filters?: InvalidateQueryFilters<QueryKey>, options?: InvalidateOptions) {
if (!!listFilters) {
const queryKey = this.listKeyWithFilters(listFilters)
return this.queryClient.invalidateQueries({ ...filters, queryKey }, options)
}
return this.queryClient.invalidateQueries({
...filters,
predicate: (query) => this.predicateQueryKeyList(this.queryName, query, ignoreQueryKeys),
}, options)
}
/**
* Invalidates a specific retrieve query by ID
* @param id - The ID of the item to invalidate
* @param filters - Optional filters to apply to the invalidation
* @param options - Optional invalidation options
* @returns Promise that resolves when invalidation is complete
*/
async invalidateRetrieve(id: QueryItem['id'], filters?: InvalidateQueryFilters<QueryKey>, options?: InvalidateOptions) {
const queryKey = this.keys.retrieve(id)
return this.queryClient.invalidateQueries({
...filters,
queryKey,
}, options)
}
/**
* Refetches all queries for this query name
* @param filters - Optional filters to apply to the refetch
* @param options - Optional refetch options
* @returns Promise that resolves when refetch is complete
*/
async refetchAll(filters?: RefetchQueryFilters<QueryKey>, options?: RefetchOptions) {
return this.queryClient.refetchQueries({
...filters,
predicate: (query) => this.predicateQueryKeyAll(this.queryName, query),
}, options)
}
/**
* Refetches list queries, optionally with specific filters
* @param listFilters - Optional filters to target specific list queries
* @param ignoreQueryKeys - Query keys to ignore during refetch
* @param filters - Optional filters to apply to the refetch
* @param options - Optional refetch options
* @returns Promise that resolves when refetch is complete
*/
async refetchList(listFilters?: F, ignoreQueryKeys?: QueryKey | QueryKey[], filters?: RefetchQueryFilters<QueryKey>, options?: RefetchOptions) {
if (!!listFilters) {
const queryKey = this.listKeyWithFilters(listFilters)
return this.queryClient.refetchQueries({ ...filters, queryKey }, options)
}
return this.queryClient.refetchQueries({
...filters,
predicate: (query) => this.predicateQueryKeyList(this.queryName, query, ignoreQueryKeys),
}, options)
}
/**
* Refetches a specific retrieve query by ID
* @param id - The ID of the item to refetch
* @param filters - Optional filters to apply to the refetch
* @param options - Optional refetch options
* @returns Promise that resolves when refetch is complete
*/
async refetchRetrieve(id: QueryItem['id'], filters?: RefetchQueryFilters<QueryKey>, options?: RefetchOptions) {
const queryKey = this.keys.retrieve(id)
return this.queryClient.refetchQueries({
...filters,
queryKey,
}, options)
}
/**
* Removes a specific retrieve query data from the cache
* @param id - The ID of the item to remove from cache
* @param filters - Optional filters to apply to the removal
* @returns Promise that resolves when removal is complete
*/
async removeRetrieveQueryData(id: QueryItem['id'], filters?: QueryFilters<QueryKey>) {
const queryKey = this.keys.retrieve(id)
return this.queryClient.removeQueries({
...filters,
queryKey,
})
}
/**
* Cancels list queries that are currently in flight
* @param listFilters - Optional filters to target specific list queries
* @param ignoreQueryKeys - Query keys to ignore during cancellation
* @param filters - Optional filters to apply to the cancellation
* @param options - Optional cancellation options
* @returns Promise that resolves when cancellation is complete
*/
async cancelListQueries(listFilters?: F, ignoreQueryKeys?: QueryKey | QueryKey[], filters?: QueryFilters<QueryKey>, options?: CancelOptions) {
if (!!listFilters) {
const queryKey = this.listKeyWithFilters(listFilters)
return this.queryClient.cancelQueries({ ...filters, queryKey }, options)
}
return this.queryClient.cancelQueries({
...filters,
predicate: (query) => this.predicateQueryKeyList(this.queryName, query, ignoreQueryKeys),
}, options)
}
/**
* Gets list data from the cache, including both items array and item map
* @param filters - Optional filters to target specific list data
* @returns Object containing items array and itemMap (keyed by ID)
*/
getListData(filters?: F) {
const queryKey = this.listKeyWithFilters(filters)
const data = this.queryClient.getQueryData<InfiniteData<ListPaginationResponse<T>, PageParam>>(queryKey)
const items = (data?.pages ?? []).flat()
const itemMap = items.reduce((acc, item) => {
acc[item?.id] = item
return acc
}, {} as Record<QueryItem['id'], T>)
return {
items,
itemMap,
}
}
/**
* Gets retrieve data from cache, with fallback to list data
* @param id - The ID of the item to retrieve
* @param onlyQueryData - If true, only returns data from the specific retrieve query, not from list data
* @returns The item data or undefined if not found
*/
getRetrieveData(id: QueryItem['id'], onlyQueryData = false): T | undefined {
if (TypeGuards.isNil(id)) return undefined
const queryKey = this.keys.retrieve(id)
const queryData = this.queryClient.getQueryData<T>(queryKey)
if (!queryData?.id && !onlyQueryData) {
const { itemMap } = this.getListData()
return itemMap?.[id]
}
return queryData
}
/**
* Gets all list queries from the query cache
* @returns Array of list queries for this query name
*/
getAllListQueries() {
const queries = this.queryClient.getQueryCache().findAll({
predicate: (query) => this.predicateQueryKeyList(this.queryName, query),
})
return queries as Query<ListPaginationResponse<T>, Error, Omit<ListSelector<T>, 'allItems'>, QueryKey>[]
}
}
/**
* Factory function to create a new QueryKeys instance
* @template T - The query item type that extends QueryItem
* @template F - The filter type used for list queries
* @param name - The name of the query used as base for all keys
* @param queryClient - The React Query client instance
* @returns New QueryKeys instance
*/
export const createQueryKeys = <T extends QueryItem, F>(name: string, queryClient: QueryClient) => {
return new QueryKeys<T, F>(name, queryClient)
}
import { FetchQueryOptions, InfiniteData, MutationFunctionContext, QueryKey, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import { createQueryKeys, QueryKeys } from './QueryKeys'
import { createMutations, Mutations } from './Mutations'
import { CreateMutationCtx, CreateMutationOptions, ListPaginationResponse, ListQueryOptions, PageParam, QueryItem, QueryManagerOptions, RetrieveQueryOptions, UpdateMutationCtx, UpdateMutationOptions, DeleteMutationCtx, DeleteMutationOptions } from '../types'
import { generateTempId } from '../utils'
import { TypeGuards } from '@codeleap/types'
/**
* Comprehensive query manager class that provides hooks and utilities for managing CRUD operations with React Query
* @template T - The query item type that extends QueryItem
* @template F - The filter type used for list queries
*
* @description
* QueryManager provides a complete solution for managing list and individual item queries with:
* - Infinite scroll pagination for lists
* - Optimistic updates for create, update, and delete operations
* - Automatic cache synchronization between list and individual queries
* - Built-in error handling and rollback mechanisms
*/
export class QueryManager<T extends QueryItem, F> {
/**
* Creates a new QueryManager instance
* @param options - Configuration options for the query manager
*/
constructor(private options: QueryManagerOptions<T, F>) {
this.queryKeys = createQueryKeys<T, F>(options.name, options.queryClient)
this.mutations = createMutations<T, F>(this.queryKeys, options.queryClient, options.name)
}
/**
* Gets the name of this query manager
* @returns The query name used for identification
*/
get name() {
return this.options.name
}
/**
* Gets the configured CRUD functions
* @returns Object containing all configured function handlers
*/
get functions() {
return {
list: this.options.listFn,
retrieve: this.options.retrieveFn,
create: this.options.createFn,
update: this.options.updateFn,
delete: this.options.deleteFn,
}
}
/** QueryKeys instance for managing query keys and cache operations */
queryKeys: QueryKeys<T, F>
/** Mutations instance for managing cache mutations */
mutations: Mutations<T, F>
/**
* React hook for infinite scroll list queries with pagination
* @param options - Configuration options for the list query
* @returns Object containing items array, query key, and query object
*
* @example
* ```typescript
* const { items, query } = queryManager.useList({
* filters: { status: 'active' },
* limit: 20
* })
* ```
*/
useList(options: ListQueryOptions<T, F> = {}) {
const {
limit,
filters,
...queryOptions
} = options
const listLimit = limit ?? this.options?.listLimit ?? 10
const queryKey = this.queryKeys.useListKeyWithFilters(filters)
const onSelect = useCallback((data: InfiniteData<ListPaginationResponse<T>, PageParam>) => {
const pages = data?.pages ?? []
return {
pageParams: data?.pageParams,
pages,
allItems: pages.flat(),
}
}, [])
const query = useInfiniteQuery({
queryKey,
queryFn: async (query) => {
const listOffset = query?.pageParam ?? 0
return this.options?.listFn?.(listLimit, listOffset, filters)
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
if (!lastPage?.length || lastPage.length < listLimit) {
return undefined
}
return (lastPageParam ?? 0) + lastPage.length
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
if ((firstPageParam ?? 0) <= 0) {
return undefined
}
return (firstPageParam ?? 0) - listLimit
},
select(data) {
return onSelect(data)
},
...queryOptions,
})
const useListEffect = (this.options.useListEffect ?? ((args: any) => null))
useListEffect(query)
const items = query.data?.allItems ?? []
return {
items,
queryKey,
query,
}
}
/**
* React hook for retrieving a single item by ID
* @param id - The ID of the item to retrieve
* @param options - Configuration options for the retrieve query
* @returns Object containing the item data, query key, and query object
*
* @description
* This hook automatically:
* - Uses list cache as initial data if available
* - Updates list cache when retrieve data changes
* - Synchronizes data between list and individual caches
*
* @example
* ```typescript
* const { item, query } = queryManager.useRetrieve('user-123', {
* enabled: !!userId
* })
* ```
*/
useRetrieve(id: T['id'], options: RetrieveQueryOptions<T> = {}) {
const {
select,
...queryOptions
} = options
const onSelect = useCallback((data: T) => {
this.mutations.updateItems(data)
if (select) select?.(data)
return data
}, [select])
const getInitialData = useCallback(() => {
const { itemMap } = this.queryKeys.getListData()
return itemMap?.[id]
}, [id])
const queryKey = this.queryKeys.useRetrieveKey(id)
const query = useQuery({
initialData: getInitialData,
...queryOptions,
queryKey,
queryFn: () => {
return this.options.retrieveFn(id)
},
select: (data) => {
return onSelect(data)
},
})
return {
item: query.data,
queryKey,
query,
}
}
/**
* React hook for creating new items with optimistic updates
* @param options - Configuration options for the create mutation
* @returns React Query mutation object for create operations
*
* @description
* This hook supports optimistic updates by:
* - Immediately adding a temporary item to the cache
* - Rolling back on error by removing the temporary item
* - Replacing temporary item with real data on success
*
* @example
* ```typescript
* const createMutation = queryManager.useCreate({
* optimistic: true,
* appendTo: 'start',
* listFilters: { status: 'active' }
* })
*
* // Usage
* createMutation.mutate({ name: 'New User', email: 'user@example.com' })
* ```
*/
useCreate(options: CreateMutationOptions<T, F> = {}) {
const {
optimistic,
listFilters,
appendTo,
onMutate: providedOnMutate,
onError: providedOnError,
onSuccess: providedOnSuccess,
...mutationOptions
} = options
const onMutate = useCallback(async (data: Partial<T>, context: MutationFunctionContext) => {
if (providedOnMutate) providedOnMutate?.(data, context)
if (optimistic) {
await this.queryKeys.cancelListQueries(listFilters)
const tempId = generateTempId()
const newItem = { ...data, id: tempId } as T
this.mutations.addItem(newItem, appendTo, listFilters)
return {
tempId,
}
}
}, [providedOnMutate, optimistic, listFilters])
const onError = useCallback((error: Error, variables: Partial<T>, onMutateResult: CreateMutationCtx, context: MutationFunctionContext) => {
if (providedOnError) providedOnError?.(error, variables, onMutateResult, context)
if (!TypeGuards.isNil(onMutateResult?.tempId)) {
this.mutations.removeItem(onMutateResult?.tempId)
}
}, [providedOnError])
const onSuccess = useCallback((data: T, variables: Partial<T>, onMutateResult: CreateMutationCtx, context: MutationFunctionContext) => {
if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context)
if (TypeGuards.isNil(onMutateResult?.tempId)) {
this.mutations.addItem(data, appendTo, listFilters)
} else {
this.mutations.updateItems({ ...data, tempId: onMutateResult?.tempId })
}
}, [providedOnSuccess, listFilters])
const mutation = useMutation<T, Error, Partial<T>, CreateMutationCtx>({
...mutationOptions,
mutationKey: this.queryKeys.keys.create,
mutationFn: (data: Partial<T>) => {
return this.options.createFn(data)
},
onMutate,
onError,
onSuccess,
})
return mutation
}
/**
* React hook for updating existing items with optimistic updates
* @param options - Configuration options for the update mutation
* @returns React Query mutation object for update operations
*
* @description
* This hook supports optimistic updates by:
* - Immediately updating the item in cache with new data
* - Rolling back to previous data on error
* - Confirming updates with server response on success
*
* @example
* ```typescript
* const updateMutation = queryManager.useUpdate({
* optimistic: true,
* onSuccess: (data) => console.log('Updated:', data)
* })
*
* // Usage
* updateMutation.mutate({ id: 'user-123', name: 'Updated Name' })
* ```
*/
useUpdate(options: UpdateMutationOptions<T, F> = {}) {
const {
optimistic,
onMutate: providedOnMutate,
onError: providedOnError,
onSuccess: providedOnSuccess,
...mutationOptions
} = options
const onMutate = useCallback(async (data: Partial<T>, context: MutationFunctionContext) => {
if (providedOnMutate) providedOnMutate?.(data, context)
if (optimistic) {
const previousItem = this.queryKeys.getRetrieveData(data?.id)
if (!previousItem) return
const optimisticItem = {
...previousItem,
...data,
} as T
this.mutations.updateItems(optimisticItem)
return {
previousItem,
optimisticItem,
}
}
}, [providedOnMutate, optimistic])
const onError = useCallback((error: Error, variables: Partial<T>, onMutateResult: UpdateMutationCtx<T>, context: MutationFunctionContext) => {
if (providedOnError) providedOnError?.(error, variables, onMutateResult, context)
if (!TypeGuards.isNil(onMutateResult?.previousItem?.id)) {
this.mutations.updateItems(onMutateResult?.previousItem)
}
}, [providedOnError])
const onSuccess = useCallback((data: T, variables: Partial<T>, onMutateResult: UpdateMutationCtx<T>, context: MutationFunctionContext) => {
if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context)
this.mutations.updateItems(data)
}, [providedOnSuccess])
const mutation = useMutation<T, Error, Partial<T>, UpdateMutationCtx<T>>({
...mutationOptions,
mutationKey: this.queryKeys.keys.update,
mutationFn: (data: Partial<T>) => {
return this.options.updateFn(data)
},
onMutate,
onError,
onSuccess,
})
return mutation
}
/**
* React hook for deleting items with optimistic updates
* @param options - Configuration options for the delete mutation
* @returns React Query mutation object for delete operations
*
* @description
* This hook supports optimistic updates by:
* - Immediately removing the item from cache
* - Storing the removal positions for potential rollback
* - Restoring the item to original positions on error
* - Confirming deletion on success
*
* @example
* ```typescript
* const deleteMutation = queryManager.useDelete({
* optimistic: true,
* onSuccess: () => console.log('Item deleted successfully')
* })
*
* // Usage
* deleteMutation.mutate('user-123')
* ```
*/
useDelete(options: DeleteMutationOptions<T, F> = {}) {
const {
optimistic,
onMutate: providedOnMutate,
onError: providedOnError,
onSuccess: providedOnSuccess,
...mutationOptions
} = options
const onMutate = useCallback(async (id: T['id'], context: MutationFunctionContext) => {
if (providedOnMutate) providedOnMutate?.(id, context)
if (optimistic) {
const previousItem = this.queryKeys.getRetrieveData(id)
if (!previousItem) return
const removedAt = this.mutations.removeItem(id)
return {
previousItem,
removedAt,
}
}
}, [providedOnMutate, optimistic])
const onError = useCallback((error: Error, variables: T['id'], onMutateResult: DeleteMutationCtx<T>, context: MutationFunctionContext) => {
if (providedOnError) providedOnError?.(error, variables, onMutateResult, context)
if (!TypeGuards.isNil(onMutateResult?.previousItem?.id)) {
this.mutations.addItem(
onMutateResult?.previousItem,
onMutateResult?.removedAt,
)
}
}, [providedOnError])
const onSuccess = useCallback((data: T['id'], variables: T['id'], onMutateResult: DeleteMutationCtx<T>, context: MutationFunctionContext) => {
if (providedOnSuccess) providedOnSuccess?.(data, variables, onMutateResult, context)
if (TypeGuards.isNil(onMutateResult?.previousItem?.id)) {
this.mutations.removeItem(data)
}
}, [providedOnSuccess])
const mutation = useMutation<unknown, Error, T['id'], DeleteMutationCtx<T>>({
...mutationOptions,
mutationKey: this.queryKeys.keys.delete,
mutationFn: async (id: QueryItem['id']) => {
return this.options.deleteFn(id)
},
onMutate,
onError,
onSuccess,
})
return mutation
}
/**
* Prefetches a single item by ID for improved performance
* @param id - The ID of the item to prefetch
* @param options - Prefetch options compatible with React Query
* @returns Promise that resolves when prefetch is complete
*
* @description
* Use this method to preload data that users are likely to need soon,
* such as when hovering over links or preparing for navigation.
*
* @example
* ```typescript
* // Prefetch on hover
* const handleHover = (userId: string) => {
* queryManager.prefetchRetrieve(userId, { staleTime: 5 * 60 * 1000 })
* }
* ```
*/
prefetchRetrieve(id: T['id'], options: Omit<FetchQueryOptions<T, Error, T, QueryKey, never>, 'queryKey' | 'queryFn'> = {}) {
return this.options.queryClient.prefetchQuery({
...options,
queryKey: this.queryKeys.keys.retrieve(id),
queryFn: () => this.options.retrieveFn(id),
})
}
}
import { useMutation, useQuery, UseQueryOptions, UseMutationOptions, QueryKey, FetchQueryOptions } from '@tanstack/react-query'
import { QueryOperationsOptions, MutationFn, QueryFn, InferMutationParams, InferMutationReturn, InferQueryParams, InferQueryReturn } from './types'
/**
* Builder class for creating type-safe query and mutation operations
* @template TMutations - Record type containing all registered mutation functions
* @template TQueries - Record type containing all registered query functions
*
* @description
* QueryOperations provides a fluent interface for building collections of queries and mutations
* with full type safety. It acts as a centralized registry for all data operations and provides
* corresponding React hooks that are automatically typed based on the registered functions.
*
* Key features:
* - Fluent builder pattern for registering operations
* - Automatic type inference for parameters and return types
* - Type-safe React hooks generation
* - Immutable operation registration (returns new instances)
*/
export class QueryOperations<
TMutations,
TQueries,
> {
/**
* Creates a new QueryOperations instance
* @param _options - Configuration options including QueryClient
* @param _mutations - Record of registered mutation functions (internal)
* @param _queries - Record of registered query functions (internal)
*/
constructor(
private _options: QueryOperationsOptions,
private _mutations: TMutations = {} as TMutations,
private _queries: TQueries = {} as TQueries
) { }
/**
* Gets all registered mutation functions
* @returns Readonly record of mutation functions
*/
get mutations(): Readonly<TMutations> {
return this._mutations
}
/**
* Gets all registered query functions
* @returns Readonly record of query functions
*/
get queries(): Readonly<TQueries> {
return this._queries
}
/**
* Registers a new mutation function
* @template K - The name/key for the mutation
* @template T - The input data type for the mutation
* @template R - The return data type for the mutation
* @param name - Unique name identifier for the mutation
* @param fn - The mutation function that performs the operation
* @returns New QueryOperations instance with the mutation added
*
* @example
* ```typescript
* const operations = createQueryOperations({ queryClient })
* .mutation('createUser', async (userData: CreateUserData) => {
* return api.post('/users', userData)
* })
* .mutation('updateUser', async (userData: UpdateUserData) => {
* return api.put(`/users/${userData.id}`, userData)
* })
* ```
*/
mutation<K extends string, T = any, R = any>(
name: K,
fn: MutationFn<T, R>
): QueryOperations<TMutations & Record<K, MutationFn<T, R>>, TQueries> {
return new QueryOperations(
this._options,
{ ...this._mutations, [name]: fn } as TMutations & Record<K, MutationFn<T, R>>,
this._queries
)
}
/**
* Registers a new query function
* @template K - The name/key for the query
* @template T - The parameters type for the query
* @template R - The return data type for the query
* @param name - Unique name identifier for the query
* @param fn - The query function that fetches the data
* @returns New QueryOperations instance with the query added
*
* @example
* ```typescript
* const operations = createQueryOperations({ queryClient })
* .query('getUser', async (userId: string) => {
* return api.get(`/users/${userId}`)
* })
* .query('getUsers', async (filters?: UserFilters) => {
* return api.get('/users', { params: filters })
* })
* ```
*/
query<K extends string, T = any, R = any>(
name: K,
fn: QueryFn<T, R>
): QueryOperations<TMutations, TQueries & Record<K, QueryFn<T, R>>> {
return new QueryOperations(
this._options,
this._mutations,
{ ...this._queries, [name]: fn } as TQueries & Record<K, QueryFn<T, R>>
)
}
/**
* React hook for executing mutations with full type safety
* @template K - The mutation key type
* @param mutationKey - The name of the registered mutation to use
* @param options - React Query mutation options (excluding mutationFn and mutationKey)
* @returns React Query mutation object with inferred types
*
* @description
* This hook automatically provides type-safe parameters and return types based on the
* registered mutation function. It handles error cases and provides proper TypeScript
* inference for the mutation data and variables.
*
* @example
* ```typescript
* const createUserMutation = operations.useMutation('createUser', {
* onSuccess: (user) => {
* // 'user' is automatically typed as the return type of createUser
* console.log('Created user:', user.id)
* }
* })
*
* // Usage - parameters are type-checked
* createUserMutation.mutate({ name: 'John', email: 'john@example.com' })
* ```
*/
useMutation<K extends keyof TMutations>(
mutationKey: K,
options?: Omit<
UseMutationOptions<
InferMutationReturn<TMutations[K]>,
Error,
InferMutationParams<TMutations[K]>
>,
'mutationFn'
>
) {
const mutationFn = this._mutations[mutationKey] as MutationFn
type TData = InferMutationReturn<TMutations[K]>
type TVariables = InferMutationParams<TMutations[K]>
return useMutation<TData, Error, TVariables>({
mutationKey: this.getMutationKey(mutationKey),
mutationFn: async (data: TVariables): Promise<TData> => {
if (!mutationFn) {
throw new Error(`Mutation "${String(mutationKey)}" not found`)
}
return mutationFn(data) as Promise<TData>
},
...options
})
}
/**
* React hook for executing queries with full type safety
* @template K - The query key type
* @param queryKey - The name of the registered query to use
* @param params - Parameters to pass to the query function (optional if query doesn't require params)
* @param options - React Query options (excluding queryKey and queryFn)
* @returns React Query query object with inferred types
*
* @description
* This hook automatically provides type-safe parameters and return types based on the
* registered query function. It generates appropriate query keys and handles parameter
* validation.
*
* @example
* ```typescript
* // Query with parameters
* const userQuery = operations.useQuery('getUser', 'user-123', {
* enabled: !!userId
* })
*
* // Query without parameters
* const usersQuery = operations.useQuery('getUsers', undefined, {
* refetchInterval: 30000
* })
*
* // Query with optional parameters
* const filteredUsersQuery = operations.useQuery('getUsers', { status: 'active' })
* ```
*/
useQuery<K extends keyof TQueries, T = InferQueryReturn<TQueries[K]>>(
queryKey: K,
params?: InferQueryParams<TQueries[K]>,
options?: Omit<
Partial<UseQueryOptions<
InferQueryReturn<TQueries[K]>,
Error,
T,
QueryKey
>>,
'queryFn'
>
) {
const queryFn = this._queries[queryKey] as QueryFn
type TData = InferQueryReturn<TQueries[K]>
return useQuery<TData, Error, T, QueryKey>({
queryKey: this.getQueryKey(queryKey, params),
queryFn: async (): Promise<TData> => {
if (!queryFn) {
throw new Error(`Query "${String(queryKey)}" not found`)
}
return queryFn(params as any) as Promise<TData>
},
...options
} as any)
}
/**
* Generates a properly typed query key for React Query
* @template K - The query key type
* @param queryKey - The name of the query
* @param params - Optional parameters for the query
* @returns Query key array, with params included only when necessary
*
* @description
* This method creates React Query compatible keys that include parameters when present.
* The return type is conditionally typed based on whether the query requires parameters.
*
* @example
* ```typescript
* // Returns ['getUser', 'user-123']
* const keyWithParams = operations.getQueryKey('getUser', 'user-123')
*
* // Returns ['getUsers']
* const keyWithoutParams = operations.getQueryKey('getUsers')
* ```
*/
getQueryKey<K extends keyof TQueries>(
queryKey: K,
params?: InferQueryParams<TQueries[K]>
): QueryKey {
return (params !== undefined ? [queryKey, params] : [queryKey]) as QueryKey
}
/**
* Generates a mutation key for React Query
* @template K - The mutation key type
* @param mutationKey - The name of the mutation
* @returns Mutation key array containing only the mutation name
*
* @example
* ```typescript
* // Returns ['createUser']
* const mutationKey = operations.getMutationKey('createUser')
* ```
*/
getMutationKey<K extends keyof TMutations>(mutationKey: K): QueryKey {
return [mutationKey] as QueryKey
}
/**
* Prefetches a query to populate the cache ahead of time
* @template K - The query key type
* @param queryKey - The name of the registered query to prefetch
* @param params - Parameters to pass to the query function (optional if query doesn't require params)
* @param options - React Query prefetch options
* @returns Promise that resolves when the prefetch is complete
*
* @example
* ```typescript
* // Prefetch user data when hovering over a user link
* const handleUserHover = async (userId: string) => {
* await operations.prefetchQuery('getUser', userId, {
* staleTime: 5 * 60 * 1000 // 5 minutes
* })
* }
*
* // Prefetch data on route change
* useEffect(() => {
* operations.prefetchQuery('getUsers', { status: 'active' })
* }, [])
* ```
*/
prefetchQuery<K extends keyof TQueries, T = InferQueryReturn<TQueries[K]>>(
queryKey: K,
params?: InferQueryParams<TQueries[K]>,
options?: FetchQueryOptions<InferQueryReturn<TQueries[K]>, Error, T, QueryKey, never>
) {
const prefetchQueryKey = this.getQueryKey(queryKey, params)
const queryFn = this._queries[queryKey] as QueryFn
return this._options.queryClient.prefetchQuery<InferQueryReturn<TQueries[K]>, Error, T, QueryKey>({
queryKey: prefetchQueryKey,
queryFn: queryFn,
...options,
})
}
/**
* Retrieves cached query data if it exists
* @template K - The query key type
* @template T - The expected return type (defaults to inferred query return type)
* @param queryKey - The name of the registered query
* @param params - Parameters used when the query was cached (optional if query doesn't require params)
* @returns The cached data if it exists, undefined otherwise
*
* @example
* ```typescript
* // Get cached user data
* const cachedUser = operations.getQueryData('getUser', 'user-123')
* if (cachedUser) {
* console.log('User already in cache:', cachedUser.name)
* }
*
* // Check if users list is cached before showing loading state
* const cachedUsers = operations.getQueryData('getUsers')
* const showSkeleton = !cachedUsers
*
* // Access cached data in event handlers
* const handleUserAction = () => {
* const currentUser = operations.getQueryData('getCurrentUser')
* if (currentUser?.role === 'admin') {
* // Perform admin action
* }
* }
* ```
*/
async getQueryData<K extends keyof TQueries, T = InferQueryReturn<TQueries[K]>>(
queryKey: K,
params?: InferQueryParams<TQueries[K]>,
options?: FetchQueryOptions<InferQueryReturn<TQueries[K]>, Error, T, QueryKey, never>,
) {
const prefetchQueryKey = this.getQueryKey(queryKey, params)
const cachedData = this._options.queryClient.getQueryData<T, QueryKey>(prefetchQueryKey)
if (!cachedData) {
await this.prefetchQuery(queryKey, params, options)
}
return this._options.queryClient.getQueryData<T, QueryKey>(prefetchQueryKey)
}
}
import { QueryClient } from '../../types'
/**
* Configuration options for QueryOperations
*/
export type QueryOperationsOptions = {
/** The React Query client instance */
queryClient: QueryClient
}
/**
* Generic mutation function type
* @template T - The input data type
* @template R - The return data type
*/
export type MutationFn<T = any, R = any> = (data: T) => Promise<R> | R
/**
* Generic query function type
* @template T - The parameters type
* @template R - The return data type
*/
export type QueryFn<T = any, R = any> = (params?: T) => Promise<R> | R
/**
* Utility type to infer mutation function parameters
* @template T - The mutation function type
*/
export type InferMutationParams<T> = T extends MutationFn<infer P, any> ? P : never
/**
* Utility type to infer mutation function return type
* @template T - The mutation function type
*/
export type InferMutationReturn<T> = T extends MutationFn<any, infer R> ? R : never
/**
* Utility type to infer query function parameters
* @template T - The query function type
*/
export type InferQueryParams<T> = T extends QueryFn<infer P, any> ? P : never
/**
* Utility type to infer query function return type
* @template T - The query function type
*/
export type InferQueryReturn<T> = T extends QueryFn<any, infer R> ? R : never
import React from 'react'
import { describe, it, expect, beforeEach, jest } from 'bun:test'
import { renderHook, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createQueryManager } from '../factors/createQueryManager'
import { createTestQueryClient, TestUser, TestUserFilters, createMockUsers, createMockApiFunctions } from './setup'
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('Integration Tests', () => {
let queryClient: ReturnType<typeof createTestQueryClient>
let mockApi: ReturnType<typeof createMockApiFunctions>
let queryManager: ReturnType<typeof createQueryManager<TestUser, TestUserFilters>>
beforeEach(() => {
queryClient = createTestQueryClient()
mockApi = createMockApiFunctions()
queryManager = createQueryManager<TestUser, TestUserFilters>({
name: 'users',
queryClient,
listFn: mockApi.listFn,
retrieveFn: mockApi.retrieveFn,
createFn: mockApi.createFn,
updateFn: mockApi.updateFn,
deleteFn: mockApi.deleteFn,
listLimit: 5
})
})
describe('Complete CRUD Workflow', () => {
it('should handle complete user lifecycle', async () => {
// Start with some existing users
const initialUsers = createMockUsers(3)
mockApi.setUsers(initialUsers)
// 1. Load initial list
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
expect(listResult.current.items).toHaveLength(3)
// 2. Retrieve specific user
const targetUser = listResult.current.items[0]
const { result: retrieveResult } = renderHook(
() => queryManager.useRetrieve(targetUser.id),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(retrieveResult.current.query.isSuccess).toBe(true)
})
expect(retrieveResult.current.item).toEqual(targetUser)
// 3. Create new user
const { result: createResult } = renderHook(
() => queryManager.useCreate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
act(() => {
createResult.current.mutate({
name: 'New User',
email: 'new@example.com',
status: 'active'
})
})
await waitFor(() => {
expect(createResult.current.isSuccess).toBe(true)
})
expect(listResult.current.items).toHaveLength(4)
expect(listResult.current.items[0].name).toBe('New User')
// 4. Update existing user
const { result: updateResult } = renderHook(
() => queryManager.useUpdate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
act(() => {
updateResult.current.mutate({
id: targetUser.id,
name: 'Updated Name',
status: 'inactive'
})
})
await waitFor(() => {
expect(updateResult.current.isSuccess).toBe(true)
})
expect(retrieveResult.current.item?.name).toBe('Updated Name')
expect(retrieveResult.current.item?.status).toBe('inactive')
// 5. Delete user
const { result: deleteResult } = renderHook(
() => queryManager.useDelete({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
const userToDelete = listResult.current.items[1]
act(() => {
deleteResult.current.mutate(userToDelete.id)
})
await waitFor(() => {
expect(deleteResult.current.isSuccess).toBe(true)
})
expect(listResult.current.items).toHaveLength(3)
expect(listResult.current.items.find(u => u.id === userToDelete.id)).toBeUndefined()
})
})
describe('Pagination and Infinite Scroll', () => {
beforeEach(() => {
const manyUsers = createMockUsers(25)
mockApi.setUsers(manyUsers)
})
it('should handle pagination correctly', async () => {
const { result } = renderHook(
() => queryManager.useList({ limit: 5 }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
// First page
expect(result.current.items).toHaveLength(5)
expect(result.current.query.hasNextPage).toBe(true)
// Load next page
act(() => {
result.current.query.fetchNextPage()
})
await waitFor(() => {
expect(result.current.items).toHaveLength(10)
})
// Load more pages
act(() => {
result.current.query.fetchNextPage()
})
await waitFor(() => {
expect(result.current.items).toHaveLength(15)
})
// Continue until all pages loaded
while (result.current.query.hasNextPage) {
act(() => {
result.current.query.fetchNextPage()
})
await waitFor(() => {
expect(result.current.query.isFetchingNextPage).toBe(false)
})
}
expect(result.current.items).toHaveLength(25)
expect(result.current.query.hasNextPage).toBe(false)
})
it('should maintain pagination after mutations', async () => {
const { result: listResult } = renderHook(
() => queryManager.useList({ limit: 5 }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
// Load multiple pages
act(() => {
listResult.current.query.fetchNextPage()
})
await waitFor(() => {
expect(listResult.current.items).toHaveLength(10)
})
// Create new item
const { result: createResult } = renderHook(
() => queryManager.useCreate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
act(() => {
createResult.current.mutate({
name: 'Paginated New User',
email: 'paginated@example.com',
status: 'active'
})
})
await waitFor(() => {
expect(createResult.current.isSuccess).toBe(true)
})
// Should maintain pagination structure
expect(listResult.current.items).toHaveLength(11) // +1 new item
expect(listResult.current.items[0].name).toBe('Paginated New User')
})
})
describe('Filtering and Search', () => {
beforeEach(() => {
const users = [
...createMockUsers(5).map(u => ({ ...u, status: 'active' as const })),
...createMockUsers(3).map(u => ({ ...u, status: 'inactive' as const }))
]
mockApi.setUsers(users)
})
it('should filter list results correctly', async () => {
const { result: activeResult } = renderHook(
() => queryManager.useList({ filters: { status: 'active' } }),
{ wrapper: createWrapper(queryClient) }
)
const { result: inactiveResult } = renderHook(
() => queryManager.useList({ filters: { status: 'inactive' } }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(activeResult.current.query.isSuccess).toBe(true)
expect(inactiveResult.current.query.isSuccess).toBe(true)
})
expect(activeResult.current.items).toHaveLength(5)
expect(inactiveResult.current.items).toHaveLength(3)
expect(activeResult.current.items.every(u => u.status === 'active')).toBe(true)
expect(inactiveResult.current.items.every(u => u.status === 'inactive')).toBe(true)
})
it('should handle mutations with filtered lists', async () => {
const { result: activeListResult } = renderHook(
() => queryManager.useList({ filters: { status: 'active' } }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(activeListResult.current.query.isSuccess).toBe(true)
})
// Create with matching filter
const { result: createResult } = renderHook(
() => queryManager.useCreate({
optimistic: true,
listFilters: { status: 'active' }
}),
{ wrapper: createWrapper(queryClient) }
)
act(() => {
createResult.current.mutate({
name: 'New Active User',
email: 'active@example.com',
status: 'active'
})
})
await waitFor(() => {
expect(createResult.current.isSuccess).toBe(true)
})
expect(activeListResult.current.items).toHaveLength(6)
expect(activeListResult.current.items[0].name).toBe('New Active User')
})
it('should handle search filtering', async () => {
const users = createMockUsers(10).map((user, index) => ({
...user,
name: index % 2 === 0 ? `John ${index}` : `Jane ${index}`,
email: index % 2 === 0 ? `john${index}@example.com` : `jane${index}@example.com`
}))
mockApi.setUsers(users)
const { result } = renderHook(
() => queryManager.useList({ filters: { search: 'John' } }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(result.current.items.every(u => u.name.includes('John'))).toBe(true)
})
})
describe('Cache Synchronization', () => {
beforeEach(() => {
const users = createMockUsers(10)
mockApi.setUsers(users)
})
it('should keep list and retrieve caches synchronized', async () => {
// Load list first
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
const targetUser = listResult.current.items[2]
// Load specific user
const { result: retrieveResult } = renderHook(
() => queryManager.useRetrieve(targetUser.id),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(retrieveResult.current.query.isSuccess).toBe(true)
})
// Update the user through retrieve cache
const { result: updateResult } = renderHook(
() => queryManager.useUpdate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
act(() => {
updateResult.current.mutate({
id: targetUser.id,
name: 'Synchronized Update'
})
})
await waitFor(() => {
expect(updateResult.current.isSuccess).toBe(true)
})
// Both caches should reflect the change
expect(retrieveResult.current.item?.name).toBe('Synchronized Update')
expect(listResult.current.items.find(u => u.id === targetUser.id)?.name).toBe('Synchronized Update')
})
it('should handle multiple filtered lists correctly', async () => {
const users = [
{ id: '1', name: 'Active User 1', email: 'a1@test.com', status: 'active' as const, createdAt: '2024-01-01' },
{ id: '2', name: 'Active User 2', email: 'a2@test.com', status: 'active' as const, createdAt: '2024-01-02' },
{ id: '3', name: 'Inactive User', email: 'i1@test.com', status: 'inactive' as const, createdAt: '2024-01-03' }
]
mockApi.setUsers(users)
// Load different filtered lists
const { result: allListResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
const { result: activeListResult } = renderHook(
() => queryManager.useList({ filters: { status: 'active' } }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(allListResult.current.query.isSuccess).toBe(true)
expect(activeListResult.current.query.isSuccess).toBe(true)
})
// Update a user
const { result: updateResult } = renderHook(
() => queryManager.useUpdate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
act(() => {
updateResult.current.mutate({
id: '1',
status: 'inactive'
})
})
await waitFor(() => {
expect(updateResult.current.isSuccess).toBe(true)
})
// All lists should be updated
const updatedUserInAll = allListResult.current.items.find(u => u.id === '1')
const updatedUserInActive = activeListResult.current.items.find(u => u.id === '1')
expect(updatedUserInAll?.status).toBe('inactive')
expect(updatedUserInActive?.status).toBe('inactive')
})
})
describe('Performance and Memory', () => {
it('should handle large datasets efficiently', async () => {
// Create a large dataset
const largeDataset = createMockUsers(1000)
mockApi.setUsers(largeDataset)
const { result } = renderHook(
() => queryManager.useList({ limit: 50 }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
// Should load only the requested amount
expect(result.current.items).toHaveLength(50)
// Load next page efficiently
const startTime = performance.now()
act(() => {
result.current.query.fetchNextPage()
})
await waitFor(() => {
expect(result.current.items).toHaveLength(100)
})
const endTime = performance.now()
const loadTime = endTime - startTime
// Should be reasonably fast (less than 100ms)
expect(loadTime).toBeLessThan(100)
})
it('should properly clean up resources', async () => {
const { result, unmount } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
// Unmount component
unmount()
// Query should still be in cache but can be garbage collected
expect(queryClient.getQueryCache().getAll()).toHaveLength(1)
})
})
describe('Real-world Scenarios', () => {
it('should handle concurrent user sessions', async () => {
const users = createMockUsers(5)
mockApi.setUsers(users)
// Simulate two different user sessions
const queryClient1 = createTestQueryClient()
const queryClient2 = createTestQueryClient()
const queryManager1 = createQueryManager<TestUser, TestUserFilters>({
name: 'users',
queryClient: queryClient1,
listFn: mockApi.listFn,
retrieveFn: mockApi.retrieveFn,
createFn: mockApi.createFn,
updateFn: mockApi.updateFn,
deleteFn: mockApi.deleteFn
})
const queryManager2 = createQueryManager<TestUser, TestUserFilters>({
name: 'users',
queryClient: queryClient2,
listFn: mockApi.listFn,
retrieveFn: mockApi.retrieveFn,
createFn: mockApi.createFn,
updateFn: mockApi.updateFn,
deleteFn: mockApi.deleteFn
})
// Load data in both sessions
const { result: session1Result } = renderHook(
() => queryManager1.useList(),
{ wrapper: createWrapper(queryClient1) }
)
const { result: session2Result } = renderHook(
() => queryManager2.useList(),
{ wrapper: createWrapper(queryClient2) }
)
await waitFor(() => {
expect(session1Result.current.query.isSuccess).toBe(true)
expect(session2Result.current.query.isSuccess).toBe(true)
})
// Both sessions should have independent caches
expect(session1Result.current.items).toEqual(session2Result.current.items)
// Create in session 1
const { result: createResult } = renderHook(
() => queryManager1.useCreate({ optimistic: true }),
{ wrapper: createWrapper(queryClient1) }
)
act(() => {
createResult.current.mutate({
name: 'Session 1 User',
email: 'session1@example.com',
status: 'active'
})
})
await waitFor(() => {
expect(createResult.current.isSuccess).toBe(true)
})
// Session 1 should see the new user
expect(session1Result.current.items.some(u => u.name === 'Session 1 User')).toBe(true)
// Session 2 should not see it until refetch
expect(session2Result.current.items.some(u => u.name === 'Session 1 User')).toBe(false)
// Refetch in session 2
act(() => {
session2Result.current.query.refetch()
})
await waitFor(() => {
expect(session2Result.current.items.some(u => u.name === 'Session 1 User')).toBe(true)
})
})
})
})
import { describe, it, expect, beforeEach, spyOn, mock } from 'bun:test'
import { Mutations, createMutations } from '../lib/Mutations'
import { QueryKeys } from '../lib/QueryKeys'
import { createTestQueryClient, TestUser, TestUserFilters, createMockUsers } from './setup'
import { InfiniteData } from '@tanstack/react-query'
describe('Mutations', () => {
let queryClient: ReturnType<typeof createTestQueryClient>
let queryKeys: QueryKeys<TestUser, TestUserFilters>
let mutations: Mutations<TestUser, TestUserFilters>
beforeEach(() => {
queryClient = createTestQueryClient()
queryKeys = new QueryKeys('users', queryClient)
mutations = new Mutations(queryKeys, queryClient, 'users')
})
describe('constructor', () => {
it('should create Mutations instance with correct parameters', () => {
expect(mutations).toBeInstanceOf(Mutations)
expect(mutations['queryKeys']).toBe(queryKeys)
expect(mutations['queryClient']).toBe(queryClient)
expect(mutations['queryName']).toBe('users')
})
})
describe('addItem', () => {
const mockUsers = createMockUsers(3)
beforeEach(() => {
const mockData: InfiniteData<TestUser[], number> = {
pages: [mockUsers.slice(0, 2), mockUsers.slice(2)],
pageParams: [0, 2]
}
queryClient.setQueryData(['users', 'list'], mockData)
})
it('should add item to start by default', () => {
const newUser = createMockUsers(1)[0]
mutations.addItem(newUser)
const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number>
expect(data.pages[0][0]).toEqual(newUser)
expect(data.pages[0]).toHaveLength(3) // original 2 + new 1
})
it('should add item to start when position is "start"', () => {
const newUser = createMockUsers(1)[0]
mutations.addItem(newUser, 'start')
const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number>
expect(data.pages[0][0]).toEqual(newUser)
})
it('should add item to end when position is "end"', () => {
const newUser = createMockUsers(1)[0]
mutations.addItem(newUser, 'end')
const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number>
const lastPage = data.pages[data.pages.length - 1]
expect(lastPage[lastPage.length - 1]).toEqual(newUser)
})
it('should create new data when no cache exists', () => {
queryClient.clear()
const newUser = createMockUsers(1)[0]
mutations.addItem(newUser)
const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number>
expect(data).toEqual({
pageParams: [0],
pages: [[newUser]]
})
})
it('should add item with specific list filters', () => {
const filters = { status: 'active' as const }
const newUser = createMockUsers(1)[0]
// Set up filtered cache
queryClient.setQueryData(['users', 'list', filters], {
pages: [mockUsers.slice(0, 1)],
pageParams: [0]
})
mutations.addItem(newUser, 'start', filters)
const data = queryClient.getQueryData(['users', 'list', filters]) as InfiniteData<TestUser[], number>
expect(data.pages[0][0]).toEqual(newUser)
})
it('should handle multiple query keys (RemovedItemMap)', () => {
const newUser = createMockUsers(1)[0]
const queryKey1 = ['users', 'list']
const queryKey2 = ['users', 'list', { status: 'active' }]
// Set up multiple caches
queryClient.setQueryData(queryKey1, {
pages: [mockUsers.slice(0, 2)],
pageParams: [0]
})
queryClient.setQueryData(queryKey2, {
pages: [mockUsers.slice(0, 1)],
pageParams: [0]
})
const removedItemMap = [
[queryKey1, { pageIndex: 0, itemIndex: 1 }],
[queryKey2, { pageIndex: 0, itemIndex: 0 }]
] as any
mutations.addItem(newUser, removedItemMap)
const data1 = queryClient.getQueryData(queryKey1) as InfiniteData<TestUser[], number>
const data2 = queryClient.getQueryData(queryKey2) as InfiniteData<TestUser[], number>
expect(data1.pages[0][1]).toEqual(newUser)
expect(data2.pages[0][0]).toEqual(newUser)
})
it('should handle item position beyond page length', () => {
const newUser = createMockUsers(1)[0]
const queryKey = ['users', 'list']
queryClient.setQueryData(queryKey, {
pages: [mockUsers.slice(0, 1)],
pageParams: [0]
})
const removedItemMap = [
[queryKey, { pageIndex: 0, itemIndex: 10 }] // Beyond page length
] as any
mutations.addItem(newUser, removedItemMap)
const data = queryClient.getQueryData(queryKey) as InfiniteData<TestUser[], number>
expect(data.pages[0][data.pages[0].length - 1]).toEqual(newUser)
})
it('should handle page index beyond pages length', () => {
const newUser = createMockUsers(1)[0]
const queryKey = ['users', 'list']
queryClient.setQueryData(queryKey, {
pages: [mockUsers.slice(0, 1)],
pageParams: [0]
})
const removedItemMap = [
[queryKey, { pageIndex: 5, itemIndex: 0 }] // Beyond pages length
] as any
mutations.addItem(newUser, removedItemMap)
const data = queryClient.getQueryData(queryKey) as InfiniteData<TestUser[], number>
const lastPage = data.pages[data.pages.length - 1]
expect(lastPage[lastPage.length - 1]).toEqual(newUser)
})
it('should handle empty pages array', () => {
const newUser = createMockUsers(1)[0]
const queryKey = ['users', 'list']
queryClient.setQueryData(queryKey, {
pages: [],
pageParams: []
})
const removedItemMap = [
[queryKey, { pageIndex: 0, itemIndex: 0 }]
] as any
mutations.addItem(newUser, removedItemMap)
const data = queryClient.getQueryData(queryKey) as InfiniteData<TestUser[], number>
expect(data.pages[0]).toEqual([newUser])
})
})
describe('removeItem', () => {
const mockUsers = createMockUsers(3)
beforeEach(() => {
// Set up multiple list queries
queryClient.setQueryData(['users', 'list'], {
pages: [mockUsers.slice(0, 2), [mockUsers[2]]],
pageParams: [0, 2]
})
queryClient.setQueryData(['users', 'list', { status: 'active' }], {
pages: [[mockUsers[0]]],
pageParams: [0]
})
// Set up retrieve cache
queryClient.setQueryData(['users', 'retrieve', mockUsers[0].id], mockUsers[0])
})
it('should remove item from all list queries and return removal map', () => {
const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([
{ queryKey: ['users', 'list'], state: { data: queryClient.getQueryData(['users', 'list']) } },
{ queryKey: ['users', 'list', { status: 'active' }], state: { data: queryClient.getQueryData(['users', 'list', { status: 'active' }]) } }
] as any)
const removedMap = mutations.removeItem(mockUsers[0].id)
expect(removedMap).toHaveLength(2)
expect(removedMap![0]).toEqual([['users', 'list'], { pageIndex: 0, itemIndex: 0 }])
expect(removedMap![1]).toEqual([['users', 'list', { status: 'active' }], { pageIndex: 0, itemIndex: 0 }])
// Verify item was removed from caches
const data1 = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number>
const data2 = queryClient.getQueryData(['users', 'list', { status: 'active' }]) as InfiniteData<TestUser[], number>
expect(data1.pages[0]).not.toContain(mockUsers[0])
expect(data2.pages[0]).toHaveLength(0)
})
it('should remove retrieve query data', () => {
const removeRetrieveQueryDataSpy = spyOn(queryKeys, 'removeRetrieveQueryData')
const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([])
mutations.removeItem(mockUsers[0].id)
expect(removeRetrieveQueryDataSpy).toHaveBeenCalledWith(mockUsers[0].id)
})
it('should return empty array when item not found in any list', () => {
const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([
{ queryKey: ['users', 'list'], state: { data: queryClient.getQueryData(['users', 'list']) } }
] as any)
const removedMap = mutations.removeItem('non-existent-id')
expect(removedMap).toEqual([])
})
it('should handle empty pages after removal', () => {
const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([
{ queryKey: ['users', 'list'], state: { data: { pages: [[mockUsers[0]]], pageParams: [0] } } }
] as any)
mutations.removeItem(mockUsers[0].id)
const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number>
expect(data.pages).toEqual([[]])
expect(data.pageParams).toEqual([0])
})
it('should filter out empty pages', () => {
const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([
{ queryKey: ['users', 'list'], state: { data: { pages: [[mockUsers[0]], [mockUsers[1]]], pageParams: [0, 1] } } }
] as any)
mutations.removeItem(mockUsers[0].id)
const data = queryClient.getQueryData(['users', 'list']) as InfiniteData<TestUser[], number>
expect(data.pages).toEqual([[mockUsers[1]]])
expect(data.pageParams).toEqual([0])
})
it('should handle queries with no current data', () => {
const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([
{ queryKey: ['users', 'list'], state: { data: null } }
] as any)
const removedMap = mutations.removeItem(mockUsers[0].id)
expect(removedMap).toEqual([])
})
})
describe('updateItems', () => {
const mockUsers = createMockUsers(3)
beforeEach(() => {
const getAllListQueriesSpy = spyOn(queryKeys, 'getAllListQueries').mockReturnValue([
{
queryKey: ['users', 'list'],
state: {
data: {
pages: [mockUsers.slice(0, 2), [mockUsers[2]]],
pageParams: [0, 2]
}
}
}
] as any)
})
it('should update single item by id', () => {
const updateData = { ...mockUsers[0], name: 'Updated Name' }
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(updateData)
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'retrieve', updateData.id],
updateData
)
})
it('should update single item by tempId', () => {
const updateData = { ...mockUsers[0], name: 'Updated Name', tempId: 'temp-1' }
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(updateData)
const expectedData = { ...updateData }
// @ts-ignore
delete expectedData.tempId
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'retrieve', updateData.id],
expectedData
)
})
it('should update multiple items', () => {
const updateData = [
{ ...mockUsers[0], name: 'Updated Name 1' },
{ ...mockUsers[1], name: 'Updated Name 2' }
]
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(updateData)
expect(setQueryDataSpy).toHaveBeenCalledTimes(3) // 2 retrieve + 1 list update
})
it('should not update if data is the same (deep equal)', () => {
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
// Update with same data
mutations.updateItems(mockUsers[0])
// Should only set retrieve cache, not update list (no changes)
expect(setQueryDataSpy).toHaveBeenCalledTimes(1)
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'retrieve', mockUsers[0].id],
mockUsers[0]
)
})
it('should update list cache when item data changes', () => {
const updateData = { ...mockUsers[0], name: 'Updated Name' }
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(updateData)
// Should update both retrieve and list cache
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'retrieve', updateData.id],
updateData
)
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'list'],
expect.objectContaining({
pages: expect.arrayContaining([
expect.arrayContaining([updateData])
])
})
)
})
it('should handle queries with no data', () => {
// Override the spy for this test
spyOn(queryKeys, 'getAllListQueries').mockReturnValue([
{ queryKey: ['users', 'list'], state: { data: null } }
] as any)
const updateData = { ...mockUsers[0], name: 'Updated Name' }
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(updateData)
// Should only update retrieve cache
expect(setQueryDataSpy).toHaveBeenCalledTimes(1)
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'retrieve', updateData.id],
updateData
)
})
it('should preserve page structure when updating items', () => {
const updateData = { ...mockUsers[2], name: 'Updated Name' } // Item in second page
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(updateData)
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'list'],
expect.objectContaining({
pages: [
mockUsers.slice(0, 2), // First page unchanged
[updateData] // Second page with updated item
],
pageParams: [0, 2]
})
)
})
it('should handle items not found in list', () => {
const nonExistentItem = { id: 'non-existent', name: 'New Name' } as TestUser
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(nonExistentItem)
// Should only update retrieve cache
expect(setQueryDataSpy).toHaveBeenCalledTimes(1)
expect(setQueryDataSpy).toHaveBeenCalledWith(
['users', 'retrieve', nonExistentItem.id],
nonExistentItem
)
})
it('should remove tempId from cached data', () => {
const updateData = { ...mockUsers[0], name: 'Updated Name', tempId: 'temp-123' }
const setQueryDataSpy = spyOn(queryClient, 'setQueryData')
mutations.updateItems(updateData)
// Check retrieve cache call - tempId should be removed
const retrieveCall = setQueryDataSpy.mock.calls.find(call =>
Array.isArray(call[0]) && call[0].includes('retrieve')
)
expect(retrieveCall).toBeDefined()
const cachedData = retrieveCall![1] as any
expect(cachedData).not.toHaveProperty('tempId')
expect(cachedData.name).toBe('Updated Name')
// Check list cache call - tempId should be removed from items
const listCall = setQueryDataSpy.mock.calls.find(call =>
Array.isArray(call[0]) && call[0].includes('list')
)
if (listCall) {
const updatedListData = listCall[1] as any
const updatedItem = updatedListData.pages.flat().find((item: any) => item.id === updateData.id)
expect(updatedItem).not.toHaveProperty('tempId')
}
})
})
describe('createMutations factory', () => {
it('should create Mutations instance', () => {
const mutations = createMutations(queryKeys, queryClient, 'test')
expect(mutations).toBeInstanceOf(Mutations)
expect(mutations['queryKeys']).toBe(queryKeys)
expect(mutations['queryClient']).toBe(queryClient)
expect(mutations['queryName']).toBe('test')
})
})
})
import { describe, it, expect, beforeEach, jest, spyOn } from 'bun:test'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { QueryManager } from '../lib/QueryManager'
import { createTestQueryClient, TestUser, TestUserFilters, createMockUsers, createMockApiFunctions } from './setup'
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('QueryManager', () => {
let queryClient: ReturnType<typeof createTestQueryClient>
let mockApi: ReturnType<typeof createMockApiFunctions>
let queryManager: QueryManager<TestUser, TestUserFilters>
beforeEach(() => {
queryClient = createTestQueryClient()
mockApi = createMockApiFunctions()
queryManager = new QueryManager({
name: 'users',
queryClient,
listFn: mockApi.listFn,
retrieveFn: mockApi.retrieveFn,
createFn: mockApi.createFn,
updateFn: mockApi.updateFn,
deleteFn: mockApi.deleteFn,
listLimit: 10
})
})
describe('constructor', () => {
it('should create QueryManager with correct configuration', () => {
expect(queryManager.name).toBe('users')
expect(queryManager.functions).toEqual({
list: mockApi.listFn,
retrieve: mockApi.retrieveFn,
create: mockApi.createFn,
update: mockApi.updateFn,
delete: mockApi.deleteFn
})
expect(queryManager.queryKeys).toBeDefined()
expect(queryManager.mutations).toBeDefined()
})
})
describe('useList', () => {
beforeEach(() => {
const mockUsers = createMockUsers(25) // More than one page
mockApi.setUsers(mockUsers)
})
it('should fetch and return list items', async () => {
const { result } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(result.current.items).toHaveLength(10) // Default limit
expect(result.current.queryKey).toEqual(['users', 'list'])
})
it('should use custom limit', async () => {
const { result } = renderHook(
() => queryManager.useList({ limit: 5 }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(result.current.items).toHaveLength(5)
})
it('should apply filters', async () => {
const activeUsers = createMockUsers(3).map(user => ({ ...user, status: 'active' as const }))
const inactiveUsers = createMockUsers(2).map(user => ({ ...user, status: 'inactive' as const }))
mockApi.setUsers([...activeUsers, ...inactiveUsers])
const { result } = renderHook(
() => queryManager.useList({ filters: { status: 'active' } }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(result.current.items).toHaveLength(3)
expect(result.current.items.every(user => user.status === 'active')).toBe(true)
})
it('should handle infinite scroll pagination', async () => {
const { result } = renderHook(
() => queryManager.useList({ limit: 5 }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(result.current.items).toHaveLength(5)
expect(result.current.query.hasNextPage).toBe(true)
// Fetch next page
result.current.query.fetchNextPage()
await waitFor(() => {
expect(result.current.items).toHaveLength(10)
})
})
it('should call useListEffect if provided', async () => {
const useListEffect = jest.fn()
const queryManagerWithEffect = new QueryManager({
name: 'users',
queryClient,
listFn: mockApi.listFn,
retrieveFn: mockApi.retrieveFn,
createFn: mockApi.createFn,
updateFn: mockApi.updateFn,
deleteFn: mockApi.deleteFn,
useListEffect
})
renderHook(
() => queryManagerWithEffect.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(useListEffect).toHaveBeenCalled()
})
})
it('should handle empty results', async () => {
mockApi.setUsers([])
const { result } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(result.current.items).toEqual([])
})
it('should determine next page correctly', async () => {
const { result } = renderHook(
() => queryManager.useList({ limit: 10 }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
// Has next page when more items available
expect(result.current.query.hasNextPage).toBe(true)
// Fetch all pages
while (result.current.query.hasNextPage) {
result.current.query.fetchNextPage()
await waitFor(() => {
expect(result.current.query.isFetchingNextPage).toBe(false)
})
}
expect(result.current.query.hasNextPage).toBe(false)
})
})
describe('useRetrieve', () => {
beforeEach(() => {
const mockUsers = createMockUsers(5)
mockApi.setUsers(mockUsers)
})
it('should fetch and return single item', async () => {
const users = mockApi.getUsersArray()
const targetUser = users[0]
const { result } = renderHook(
() => queryManager.useRetrieve(targetUser.id),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(result.current.item).toEqual(targetUser)
expect(result.current.queryKey).toEqual(['users', 'retrieve', targetUser.id])
})
it('should use initial data from list cache', async () => {
const users = createMockUsers(3)
mockApi.setUsers(users)
// First, populate list cache
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
// Then retrieve specific item - should use cache as initial data
const { result: retrieveResult } = renderHook(
() => queryManager.useRetrieve(users[0].id),
{ wrapper: createWrapper(queryClient) }
)
// Should have initial data immediately
expect(retrieveResult.current.item).toEqual(users[0])
})
it('should handle item not found error', async () => {
const { result } = renderHook(
() => queryManager.useRetrieve('non-existent-id'),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isError).toBe(true)
})
expect(result.current.item).toBeUndefined()
expect(result.current.query.error).toEqual(new Error('User not found'))
})
it('should call custom select function', async () => {
const users = mockApi.getUsersArray()
const targetUser = users[0]
const customSelect = jest.fn((user: TestUser) => ({ ...user, selected: true }))
const { result } = renderHook(
() => queryManager.useRetrieve(targetUser.id, { select: customSelect }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.query.isSuccess).toBe(true)
})
expect(customSelect).toHaveBeenCalledWith(targetUser)
})
it('should update list cache when retrieve data changes', async () => {
const users = createMockUsers(3)
mockApi.setUsers(users)
// Populate list cache first
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
// Update a user via API BEFORE retrieving
const updatedUser = { ...users[0], name: 'Updated Name' }
mockApi.setUsers([updatedUser, ...users.slice(1)])
await queryManager.queryKeys.refetchList()
// Retrieve the updated user
const { result: retrieveResult } = renderHook(
() => queryManager.useRetrieve(users[0].id),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(retrieveResult.current.query.isSuccess).toBe(true)
})
expect(retrieveResult.current.item).toEqual(updatedUser)
})
})
describe('useCreate', () => {
beforeEach(() => {
mockApi.setUsers(createMockUsers(3))
})
it('should create new item', async () => {
const { result } = renderHook(
() => queryManager.useCreate(),
{ wrapper: createWrapper(queryClient) }
)
const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const }
result.current.mutate(newUserData)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toMatchObject(newUserData)
expect(mockApi.getUsersArray()).toHaveLength(4)
})
it('should handle optimistic updates', async () => {
// First, populate list cache
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
const { result: createResult } = renderHook(
() => queryManager.useCreate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const }
createResult.current.mutate(newUserData)
// Should immediately appear in list cache
await waitFor(() => {
expect(listResult.current.items.some(item => item.name === 'New User')).toBe(true)
})
await waitFor(() => {
expect(createResult.current.isSuccess).toBe(true)
})
// Should replace temp item with real data
expect(listResult.current.items.some(item =>
item.name === 'New User' && !item.id.startsWith('temp-')
)).toBe(true)
})
// it('should rollback optimistic update on error', async () => {
// // Mock API to throw error BEFORE creating hook
// const originalCreateFn = mockApi.createFn
// mockApi.createFn = jest.fn().mockRejectedValue(new Error('Creation failed'))
// // First, populate list cache
// const { result: listResult } = renderHook(
// () => queryManager.useList(),
// { wrapper: createWrapper(queryClient) }
// )
// await waitFor(() => {
// expect(listResult.current.query.isSuccess).toBe(true)
// })
// const initialItemCount = listResult.current.items.length
// const { result: createResult } = renderHook(
// () => queryManager.useCreate({
// optimistic: true,
// onError: (error) => {
// // Handle error silently for test
// console.log('Expected error:', error.message)
// }
// }),
// { wrapper: createWrapper(queryClient) }
// )
// const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const }
// await createResult.current.mutateAsync(newUserData)
// // Should temporarily appear in list
// await waitFor(() => {
// expect(listResult.current.items.length).toBeGreaterThan(initialItemCount)
// })
// // Should be removed on error
// await waitFor(() => {
// expect(createResult.current.isError).toBe(true)
// })
// expect(listResult.current.items).toHaveLength(initialItemCount)
// // Restore original function
// mockApi.createFn = originalCreateFn
// })
it('should call custom mutation callbacks', async () => {
const onMutate = jest.fn()
const onSuccess = jest.fn()
const onError = jest.fn()
const { result } = renderHook(
() => queryManager.useCreate({ onMutate, onSuccess, onError }),
{ wrapper: createWrapper(queryClient) }
)
const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const }
await result.current.mutateAsync(newUserData)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(onMutate).toHaveBeenCalled()
expect(onSuccess).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
})
it('should append to start by default', async () => {
// First, populate list cache
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
const { result: createResult } = renderHook(
() => queryManager.useCreate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const }
createResult.current.mutate(newUserData)
await waitFor(() => {
expect(listResult.current.items[0].name).toBe('New User')
})
})
it('should append to end when specified', async () => {
// First, populate list cache
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
const { result: createResult } = renderHook(
() => queryManager.useCreate({ optimistic: true, appendTo: 'end' }),
{ wrapper: createWrapper(queryClient) }
)
const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const }
createResult.current.mutate(newUserData)
await waitFor(() => {
const items = listResult.current.items
expect(items[items.length - 1].name).toBe('New User')
})
})
it('should handle list filters', async () => {
// First, populate filtered list cache
const { result: listResult } = renderHook(
() => queryManager.useList({ filters: { status: 'active' } }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
const { result: createResult } = renderHook(
() => queryManager.useCreate({
optimistic: true,
listFilters: { status: 'active' }
}),
{ wrapper: createWrapper(queryClient) }
)
const newUserData = { name: 'New User', email: 'new@example.com', status: 'active' as const }
createResult.current.mutate(newUserData)
await waitFor(() => {
expect(listResult.current.items.some(item => item.name === 'New User')).toBe(true)
})
})
})
describe('useUpdate', () => {
let existingUsers: TestUser[]
beforeEach(() => {
existingUsers = createMockUsers(3)
mockApi.setUsers(existingUsers)
})
it('should update existing item', async () => {
const { result } = renderHook(
() => queryManager.useUpdate(),
{ wrapper: createWrapper(queryClient) }
)
const updateData = { id: existingUsers[0].id, name: 'Updated Name' }
result.current.mutate(updateData)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toMatchObject(updateData)
const updatedUsers = mockApi.getUsersArray()
expect(updatedUsers.find(u => u.id === existingUsers[0].id)?.name).toBe('Updated Name')
})
it('should handle optimistic updates', async () => {
// First, populate retrieve cache
const { result: retrieveResult } = renderHook(
() => queryManager.useRetrieve(existingUsers[0].id),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(retrieveResult.current.query.isSuccess).toBe(true)
})
const { result: updateResult } = renderHook(
() => queryManager.useUpdate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
const updateData = { id: existingUsers[0].id, name: 'Optimistically Updated' }
updateResult.current.mutate(updateData)
// Should immediately appear updated
await waitFor(() => {
expect(retrieveResult.current.item?.name).toBe('Optimistically Updated')
})
await waitFor(() => {
expect(updateResult.current.isSuccess).toBe(true)
})
})
// it('should rollback optimistic update on error', async () => {
// // Mock API to throw error BEFORE creating hook
// const originalUpdateFn = mockApi.updateFn
// mockApi.updateFn = jest.fn().mockRejectedValue(new Error('Update failed'))
// // First, populate retrieve cache
// const { result: retrieveResult } = renderHook(
// () => queryManager.useRetrieve(existingUsers[0].id),
// { wrapper: createWrapper(queryClient) }
// )
// await waitFor(() => {
// expect(retrieveResult.current.query.isSuccess).toBe(true)
// })
// const originalName = retrieveResult.current.item?.name
// const { result: updateResult } = renderHook(
// () => queryManager.useUpdate({
// optimistic: true,
// onError: (error) => {
// console.log('Expected error:', error.message)
// }
// }),
// { wrapper: createWrapper(queryClient) }
// )
// const updateData = { id: existingUsers[0].id, name: 'Failed Update' }
// updateResult.current.mutate(updateData)
// // Should temporarily show updated name
// await waitFor(() => {
// expect(retrieveResult.current.item?.name).toBe('Failed Update')
// })
// // Should rollback to original name on error
// await waitFor(() => {
// expect(updateResult.current.isError).toBe(true)
// })
// expect(retrieveResult.current.item?.name).toBe(originalName!)
// // Restore original function
// mockApi.updateFn = originalUpdateFn
// })
it('should call custom mutation callbacks', async () => {
const onMutate = jest.fn()
const onSuccess = jest.fn()
const onError = jest.fn()
const { result } = renderHook(
() => queryManager.useUpdate({ onMutate, onSuccess, onError }),
{ wrapper: createWrapper(queryClient) }
)
const updateData = { id: existingUsers[0].id, name: 'Updated Name' }
result.current.mutate(updateData)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(onMutate).toHaveBeenCalled()
expect(onSuccess).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
})
})
describe('useDelete', () => {
let existingUsers: TestUser[]
beforeEach(() => {
existingUsers = createMockUsers(3)
mockApi.setUsers(existingUsers)
})
it('should delete existing item', async () => {
const { result } = renderHook(
() => queryManager.useDelete(),
{ wrapper: createWrapper(queryClient) }
)
const targetId = existingUsers[0].id
result.current.mutate(targetId)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toBe(targetId)
expect(mockApi.getUsersArray()).toHaveLength(2)
expect(mockApi.getUsersArray().find(u => u.id === targetId)).toBeUndefined()
})
it('should handle optimistic updates', async () => {
// First, populate list cache
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
const { result: deleteResult } = renderHook(
() => queryManager.useDelete({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
const targetId = existingUsers[0].id
deleteResult.current.mutate(targetId)
// Should immediately disappear from list
await waitFor(() => {
expect(listResult.current.items.find(item => item.id === targetId)).toBeUndefined()
})
await waitFor(() => {
expect(deleteResult.current.isSuccess).toBe(true)
})
})
// it('should rollback optimistic delete on error', async () => {
// // Mock API to throw error BEFORE creating hook
// const originalDeleteFn = mockApi.deleteFn
// mockApi.deleteFn = jest.fn().mockRejectedValue(new Error('Delete failed'))
// // First, populate list cache
// const { result: listResult } = renderHook(
// () => queryManager.useList(),
// { wrapper: createWrapper(queryClient) }
// )
// await waitFor(() => {
// expect(listResult.current.query.isSuccess).toBe(true)
// })
// const initialItemCount = listResult.current.items.length
// const targetId = existingUsers[0].id
// const { result: deleteResult } = renderHook(
// () => queryManager.useDelete({
// optimistic: true,
// onError: (error) => {
// console.log('Expected error:', error.message)
// }
// }),
// { wrapper: createWrapper(queryClient) }
// )
// deleteResult.current.mutate(targetId)
// // Should temporarily disappear from list
// await waitFor(() => {
// expect(listResult.current.items.length).toBeLessThan(initialItemCount)
// })
// // Should reappear on error
// await waitFor(() => {
// expect(deleteResult.current.isError).toBe(true)
// })
// expect(listResult.current.items).toHaveLength(initialItemCount)
// expect(listResult.current.items.find(item => item.id === targetId)).toBeDefined()
// // Restore original function
// mockApi.deleteFn = originalDeleteFn
// })
// it('should handle error in delete operation', async () => {
// // Mock API to throw error BEFORE creating hook
// const originalDeleteFn = mockApi.deleteFn
// mockApi.deleteFn = jest.fn().mockRejectedValue(new Error('Delete failed'))
// const { result } = renderHook(
// () => queryManager.useDelete({
// onError: (error) => {
// console.log('Expected error:', error.message)
// }
// }),
// { wrapper: createWrapper(queryClient) }
// )
// const targetId = existingUsers[0].id
// result.current.mutate(targetId)
// await waitFor(() => {
// expect(result.current.isError).toBe(true)
// })
// expect(result.current.error).toEqual(new Error('Delete failed'))
// // Restore original function
// mockApi.deleteFn = originalDeleteFn
// })
it('should call custom mutation callbacks', async () => {
const onMutate = jest.fn()
const onSuccess = jest.fn()
const onError = jest.fn()
const { result } = renderHook(
() => queryManager.useDelete({ onMutate, onSuccess, onError }),
{ wrapper: createWrapper(queryClient) }
)
const targetId = existingUsers[0].id
result.current.mutate(targetId)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(onMutate).toHaveBeenCalled()
expect(onSuccess).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
})
// it('should restore item to original position on rollback', async () => {
// // Mock API to throw error BEFORE creating hook
// const originalDeleteFn = mockApi.deleteFn
// mockApi.deleteFn = jest.fn().mockRejectedValue(new Error('Delete failed'))
// // First, populate list cache
// const { result: listResult } = renderHook(
// () => queryManager.useList(),
// { wrapper: createWrapper(queryClient) }
// )
// await waitFor(() => {
// expect(listResult.current.query.isSuccess).toBe(true)
// })
// const originalItems = [...listResult.current.items]
// const targetId = existingUsers[1].id // Delete middle item
// const { result: deleteResult } = renderHook(
// () => queryManager.useDelete({
// optimistic: true,
// onError: (error) => {
// console.log('Expected error:', error.message)
// }
// }),
// { wrapper: createWrapper(queryClient) }
// )
// deleteResult.current.mutate(targetId)
// // Wait for error and rollback
// await waitFor(() => {
// expect(deleteResult.current.isError).toBe(true)
// })
// // Items should be in original order
// expect(listResult.current.items.length).toBe(originalItems.length)
// expect(listResult.current.items.find(item => item.id === targetId)).toBeDefined()
// // Restore original function
// mockApi.deleteFn = originalDeleteFn
// })
})
describe('prefetchRetrieve', () => {
beforeEach(() => {
const mockUsers = createMockUsers(3)
mockApi.setUsers(mockUsers)
})
it('should prefetch retrieve query', async () => {
const users = mockApi.getUsersArray()
const targetUser = users[0]
const prefetchSpy = spyOn(queryClient, 'prefetchQuery')
await queryManager.prefetchRetrieve(targetUser.id, { staleTime: 5000 })
expect(prefetchSpy).toHaveBeenCalledWith({
staleTime: 5000,
queryKey: ['users', 'retrieve', targetUser.id],
queryFn: expect.any(Function)
})
})
it('should execute prefetch query function correctly', async () => {
const users = mockApi.getUsersArray()
const targetUser = users[0]
await queryManager.prefetchRetrieve(targetUser.id)
// Verify data was prefetched correctly
const cachedData = queryClient.getQueryData(['users', 'retrieve', targetUser.id])
expect(cachedData).toEqual(targetUser)
})
})
describe('integration scenarios', () => {
beforeEach(() => {
const mockUsers = createMockUsers(10)
mockApi.setUsers(mockUsers)
})
it('should synchronize data between list and retrieve caches', async () => {
// This test needs to ensure proper synchronization implementation
// The issue is likely that updating one cache doesn't automatically sync the other
// First, populate list cache
const { result: listResult } = renderHook(
() => queryManager.useList(),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(listResult.current.query.isSuccess).toBe(true)
})
const targetUser = listResult.current.items[0]
// Then retrieve specific item
const { result: retrieveResult } = renderHook(
() => queryManager.useRetrieve(targetUser.id),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(retrieveResult.current.query.isSuccess).toBe(true)
})
// Update the user via mutations (not direct API)
const { result: updateResult } = renderHook(
() => queryManager.useUpdate({ optimistic: true }),
{ wrapper: createWrapper(queryClient) }
)
const updateData = { id: targetUser.id, name: 'Synchronized Update' }
updateResult.current.mutate(updateData)
await waitFor(() => {
expect(updateResult.current.isSuccess).toBe(true)
})
// Both caches should reflect the update
await waitFor(() => {
expect(retrieveResult.current.item?.name).toBe('Synchronized Update')
expect(listResult.current.items.find(u => u.id === targetUser.id)?.name).toBe('Synchronized Update')
})
})
})
})
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
import React from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { renderHook, act, waitFor } from "@testing-library/react"
import { QueryOperations } from "../lib/QueryOperations"
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe("QueryOperations", () => {
let queryClient: QueryClient
let operations: QueryOperations<{}, {}>
beforeEach(() => {
queryClient = new QueryClient()
operations = new QueryOperations({ queryClient })
})
it("registers a mutation correctly", () => {
const fn = mock(() => Promise.resolve("ok"))
const ops = operations.mutation("createUser", fn)
expect(Object.keys(ops.mutations)).toContain("createUser")
expect(typeof ops.mutations.createUser).toBe("function")
})
it("registers a query correctly", () => {
const fn = mock((id: string) => Promise.resolve({ id }))
const ops = operations.query("getUser", fn)
expect(Object.keys(ops.queries)).toContain("getUser")
expect(typeof ops.queries.getUser).toBe("function")
})
it("getQueryKey includes params when passed", () => {
const ops = operations.query("getUser", async (id: string) => ({ id }))
expect(ops.getQueryKey("getUser", "123")).toEqual(["getUser", "123"])
expect(ops.getQueryKey("getUser")).toEqual(["getUser"])
})
it("getMutationKey returns mutation key", () => {
const ops = operations.mutation("createUser", async (data: any) => data)
expect(ops.getMutationKey("createUser")).toEqual(["createUser"])
})
it("prefetchQuery calls queryClient.prefetchQuery", async () => {
const fn = mock((id: string) => Promise.resolve({ id }))
const ops = operations.query("getUser", fn)
const spy = spyOn(queryClient, "prefetchQuery")
await ops.prefetchQuery("getUser", "abc")
expect(spy).toHaveBeenCalled()
expect(spy.mock.calls[0][0].queryKey).toEqual(["getUser", "abc"])
})
it("getQueryData fetches from cache or prefetches", async () => {
const fn = async (id: string) => ({ id })
const ops = operations.query("getUser", fn)
const spyPrefetch = spyOn(queryClient, "prefetchQuery")
const data = await ops.getQueryData("getUser", "123")
expect(data).toBeDefined()
expect(spyPrefetch).toHaveBeenCalled()
})
describe("useMutation hook", () => {
it("runs a mutation and returns expected result", async () => {
const ops = operations.mutation("createUser", async (user: { name: string }) => {
return { id: "1", ...user }
})
const { result } = renderHook(
() => ops.useMutation("createUser"),
{ wrapper: createWrapper(queryClient) }
)
let response: any
await act(async () => {
response = await result.current.mutateAsync({ name: "John" })
})
expect(response).toEqual({ id: "1", name: "John" })
})
})
describe("useQuery hook", () => {
it("fetches query result successfully", async () => {
const ops = operations.query("getUser", async (id: string) => {
return { id, name: "Alice" }
})
const { result } = renderHook(
() => ops.useQuery("getUser", "42", { enabled: true }),
{ wrapper: createWrapper(queryClient) }
)
await waitFor(() => {
expect(result.current.data).toEqual({ id: "42", name: "Alice" })
})
})
})
})
import { afterEach, beforeEach } from 'bun:test'
import { QueryClient } from '@tanstack/react-query'
import { cleanup } from '@testing-library/react'
export interface TestUser {
id: string
name: string
email: string
status: 'active' | 'inactive'
createdAt: string
}
export interface TestUserFilters {
status?: 'active' | 'inactive'
search?: string
}
export const createTestQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
})
}
export const createMockUser = (overrides: Partial<TestUser> = {}): TestUser => ({
id: `user-${Date.now()}-${Math.random()}`,
name: 'John Doe',
email: 'john@example.com',
status: 'active',
createdAt: new Date().toISOString(),
...overrides,
})
export const createMockUsers = (count: number): TestUser[] => {
return Array.from({ length: count }, (_, i) => createMockUser({
id: `user-${i + 1}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
}))
}
export const createMockApiFunctions = () => {
const users: TestUser[] = []
return {
listFn: async (limit: number, offset: number, filters?: TestUserFilters) => {
let filteredUsers = [...users]
if (filters?.status) {
filteredUsers = filteredUsers.filter(user => user.status === filters.status)
}
if (filters?.search) {
filteredUsers = filteredUsers.filter(user =>
user.name.toLowerCase().includes(filters.search!.toLowerCase()) ||
user.email.toLowerCase().includes(filters.search!.toLowerCase())
)
}
return filteredUsers.slice(offset, offset + limit)
},
retrieveFn: async (id: string) => {
const user = users.find(u => u.id === id)
if (!user) throw new Error('User not found')
return user
},
createFn: async (data: Partial<TestUser>) => {
const newUser: TestUser = {
id: `user-${users?.length + 1}`,
name: data.name || 'New User',
email: data.email || 'new@example.com',
status: data.status || 'active',
createdAt: new Date().toISOString(),
}
users.push(newUser)
return newUser
},
updateFn: async (data: Partial<TestUser>) => {
const userIndex = users.findIndex(u => u.id === data.id)
if (userIndex === -1) throw new Error('User not found')
users[userIndex] = { ...users[userIndex], ...data }
return users[userIndex]
},
deleteFn: async (id: string) => {
const userIndex = users.findIndex(u => u.id === id)
if (userIndex === -1) throw new Error('User not found')
users.splice(userIndex, 1)
return id
},
getUsersArray: () => [...users],
addUser: (user: TestUser) => users.push(user),
clearUsers: () => users.splice(0, users.length),
setUsers: (newUsers: TestUser[]) => {
users.splice(0, users.length, ...newUsers)
}
}
}
beforeEach(() => { })
afterEach(() => {
cleanup()
})
export const waitFor = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
import { UseInfiniteQueryResult, useQueryClient } from '@tanstack/react-query'
export type QueryItem = {
id: string | number
}
export type PageParam = number
export type ListPaginationResponse<T extends QueryItem> = T[]
export type QueryManagerOptions<T extends QueryItem, F> = {
name: string
queryClient: ReturnType<typeof useQueryClient>
listFn?: (limit: number, offset: number, filters: F) => Promise<ListPaginationResponse<T>>
listLimit?: number
useListEffect?: (listQuery: UseInfiniteQueryResult<{
pageParams: number[]
pages: ListPaginationResponse<T>[]
allItems: T[]
}, Error>) => void
retrieveFn?: (id: T['id']) => Promise<T>
createFn?: (data: Partial<T>) => Promise<T>
updateFn?: (data: Partial<T>) => Promise<T>
deleteFn?: (id: T['id']) => Promise<T['id']>
}
import { UseMutationOptions } from '@tanstack/react-query'
import { QueryItem } from './core'
import { RemovedItemMap } from './utility'
export type CreateMutationCtx = {
tempId?: QueryItem['id']
}
export type CreateMutationOptions<T extends QueryItem, F> = Omit<
UseMutationOptions<T, Error, Partial<T>, CreateMutationCtx>,
'mutationKey' | 'mutationFn'
> & {
optimistic?: boolean
listFilters?: F
appendTo?: RemovedItemMap | 'start' | 'end'
}
import { UseMutationOptions } from '@tanstack/react-query'
import { QueryItem } from './core'
import { RemovedItemMap } from './utility'
export type DeleteMutationCtx<T> = {
previousItem: T | undefined
removedAt: RemovedItemMap
}
export type DeleteMutationOptions<T extends QueryItem, F> = Omit<
UseMutationOptions<unknown, Error, T['id'], DeleteMutationCtx<T>>,
'mutationKey' | 'mutationFn'
> & {
optimistic?: boolean
}
export * from './core'
export * from './list'
export * from './utility'
export * from './retrieve'
export * from './create'
export * from './update'
export * from './delete'
import { QueryKey, UseInfiniteQueryOptions } from '@tanstack/react-query'
import { ListPaginationResponse, PageParam, QueryItem } from './core'
export type ListSelector<T extends QueryItem> = {
pageParams: PageParam[]
pages: ListPaginationResponse<T>[]
allItems: T[]
}
type InfiniteQueryOptions<T extends QueryItem> = UseInfiniteQueryOptions<
ListPaginationResponse<T>,
Error,
ListSelector<T>,
QueryKey,
PageParam
>
export type ListQueryOptions<T extends QueryItem, F> = Omit<
InfiniteQueryOptions<T>,
'queryKey' | 'queryFn' | 'initialPageParam' | 'getNextPageParam' | 'getPreviousPageParam' | 'select'
> & {
limit?: number
filters?: F
}
import { QueryKey, UndefinedInitialDataOptions } from '@tanstack/react-query'
import { QueryItem } from './core'
export type RetrieveQueryOptions<T extends QueryItem> = Omit<
UndefinedInitialDataOptions<T, Error, T, QueryKey>,
'queryKey' | 'queryFn'
>
import { UseMutationOptions } from '@tanstack/react-query'
import { QueryItem } from './core'
export type UpdateMutationCtx<T> = {
previousItem: T | undefined
optimisticItem: T | undefined
}
export type UpdateMutationOptions<T extends QueryItem, F> = Omit<
UseMutationOptions<T, Error, Partial<T>, UpdateMutationCtx<T>>,
'mutationKey' | 'mutationFn'
> & {
optimistic?: boolean
}
import { QueryItem } from './core';
import { QueryKey, useQueryClient } from '@tanstack/react-query'
export type QueryClient = ReturnType<typeof useQueryClient>
export type WithTempId<T extends QueryItem> = T & {
tempId?: QueryItem['id']
}
export type ItemPosition = {
pageIndex: number
itemIndex: number
}
export type RemovedItemMap = [QueryKey, ItemPosition][]
export type PaginationResponse<T extends QueryItem> = {
count: number
next: string
previous: string
results: T[]
}
export * from './misc'
function uuidV4() {
function randomHex() {
return Math.floor(Math.random() * 16).toString(16);
}
let uuid = ''
for (let i = 0; i < 8; i++) {
uuid += randomHex()
}
uuid += '-'
for (let i = 0; i < 4; i++) {
uuid += randomHex()
}
uuid += '-'
uuid += '4';
for (let i = 0; i < 3; i++) {
uuid += randomHex()
}
uuid += '-'
uuid += ['8', '9', 'a', 'b'][Math.floor(Math.random() * 4)];
for (let i = 0; i < 3; i++) {
uuid += randomHex()
}
uuid += '-'
for (let i = 0; i < 12; i++) {
uuid += randomHex()
}
return uuid
}
export const tempIdPrefix = 'temp-id'
export const generateTempId = () => {
return tempIdPrefix + '-' + uuidV4()
}
+6
-10
{
"name": "@codeleap/query",
"version": "5.8.3",
"version": "5.8.4",
"main": "src/index.ts",

@@ -12,6 +12,4 @@ "license": "UNLICENSED",

"devDependencies": {
"@codeleap/config": "5.8.3",
"@codeleap/types": "5.8.3",
"@codeleap/utils": "5.8.3",
"@codeleap/hooks": "5.8.3",
"@codeleap/config": "5.8.4",
"@codeleap/types": "5.8.4",
"ts-node-dev": "1.1.8"

@@ -23,11 +21,9 @@ },

"peerDependencies": {
"@codeleap/types": "5.8.3",
"@codeleap/utils": "5.8.3",
"@codeleap/hooks": "5.8.3",
"@codeleap/types": "5.8.4",
"typescript": "5.5.2",
"@tanstack/react-query": "5.60.6"
"@tanstack/react-query": "5.89.0"
},
"dependencies": {
"immer": "10.1.1"
"fast-deep-equal": "3.1.3"
}
}

@@ -1,9 +0,3 @@

export * from './QueryManager'
export * from './queryClient'
export * from './lib'
export * from './types'
import * as ReactQuery from '@tanstack/react-query'
export {
ReactQuery
}
export * from './factors'
import * as ReactQuery from '@tanstack/react-query'
import { waitFor } from '@codeleap/utils'
import { QueryManagerOptions, QueryManagerItem } from './types'
import { QueryManager } from './QueryManager'
export type QueryKeyBuilder<Args extends any[] = any[]> = (...args:Args) => ReactQuery.QueryKey
type PollingResult<T> = {
stop: boolean
data: T
}
type PollingCallback<T, R> = (query: ReactQuery.Query<T>, count: number, prev?: R) => Promise<PollingResult<R>>
type PollQueryOptions<T, R> = {
interval: number
callback: PollingCallback<T, R>
leading?: boolean
initialData?: R
}
interface EnhancedQuery<T> extends ReactQuery.Query<T> {
waitForRefresh(): Promise<ReactQuery.Query<T>>
listen(callback: (e: ReactQuery.QueryCacheNotifyEvent) => void): () => void
refresh(): Promise<T>
poll<R>(
options: PollQueryOptions<T, R>
): Promise<R>
getData(): T
ensureData(options?: Partial<ReactQuery.EnsureQueryDataOptions<T, Error, T, ReactQuery.QueryKey, never>>): Promise<T>
key: ReactQuery.QueryKey
}
type DynamicEnhancedQuery<T, BuilderArgs extends any[]> = {
[P in keyof EnhancedQuery<T>]: (...args: BuilderArgs) => EnhancedQuery<T>[P]
}
export class CodeleapQueryClient {
constructor(public client: ReactQuery.QueryClient) {
}
listenToQuery(key: ReactQuery.QueryKey, callback: (e: ReactQuery.QueryCacheNotifyEvent) => void) {
const cache = this.client.getQueryCache()
const query = cache.find({ exact: true, queryKey: key })
if (!query) {
return
}
const removeListener = cache.subscribe((e) => {
const matches = ReactQuery.matchQuery({ exact: true, queryKey: key }, e.query)
if (matches) {
callback(e)
}
})
return removeListener
}
async pollQuery<T, R>(
key: ReactQuery.QueryKey,
options: PollQueryOptions<T, R>,
) {
const { interval, callback, initialData, leading = false } = options
const cache = this.client.getQueryCache()
const initialQuery = cache.find({ exact: true, queryKey: key })
if (!initialQuery) {
return Promise.reject(new Error('Query not found'))
}
let count = 0
let result: PollingResult<R> = {
stop: false,
data: initialData,
}
while (!result?.stop) {
const shouldWait = count > 0 || leading
if (shouldWait) {
await waitFor(interval)
}
this.client.refetchQueries({
exact: true,
queryKey: key,
})
const newQuery = await this.waitForRefresh<T>(key)
const newResult = await callback(newQuery, count, result?.data)
count += 1
result = newResult
}
return result?.data
}
queryProxy<T>(key: ReactQuery.QueryKey) {
const getClient = () => this
return new Proxy<EnhancedQuery<T>>({} as EnhancedQuery<T>, {
get(target, p, receiver) {
const client = getClient()
// these don't need the actual query
switch (p) {
case 'key':
return key
case 'getData':
return () => {
return client.client.getQueryData<T>(key)
}
default:
break
}
const cache = client.client.getQueryCache()
const query = cache.find({ exact: true, queryKey: key })
if (!query) {
console.warn(`Attempt to access property ${String(p)} on undefined query with key`, key)
return undefined
}
switch (p) {
case 'waitForRefresh':
return () => {
return client.waitForRefresh<T>(key)
}
case 'listen':
return (callback: (e: ReactQuery.QueryCacheNotifyEvent) => void) => {
return client.listenToQuery(key, callback)
}
case 'ensureData':
return (options) => {
return client.client.ensureQueryData<T>({
queryKey: key,
...options
})
}
case 'refresh':
return async () => {
client.client.refetchQueries({
exact: true,
queryKey: key,
})
const newQuery = await client.waitForRefresh<T>(key)
return newQuery.state.data
}
case 'poll':
return (options: PollQueryOptions<T, any>) => {
return client.pollQuery(key, options)
}
default:
return Reflect.get(query, p, receiver)
}
},
})
}
waitForRefresh<T>(key: ReactQuery.QueryKey) {
const initialQuery = this.client.getQueryCache().find({ exact: true, queryKey: key })
if (!initialQuery) {
return Promise.reject(new Error('Query not found'))
}
const updateTime = initialQuery.state.dataUpdatedAt
const errorTime = initialQuery.state.errorUpdatedAt
return new Promise<ReactQuery.Query<T>>((resolve, reject) => {
const removeListener = this.listenToQuery(key, (e) => {
const query = e.query
const isNewer = query.state.dataUpdatedAt > updateTime || query.state.errorUpdatedAt > errorTime
const isIdle = query.state.fetchStatus === 'idle'
const isSuccess = query.state.status === 'success'
const isError = query.state.status === 'error'
const isResolved = isSuccess || isError
if (isNewer && isIdle && isResolved) {
if (isSuccess) {
resolve(query)
} else {
reject()
}
removeListener()
}
})
})
}
queryKey<Data>(k: ReactQuery.QueryKey, options?: ReactQuery.QueryOptions<Data>) {
if(options){
this.client.setQueryDefaults(k, options)
const cache = this.client.getQueryCache()
const q = new ReactQuery.Query({
cache,
queryKey: k,
queryHash: ReactQuery.hashKey(k),
...options,
})
cache.add(q)
}
return this.queryProxy<Data>(k)
}
dynamicQueryKey<Data, BuilderArgs extends any[] = any[]>(k: QueryKeyBuilder<BuilderArgs>) {
const getClient = () => this
return new Proxy<DynamicEnhancedQuery<Data, BuilderArgs>>({} as DynamicEnhancedQuery<Data, BuilderArgs>, {
get(target, p, receiver) {
return (...params:BuilderArgs) => {
const key = k(...params)
const proxy = getClient().queryProxy<Data>(key)
return Reflect.get(proxy, p, receiver)
}
},
})
}
queryManager<T extends QueryManagerItem, Args>(name:string, options: Partial<QueryManagerOptions<T, Args>>) {
// @ts-expect-error
const m = new QueryManager<T, Args>({
name,
queryClient: this.client,
...options,
})
return m
}
}
/* eslint-disable max-lines */
import {
useQuery,
useMutation,
useInfiniteQuery,
hashKey,
QueryKey,
} from '@tanstack/react-query'
import { Draft, produce } from 'immer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { deepMerge } from '@codeleap/utils'
import { usePromise } from '@codeleap/hooks'
import { TypeGuards } from '@codeleap/types'
import {
QueryManagerItem,
AppendToPagination,
CreateOptions,
QueryManagerOptions,
MutationCtx,
QueryStateValue,
InfinitePaginationData,
QueryManagerActions,
UpdateOptions,
UseManagerArgs,
GetItemOptions,
DeleteOptions,
SettableOptions,
QueryManagerMeta,
ListOptions,
RetrieveOptions,
OptionChangeListener,
QueryManagerActionTriggers,
UseActionOptions,
PaginationResponse,
UseListSelector,
PageParam,
} from './types'
export * from './types'
export class QueryManager<
T extends QueryManagerItem,
ExtraArgs extends Record<string, any> = any,
Meta extends QueryManagerMeta = QueryManagerMeta,
Actions extends QueryManagerActions<T, ExtraArgs, Meta> = QueryManagerActions<T, ExtraArgs, Meta>
> {
options: QueryManagerOptions<T, ExtraArgs, Meta, Actions>
meta: Meta
itemMap: Record<T['id'], T>
queryStates: Record<string, QueryStateValue<T>> = {}
optionListeners: OptionChangeListener<QueryManagerOptions<T, ExtraArgs, Meta, Actions>>[]
constructor(options: QueryManagerOptions<T, ExtraArgs, Meta, Actions>) {
this.options = options
this.itemMap = {} as Record<T['id'], T>
this.meta = options?.initialMeta
this.optionListeners = []
this.queryStates = {
[hashKey(this.filteredQueryKey())]: {
itemIndexes: {},
pagesById: {},
key: this.queryKeys.list,
} as unknown as QueryStateValue<T>,
}
}
extractKey(item:T) {
return this.options?.keyExtractor?.(item) ?? item.id
}
get keySuffixes() {
return {
list: 'list',
infiniteList: 'infinite-list',
create: 'create',
update: 'update',
delete: 'delete',
retrieve: 'retrieve',
}
}
get actions() {
const actions = this.options.actions ?? {} as Actions
const actionKeys = Object.keys(actions)
const actionFunctions = actionKeys.reduce((acc, key) => {
const action = actions[key]
// @ts-ignore
acc[key] = (...args: any[]) => {
return action(this, ...args)
}
return acc
}, {} as QueryManagerActionTriggers<Actions>)
return actionFunctions
}
generateId() {
return this.options.generateId?.() ?? this.knownItemCount()?.count + 1
}
useOptions(cb?: OptionChangeListener<QueryManagerOptions<T, ExtraArgs, Meta, Actions>>) {
const [options, setOptions] = useState(this.options)
const [meta, setMeta] = useState(this.meta)
useEffect(() => {
const idx = this.optionListeners.push((o, meta) => {
setOptions(o)
setMeta(meta)
cb?.(o, meta)
}) - 1
return () => {
this.optionListeners.splice(idx, 1)
}
})
return [options, meta] as const
}
async updateItems(items: T | T[], settingIds?: T['id'] | T['id'][]) {
const itemArr = Array.isArray(items) ? items : [items]
const setIdsTo = TypeGuards.isNil(settingIds) ? settingIds : TypeGuards.isArray(settingIds) ? settingIds : [settingIds]
const newItems = []
const ids = itemArr.map((i, idx) => {
const id = this.extractKey(i)
let newId = id
let withNewId = i
if (setIdsTo?.[idx]) {
newId = setIdsTo[idx]
withNewId = {
...(this.itemMap[id] ?? {}),
...i,
id: newId,
}
delete this.itemMap[id]
}
this.itemMap[withNewId.id] = withNewId
this.queryClient.setQueryData<T>(this.queryKeyFor(id), (old) => {
return withNewId
})
if (!TypeGuards.isNil(newId)) {
this.queryClient.setQueryData<T>(this.queryKeyFor(newId), (old) => {
return withNewId
})
}
newItems.push(withNewId)
return id
})
const promises = Object.values(this.queryStates).map(async ({ key, pagesById }) => {
this.queryClient.setQueryData<InfinitePaginationData<T>>(key, (old) => {
if (!old) return old
ids.forEach((id, idx) => {
if (!pagesById[id]) return
const [pageIdx, itemIdx] = pagesById[id]
const newItem = newItems[idx]
old.pages[pageIdx].results[itemIdx] = newItem
})
this.transformData(old, key)
return old
})
})
await Promise.all(promises)
}
async getItem(itemId: T['id'], options?: GetItemOptions<T>) {
const i = this.itemMap[itemId]
if ((!i && !!options?.fetchOnNotFoud) || options?.forceRefetch) {
const item = await this.options.retrieveItem(itemId)
this.updateItems(item)
return item
}
return i
}
addItem: AppendToPagination<T, ExtraArgs> = async (args) => {
const updateOnList = TypeGuards.isUndefined(args?.onListsWithFilters) ? undefined : hashKey(
this.filteredQueryKey(args.onListsWithFilters),
)
const promises = Object.entries(this.queryStates).map(async ([hashedKey, { key }]) => {
if (!TypeGuards.isUndefined(updateOnList)) {
if (updateOnList !== hashedKey) {
return
}
}
this.queryClient.setQueryData<InfinitePaginationData<T>>(key, (_old) => {
const newData = produce(_old, (old) => {
if (!old?.pages?.length || (old?.pages?.length > 1 && old?.pages?.every(page => page.results.length <= 0))) {
old = {
pageParams: [],
pages: [
{
results: [],
count: 0,
next: null,
previous: null,
},
],
}
}
const itemsToAppend = (TypeGuards.isArray(args.item) ? args.item : [args.item]) as Draft<T>[]
if (args.to === 'end') {
const idx = old.pages.length - 1
old.pages[idx].results.push(...itemsToAppend)
old.pages[idx].count += itemsToAppend.length
if (old.pageParams[idx]) {
// @ts-ignore
old.pageParams[idx].limit += itemsToAppend.length
} else {
old.pageParams[idx] = {
limit: this.options?.limit ?? itemsToAppend.length,
offset: 0,
}
}
} else if (args.to === 'start') {
old.pages[0].results.unshift(...itemsToAppend)
// @ts-ignore
if (old.pageParams[0]) {
// @ts-ignore
old.pageParams[0].offset -= itemsToAppend.length
// @ts-ignore
old.pageParams[0].limit += itemsToAppend.length
} else {
old.pageParams[0] = {
limit: itemsToAppend.length,
offset: -itemsToAppend.length,
}
}
} else if (!!args.to) {
const appendTo = TypeGuards.isArray(args.to) ? args.to : args.to[hashedKey]
const [pageIdx, itemIdx] = appendTo
old.pages[pageIdx].results.splice(itemIdx, 0, ...itemsToAppend)
if (old.pageParams[pageIdx]) {
// @ts-ignore
old.pageParams[pageIdx].offset -= itemsToAppend.length
// @ts-ignore
old.pageParams[pageIdx].limit += itemsToAppend.length
} else {
old.pageParams[pageIdx] = {
limit: itemsToAppend.length,
offset: -itemsToAppend.length,
}
}
}
})
this.transformData(newData, key)
return newData
})
})
await Promise.all(promises)
}
useAction<T extends keyof Actions>(
action: T,
options?: UseActionOptions<Actions[T]>,
) {
const mut = useMutation({
mutationKey: [this.name, action, options?.mutationKey],
mutationFn: (vars) => this.options.actions[action](this, vars),
...options,
})
return mut
}
async removeItem(itemId: T['id']) {
const removedPositions = {} as Record<string, [number, number]>
const promises = Object.entries(this.queryStates).map(async ([hashedKey, { key, pagesById }]) => {
this.queryClient.setQueryData<InfinitePaginationData<T>>(key, (old) => {
const [itemPage, itemIdx] = pagesById[itemId]
old.pages[itemPage].results.splice(itemIdx, 1)
// @ts-ignore
old.pageParams[itemPage].limit -= 1
removedPositions[hashedKey] = [itemPage, itemIdx]
return old
})
})
await Promise.all(promises)
return removedPositions
}
transformData(data: InfinitePaginationData<T>, key: QueryKey) {
const pagesById = {} as Record<T['id'], [number, number]>
const flatItems = [] as T[]
const itemIndexes = {} as Record<T['id'], number>
const hashedKey = hashKey(key)
let pageIdx = 0
for (const page of data?.pages ?? []) {
page.results.forEach((i, itemIdx) => {
const flatIdx = flatItems.length
const itemId = i.id
const include = true
if (include) {
flatItems.push(i)
}
pagesById[itemId] = [pageIdx, itemIdx]
this.itemMap[itemId] = i
itemIndexes[itemId] = flatIdx
})
pageIdx += 1
}
this.queryStates[hashedKey] = {
itemIndexes: { ...this.queryStates[hashedKey]?.itemIndexes, ...itemIndexes },
pagesById: { ...this.queryStates[hashedKey]?.pagesById, ...pagesById },
key,
}
return {
itemMap: this.itemMap,
pagesById,
itemIndexes,
itemList: flatItems,
}
}
get queryKeys() {
return {
list: [this.name, this.keySuffixes.list],
// infiniteList: [this.name, this.keySuffixes.infiniteList],
create: [this.name, this.keySuffixes.create],
update: [this.name, this.keySuffixes.update],
delete: [this.name, this.keySuffixes.delete],
retrieve: [this.name, this.keySuffixes.retrieve],
}
}
get name() {
return this.options.name
}
get standardLimit() {
return this.options.limit ?? 10
}
get queryClient() {
return this.options.queryClient
}
getList(forFilters?: ExtraArgs) {
return this.queryClient.getQueryData<InfinitePaginationData<T>>(this.filteredQueryKey(forFilters))
}
getListState(forFilters?: ExtraArgs) {
return this.queryClient.getQueryState<InfinitePaginationData<T>>(this.filteredQueryKey(forFilters))
}
getItemCount(forFilters?: ExtraArgs) {
return this.getList(forFilters)?.pages.reduce((acc, p) => Math.max(p.count, acc), 0)
}
knownItemCount() {
const res = Object.values(this.queryStates).reduce<{ count: number; lastDatetime?: number }>((acc, { key }) => {
const state = this.queryClient.getQueryState<InfinitePaginationData<T>>(key)
const lastDatetime = acc.lastDatetime ?? 0
if (state?.dataUpdatedAt > lastDatetime) {
acc.lastDatetime = state.dataUpdatedAt
const totals = state.data?.pages.reduce((acc, p) => Math.max(p.count, acc), 0)
acc.count = totals
}
return acc
}, {
count: 0,
lastDatetime: null,
})
return res
}
queryKeyFor(itemId: T['id']) {
return [this.name, this.keySuffixes.retrieve, itemId]
}
filteredQueryKey(filters = {} as ExtraArgs) {
return [...this.queryKeys.list, filters]
}
useList(options: ListOptions<T, ExtraArgs> = {}) {
const [isRefreshing, setRefreshing] = useState(false)
const {
filter = {} as ExtraArgs,
queryOptions,
limit = this.standardLimit,
} = options
const queryKey = this.filteredQueryKey(filter)
const hashedKey = hashKey(queryKey)
const useListEffect = this.options?.useListEffect ?? (() => null)
const select:ListOptions<T, ExtraArgs>['queryOptions']['select'] = useCallback((data) => {
const { itemList } = this.transformData(data, queryKey)
return {
pageParams: data.pageParams,
pages: data?.pages ?? [],
flatItems: itemList,
}
}, [])
const query = useInfiniteQuery<PaginationResponse<T>, Error, UseListSelector<T>, QueryKey, PageParam>({
queryKey,
initialPageParam: {
limit,
offset: 0,
},
queryFn: async (query) => {
return this.options.listItems(limit, query.pageParam?.offset ?? 0, filter)
},
refetchOnMount: (query) => {
if (TypeGuards.isBoolean(queryOptions?.refetchOnMount) || TypeGuards.isString(queryOptions?.refetchOnMount)) {
return queryOptions?.refetchOnMount
}
return query.state.dataUpdateCount === 0 || query.isStaleByTime()
},
getNextPageParam: (lastPage, pages) => {
const currentTotal = pages.reduce((acc, p) => p.results.length + acc, 0)
if (currentTotal >= (lastPage?.count || Infinity)) {
return undefined
}
return {
limit: limit,
offset: currentTotal,
}
},
getPreviousPageParam: (lastPage, pages) => {
const currentTotal = pages.reduce((acc, p) => p.results.length + acc, 0)
if (currentTotal >= (lastPage?.count || Infinity)) {
return undefined
}
return {
limit: limit,
offset: currentTotal,
}
},
select,
...queryOptions,
})
const refresh = async () => {
setRefreshing(true)
await this.refresh(filter)
setRefreshing(false)
}
const listEffect = useListEffect({
query,
refreshQuery: (silent = true) => silent ? this.refresh(filter) : refresh(),
cancelQuery: () => this.queryClient.cancelQueries({ queryKey }),
})
const itemCount = useMemo(() => {
return query.data?.pages.reduce((acc, p) => Math.max(p.count, acc), 0)
}, [query.data])
// @ts-ignore
const items = query.data?.flatItems ?? []
return {
items: items as T[],
query,
getNextPage: query.fetchNextPage,
getPreviousPage: query.fetchPreviousPage,
refresh,
isRefreshing,
itemMap: this.itemMap,
pagesById: this.queryStates[hashedKey]?.pagesById ?? {},
itemIndexes: this.queryStates[hashedKey]?.itemIndexes ?? {},
itemCount,
}
}
useRetrieve(options?: RetrieveOptions<T>) {
const [isRefreshing, setRefreshing] = useState(false)
const itemId = options?.id
const select:RetrieveOptions<T>['queryOptions']['select'] = useCallback((data) => {
this.updateItems(data)
return data
}, [])
const query = useQuery({
queryKey: this.queryKeyFor(itemId),
initialData: () => {
return this.itemMap[itemId]
},
queryFn: () => {
return this.options.retrieveItem(itemId)
},
select,
...options?.queryOptions,
})
const refresh = async () => {
setRefreshing(true)
await this.refreshItem(itemId)
setRefreshing(false)
}
return {
data: query.data,
query,
refresh,
isRefreshing,
}
}
useItem(options?: RetrieveOptions<T>) {
return this.useRetrieve(options)
}
useCreate(options?: CreateOptions<T>) {
const [managerOptions, meta] = this.useOptions()
const tmpOptions = useRef<CreateOptions<T>>(options ?? managerOptions.creation ?? {
appendTo: 'start',
optimistic: false,
})
const getOptimisticItem = usePromise<T>({
timeout: 1200,
})
const query = useMutation({
...options?.mutationOptions,
mutationFn: (data: Partial<T>) => {
return this.options.createItem(data)
},
mutationKey: this.queryKeys.create,
onMutate: async (data) => {
options?.mutationOptions?.onMutate?.(data)
if (!!tmpOptions?.current?.optimistic) {
await this.queryClient.cancelQueries({ queryKey: this.queryKeys.list })
const addedItem = {
id: this.generateId(),
...data,
} as T
getOptimisticItem.resolve(addedItem)
const addedId = this.extractKey(addedItem)
this.addItem({
item: addedItem,
to: tmpOptions?.current?.appendTo || managerOptions.creation?.appendTo || 'start',
onListsWithFilters: tmpOptions.current?.onListsWithFilters,
})
return {
// previousData,
addedId,
}
}
},
onError: (error, data, ctx: MutationCtx<T>) => {
const isOptimistic = tmpOptions.current?.optimistic
if (isOptimistic) {
this.removeItem(ctx.addedId)
}
},
onSuccess: (data) => {
if (!tmpOptions.current?.optimistic) {
this.addItem({
item: data,
to: tmpOptions?.current?.appendTo || managerOptions.creation?.appendTo || 'start',
onListsWithFilters: tmpOptions?.current?.onListsWithFilters,
})
} else {
this.updateItems(data)
}
},
})
const createItem = async (data: Partial<T>, options?: CreateOptions<T>) => {
const prevOptions = { ...(tmpOptions.current ?? {}) }
if (!!options) {
tmpOptions.current = options
}
let res: T = null
if (tmpOptions.current?.optimistic) {
query.mutateAsync(data)
res = await getOptimisticItem._await()
} else {
res = await query.mutateAsync(data)
}
if (!!options) {
tmpOptions.current = prevOptions
}
return res
}
return {
item: query.data,
create: createItem,
query,
}
}
useUpdate(options?: UpdateOptions<T>) {
const [managerOptions] = this.useOptions()
const tmpOptions = useRef<UpdateOptions<T>>(options ?? managerOptions.update ?? {
optimistic: false,
})
const getOptimisticItem = usePromise<T>({
timeout: 1200,
})
const query = useMutation({
...options?.mutationOptions,
mutationKey: this.queryKeys.update,
onMutate: async (data) => {
options?.mutationOptions?.onMutate?.(data)
if (tmpOptions.current?.optimistic) {
const prevItem = await this.getItem(data.id, {
fetchOnNotFoud: false,
})
if (!prevItem) return
const optimisticItem:T = {
...prevItem,
...data,
}
getOptimisticItem.resolve(optimisticItem)
this.updateItems(optimisticItem)
return {
previousItem: prevItem,
optimisticItem,
} as MutationCtx<T>
}
},
onError: (error, data, ctx: MutationCtx<T>) => {
if (tmpOptions.current?.optimistic && !!ctx?.previousItem?.id) {
this.updateItems(
ctx.previousItem,
)
}
},
mutationFn: (data: Partial<T>) => {
return this.options.updateItem(data)
},
onSuccess: (data) => {
this.updateItems(data)
},
})
const update = async (data: Partial<T>, options?: UpdateOptions<T>) => {
const prevOptions = tmpOptions.current
if (!!options) {
tmpOptions.current = options
}
let res: T = null
if (tmpOptions.current?.optimistic) {
query.mutateAsync(data)
res = await getOptimisticItem._await()
} else {
res = await query.mutateAsync(data)
}
if (!!options) {
tmpOptions.current = prevOptions
}
return res
}
return {
update,
query,
item: query.data,
}
}
useDelete(options?: DeleteOptions<T>) {
const [managerOptions] = this.useOptions()
const tmpOptions = useRef<DeleteOptions<T>>(options ?? managerOptions?.deletion ?? {
optimistic: false,
})
const getOptimisticItem = usePromise<T>({
timeout: 1200,
})
const query = useMutation({
...options?.mutationOptions,
mutationKey: this.queryKeys.delete,
onMutate: async (data) => {
options?.mutationOptions?.onMutate?.(data)
if (tmpOptions.current?.optimistic) {
const prevItem = await this.getItem(data.id, {
fetchOnNotFoud: false,
})
getOptimisticItem.resolve(prevItem)
const removedAt = await this.removeItem(data.id)
if (!prevItem) return
return {
previousItem: prevItem,
prevItemPages: removedAt,
} as MutationCtx<T>
}
},
mutationFn: (data: T) => {
return this.options.deleteItem(data)
},
onError: (error, data, ctx: MutationCtx<T>) => {
if (!!ctx?.previousItem?.id && tmpOptions.current?.optimistic) {
this.addItem({
item: ctx.previousItem,
to: ctx.prevItemPages,
})
}
},
onSuccess: (data) => {
if (!tmpOptions.current?.optimistic) {
this.removeItem(data.id)
}
},
})
const _delete = async (data: T, options?: UpdateOptions<T>) => {
const prevOptions = tmpOptions.current
if (!!options) {
tmpOptions.current = options
}
let prevItem = null
if (tmpOptions.current?.optimistic) {
query.mutateAsync(data)
prevItem = await getOptimisticItem._await()
} else {
prevItem = await query.mutateAsync(data)
}
if (!!options) {
tmpOptions.current = prevOptions
}
return prevItem
}
return {
delete: _delete,
query,
}
}
async refreshItem(itemId: T['id']) {
const newItem = await this.getItem(itemId, {
fetchOnNotFoud: true,
forceRefetch: true,
})
this.queryClient.setQueryData(this.queryKeyFor(itemId), old => {
return newItem
})
this.updateItems(newItem)
return newItem
}
async refresh(filters?: ExtraArgs) {
if (!!filters) {
const key = this.filteredQueryKey(filters)
this.queryClient.refetchQueries({
queryKey: key,
})
} else {
this.queryClient.refetchQueries({
queryKey: this.queryKeys.list,
})
}
}
setItem(item: T) {
return this.updateItems(item)
}
use(options?: UseManagerArgs<T, ExtraArgs>) {
const list = this.useList({
filter: options?.filter,
queryOptions: options?.listOptions?.queryOptions,
limit: options?.limit,
})
const create = this.useCreate(options?.creation)
const update = this.useUpdate(options?.update)
const del = this.useDelete(options?.deletion)
const queries = {
create,
update,
del,
}
return {
items: list.items,
list,
itemMap: list.itemMap,
create: create.create,
update: update.update,
delete: del.delete,
getNextPage: list.getNextPage,
getPreviousPage: list.getPreviousPage,
refreshItem: this.refreshItem.bind(this),
setItem: this.setItem.bind(this),
refresh: list.refresh,
isRefreshing: list.isRefreshing,
actions: this.actions,
updatedAt: list.query.dataUpdatedAt,
queries,
}
}
setOptions(to: SettableOptions<QueryManagerOptions<T, ExtraArgs, Meta, Actions>>) {
const {
creation = {},
update = {},
deletion = {},
limit,
} = this.options
const currentOptions = {
creation,
update,
deletion,
limit,
}
const o = deepMerge(currentOptions, to)
this.options = {
...this.options,
...o,
}
this.meta = deepMerge(this.meta, to.meta)
this.optionListeners.forEach((l) => l(this.options, this.meta))
}
}
import { DefinedInitialDataInfiniteOptions, InfiniteData, QueryKey, UseInfiniteQueryResult, UseMutationOptions, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { QueryManager } from './QueryManager'
export type PageParam = {limit: number; offset: number}
export type PaginationResponse<T> = {
count: number
next: string | null
previous: string | null
results: T[]
}
type OmitMutationKeys<O> = Omit<O, 'mutationFn'|'mutationKey'>
export type QueryManagerMeta = Record<string, any>
export type CreateOptions<T extends QueryManagerItem> = {
appendTo?: 'start' | 'end' | [number, number] | Record<string, [number, number]>
optimistic?: boolean
mutationOptions?: Partial<OmitMutationKeys<UseMutationOptions<T, unknown, Partial<T>, MutationCtx<T>>>>
onListsWithFilters?: any
}
export type UpdateOptions<T extends QueryManagerItem> = {
optimistic?: boolean
mutationOptions?: Partial<OmitMutationKeys<UseMutationOptions<T, unknown, Partial<T>, MutationCtx<T>>>>
}
export type DeleteOptions<T extends QueryManagerItem> = {
optimistic?: boolean
mutationOptions?: Partial<OmitMutationKeys<UseMutationOptions<T, unknown, T, MutationCtx<T>>>>
}
export type RetrieveOptions<T extends QueryManagerItem> = {
queryOptions?: Partial<UseQueryOptions<T, unknown, T>>
id?: T['id']
}
export type ListOptions<T extends QueryManagerItem, ExtraArgs = any> = {
queryOptions?: Partial<
DefinedInitialDataInfiniteOptions<PaginationResponse<T>, Error, UseListSelector<T>, QueryKey, PageParam>
>
filter?: ExtraArgs
limit?: number
}
export type QueryManagerAction<
T extends QueryManagerItem,
ExtraArgs = any,
Meta extends QueryManagerMeta = QueryManagerMeta,
Args extends any[] = any[]
> = (
manager: QueryManager<T, ExtraArgs, Meta>, ...args: Args
) => any
export type QueryManagerActions<
T extends QueryManagerItem,
ExtraArgs = any,
Meta extends QueryManagerMeta = QueryManagerMeta
> = Record<
string, QueryManagerAction<T, ExtraArgs, Meta>
>
export type UseListEffect<T extends QueryManagerItem = any> = (
listQuery: {
query: UseInfiniteQueryResult<UseListSelector<T>, Error>
refreshQuery: (silent?: boolean) => void
cancelQuery: () => void
}
) => void
export type QueryManagerOptions<
T extends QueryManagerItem,
ExtraArgs = any,
Meta extends QueryManagerMeta = QueryManagerMeta,
Actions extends QueryManagerActions<T, ExtraArgs, Meta> = QueryManagerActions<T, ExtraArgs, Meta>
> = {
name: string
itemType: T
queryClient: ReturnType<typeof useQueryClient>
listItems?: (limit: number, offset: number, args?: ExtraArgs) => Promise<PaginationResponse<T>>
createItem?: (data: Partial<T>, args?: ExtraArgs) => Promise<T>
updateItem?: (data: Partial<T>, args?: ExtraArgs) => Promise<T>
deleteItem?: (data: T, args?: ExtraArgs) => Promise<T>
retrieveItem?: (id: T['id']) => Promise<T>
useListEffect?: UseListEffect<T>
limit?: number
creation?: CreateOptions<T>
update?: UpdateOptions<T>
deletion?: DeleteOptions<T>
generateId?: () => T['id']
actions?: Actions
keyExtractor?: (item: T) => string
initialMeta?: Meta
}
export type QueryManagerActionTrigger<
A extends QueryManagerAction<any, any, any>,
Args extends any[] = A extends QueryManagerAction<any, any, any, infer _Args> ? _Args : any[]
> = (...args: Args) => any
export type QueryManagerActionTriggers<
Actions extends QueryManagerActions<any, any, any>
> = {
[K in keyof Actions]: QueryManagerActionTrigger<Actions[K]>
}
export type InfinitePaginationData<T> = InfiniteData<PaginationResponse<T>, PageParam>
export type UseManagerArgs<T extends QueryManagerItem, ExtraArgs = any> = {
filter?: ExtraArgs
limit?: number
offset?: number
creation?: CreateOptions<T>
update?: UpdateOptions<T>
deletion?: DeleteOptions<T>
listOptions?: Pick<ListOptions<T, ExtraArgs>, 'queryOptions'>
}
export type QueryManagerItem = {
id: string | number
}
export type AppendToPaginationParams<TItem extends QueryManagerItem, Filters = any> = {
item: TItem|TItem[]
to?: CreateOptions<TItem>['appendTo']
refreshKey?: QueryKey
onListsWithFilters?: Filters
}
export type AppendToPaginationReturn<TItem = any> = InfiniteData<TItem>
export type AppendToPagination<TItem extends QueryManagerItem, ExtraArgs = any> = (params: AppendToPaginationParams<TItem, ExtraArgs>) => Promise<void>
export type MutationCtx<T extends QueryManagerItem> = null | {
previousData?: InfinitePaginationData<T>
addedId?: T['id']
previousItem?: T
optimisticItem?: T
prevItemPages?:Record<string, [number, number]>
}
export const isInfiniteQueryData = <T>(data: any): data is InfinitePaginationData<T> => {
return !!data?.pages && !!data?.pageParams
}
export type QueryStateValue<T extends QueryManagerItem> = {
pagesById: Record<T['id'], [number, number]>
itemIndexes: Record<T['id'], number>
key: QueryKey
}
export type QueryStateSubscriber<T extends QueryManagerItem> = (data: QueryStateValue<T>) => void
export type FilterKeyOrder = string[]
export type GetItemOptions<T extends QueryManagerItem> = {
forceRefetch?: boolean
fetchOnNotFoud?: boolean
}
export type SettableOptions<O extends QueryManagerOptions<any, any, any, any>> = Partial<
Pick<
O,
'limit' |
'creation' |
'update' |
'deletion'
> & {
meta: O['initialMeta']
}
>
export type OptionChangeListener<O extends QueryManagerOptions<any, any, any, any>> = (
options: O,
meta: O['initialMeta'],
) => any
export type UseActionOptions<T extends QueryManagerAction<any, any, any>> = UseMutationOptions<
Awaited<ReturnType<T>>,
unknown,
Parameters<T>[1]
>
export type UseListSelector<T> = {
pageParams: PageParam[]
pages: PaginationResponse<T>[]
flatItems: T[]
}

Sorry, the diff of this file is not supported yet