UNPKG

@tanstack/db-collections

Version:

A collection for (aspirationally) every way of loading your data

451 lines (414 loc) 13.4 kB
import { QueryObserver } from "@tanstack/query-core" import type { QueryClient, QueryFunctionContext, QueryKey, QueryObserverOptions, } from "@tanstack/query-core" import type { CollectionConfig, DeleteMutationFn, DeleteMutationFnParams, InsertMutationFn, InsertMutationFnParams, SyncConfig, UpdateMutationFn, UpdateMutationFnParams, UtilsRecord, } from "@tanstack/db" export interface QueryCollectionConfig< TItem extends object, TError = unknown, TQueryKey extends QueryKey = QueryKey, > { queryKey: TQueryKey queryFn: (context: QueryFunctionContext<TQueryKey>) => Promise<Array<TItem>> queryClient: QueryClient // Query-specific options enabled?: boolean refetchInterval?: QueryObserverOptions< Array<TItem>, TError, Array<TItem>, Array<TItem>, TQueryKey >[`refetchInterval`] retry?: QueryObserverOptions< Array<TItem>, TError, Array<TItem>, Array<TItem>, TQueryKey >[`retry`] retryDelay?: QueryObserverOptions< Array<TItem>, TError, Array<TItem>, Array<TItem>, TQueryKey >[`retryDelay`] staleTime?: QueryObserverOptions< Array<TItem>, TError, Array<TItem>, Array<TItem>, TQueryKey >[`staleTime`] // Standard Collection configuration properties id?: string getKey: CollectionConfig<TItem>[`getKey`] schema?: CollectionConfig<TItem>[`schema`] sync?: CollectionConfig<TItem>[`sync`] startSync?: CollectionConfig<TItem>[`startSync`] // Direct persistence handlers /** * Optional asynchronous handler function called before an insert operation * @param params Object containing transaction and collection information * @returns Promise resolving to void or { refetch?: boolean } to control refetching * @example * // Basic query collection insert handler * onInsert: async ({ transaction }) => { * const newItem = transaction.mutations[0].modified * await api.createTodo(newItem) * // Automatically refetches query after insert * } * * @example * // Insert handler with refetch control * onInsert: async ({ transaction }) => { * const newItem = transaction.mutations[0].modified * await api.createTodo(newItem) * return { refetch: false } // Skip automatic refetch * } * * @example * // Insert handler with multiple items * onInsert: async ({ transaction }) => { * const items = transaction.mutations.map(m => m.modified) * await api.createTodos(items) * // Will refetch query to get updated data * } * * @example * // Insert handler with error handling * onInsert: async ({ transaction }) => { * try { * const newItem = transaction.mutations[0].modified * await api.createTodo(newItem) * } catch (error) { * console.error('Insert failed:', error) * throw error // Transaction will rollback optimistic changes * } * } */ onInsert?: InsertMutationFn<TItem> /** * Optional asynchronous handler function called before an update operation * @param params Object containing transaction and collection information * @returns Promise resolving to void or { refetch?: boolean } to control refetching * @example * // Basic query collection update handler * onUpdate: async ({ transaction }) => { * const mutation = transaction.mutations[0] * await api.updateTodo(mutation.original.id, mutation.changes) * // Automatically refetches query after update * } * * @example * // Update handler with multiple items * onUpdate: async ({ transaction }) => { * const updates = transaction.mutations.map(m => ({ * id: m.key, * changes: m.changes * })) * await api.updateTodos(updates) * // Will refetch query to get updated data * } * * @example * // Update handler with manual refetch * onUpdate: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * await api.updateTodo(mutation.original.id, mutation.changes) * * // Manually trigger refetch * await collection.utils.refetch() * * return { refetch: false } // Skip automatic refetch * } * * @example * // Update handler with related collection refetch * onUpdate: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * await api.updateTodo(mutation.original.id, mutation.changes) * * // Refetch related collections when this item changes * await Promise.all([ * collection.utils.refetch(), // Refetch this collection * usersCollection.utils.refetch(), // Refetch users * tagsCollection.utils.refetch() // Refetch tags * ]) * * return { refetch: false } // Skip automatic refetch since we handled it manually * } */ onUpdate?: UpdateMutationFn<TItem> /** * Optional asynchronous handler function called before a delete operation * @param params Object containing transaction and collection information * @returns Promise resolving to void or { refetch?: boolean } to control refetching * @example * // Basic query collection delete handler * onDelete: async ({ transaction }) => { * const mutation = transaction.mutations[0] * await api.deleteTodo(mutation.original.id) * // Automatically refetches query after delete * } * * @example * // Delete handler with refetch control * onDelete: async ({ transaction }) => { * const mutation = transaction.mutations[0] * await api.deleteTodo(mutation.original.id) * return { refetch: false } // Skip automatic refetch * } * * @example * // Delete handler with multiple items * onDelete: async ({ transaction }) => { * const keysToDelete = transaction.mutations.map(m => m.key) * await api.deleteTodos(keysToDelete) * // Will refetch query to get updated data * } * * @example * // Delete handler with related collection refetch * onDelete: async ({ transaction, collection }) => { * const mutation = transaction.mutations[0] * await api.deleteTodo(mutation.original.id) * * // Refetch related collections when this item is deleted * await Promise.all([ * collection.utils.refetch(), // Refetch this collection * usersCollection.utils.refetch(), // Refetch users * projectsCollection.utils.refetch() // Refetch projects * ]) * * return { refetch: false } // Skip automatic refetch since we handled it manually * } */ onDelete?: DeleteMutationFn<TItem> // TODO type returning { refetch: boolean } } /** * Type for the refetch utility function */ export type RefetchFn = () => Promise<void> /** * Query collection utilities type */ export interface QueryCollectionUtils extends UtilsRecord { refetch: RefetchFn } /** * Creates query collection options for use with a standard Collection * * @param config - Configuration options for the Query collection * @returns Collection options with utilities */ export function queryCollectionOptions< TItem extends object, TError = unknown, TQueryKey extends QueryKey = QueryKey, >( config: QueryCollectionConfig<TItem, TError, TQueryKey> ): CollectionConfig<TItem> & { utils: QueryCollectionUtils } { const { queryKey, queryFn, queryClient, enabled, refetchInterval, retry, retryDelay, staleTime, getKey, onInsert, onUpdate, onDelete, ...baseCollectionConfig } = config // Validate required parameters // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!queryKey) { throw new Error(`[QueryCollection] queryKey must be provided.`) } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!queryFn) { throw new Error(`[QueryCollection] queryFn must be provided.`) } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!queryClient) { throw new Error(`[QueryCollection] queryClient must be provided.`) } if (!getKey) { throw new Error(`[QueryCollection] getKey must be provided.`) } const internalSync: SyncConfig<TItem>[`sync`] = (params) => { const { begin, write, commit, collection } = params const observerOptions: QueryObserverOptions< Array<TItem>, TError, Array<TItem>, Array<TItem>, TQueryKey > = { queryKey: queryKey, queryFn: queryFn, enabled: enabled, refetchInterval: refetchInterval, retry: retry, retryDelay: retryDelay, staleTime: staleTime, structuralSharing: true, notifyOnChangeProps: `all`, } const localObserver = new QueryObserver< Array<TItem>, TError, Array<TItem>, Array<TItem>, TQueryKey >(queryClient, observerOptions) const actualUnsubscribeFn = localObserver.subscribe((result) => { if (result.isSuccess) { const newItemsArray = result.data if ( !Array.isArray(newItemsArray) || newItemsArray.some((item) => typeof item !== `object`) ) { console.error( `[QueryCollection] queryFn did not return an array of objects. Skipping update.`, newItemsArray ) return } const currentSyncedItems = new Map(collection.syncedData) const newItemsMap = new Map<string | number, TItem>() newItemsArray.forEach((item) => { const key = getKey(item) newItemsMap.set(key, item) }) begin() // Helper function for shallow equality check of objects const shallowEqual = ( obj1: Record<string, any>, obj2: Record<string, any> ): boolean => { // Get all keys from both objects const keys1 = Object.keys(obj1) const keys2 = Object.keys(obj2) // If number of keys is different, objects are not equal if (keys1.length !== keys2.length) return false // Check if all keys in obj1 have the same values in obj2 return keys1.every((key) => { // Skip comparing functions and complex objects deeply if (typeof obj1[key] === `function`) return true if (typeof obj1[key] === `object` && obj1[key] !== null) { // For nested objects, just compare references // A more robust solution might do recursive shallow comparison // or let users provide a custom equality function return obj1[key] === obj2[key] } return obj1[key] === obj2[key] }) } currentSyncedItems.forEach((oldItem, key) => { const newItem = newItemsMap.get(key) if (!newItem) { write({ type: `delete`, value: oldItem }) } else if ( !shallowEqual( oldItem as Record<string, any>, newItem as Record<string, any> ) ) { // Only update if there are actual differences in the properties write({ type: `update`, value: newItem }) } }) newItemsMap.forEach((newItem, key) => { if (!currentSyncedItems.has(key)) { write({ type: `insert`, value: newItem }) } }) commit() } else if (result.isError) { console.error( `[QueryCollection] Error observing query ${String(queryKey)}:`, result.error ) } }) return async () => { actualUnsubscribeFn() await queryClient.cancelQueries({ queryKey }) queryClient.removeQueries({ queryKey }) } } /** * Refetch the query data * @returns Promise that resolves when the refetch is complete */ const refetch: RefetchFn = async (): Promise<void> => { return queryClient.refetchQueries({ queryKey: queryKey, }) } // Create wrapper handlers for direct persistence operations that handle refetching const wrappedOnInsert = onInsert ? async (params: InsertMutationFnParams<TItem>) => { const handlerResult = (await onInsert(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false if (shouldRefetch) { await refetch() } return handlerResult } : undefined const wrappedOnUpdate = onUpdate ? async (params: UpdateMutationFnParams<TItem>) => { const handlerResult = (await onUpdate(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false if (shouldRefetch) { await refetch() } return handlerResult } : undefined const wrappedOnDelete = onDelete ? async (params: DeleteMutationFnParams<TItem>) => { const handlerResult = (await onDelete(params)) ?? {} const shouldRefetch = (handlerResult as { refetch?: boolean }).refetch !== false if (shouldRefetch) { await refetch() } return handlerResult } : undefined return { ...baseCollectionConfig, getKey, sync: { sync: internalSync }, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, utils: { refetch, }, } }