@tanstack/query-db-collection
Version:
TanStack Query collection for TanStack DB
601 lines (546 loc) • 17.8 kB
text/typescript
import { QueryObserver } from "@tanstack/query-core"
import {
GetKeyRequiredError,
QueryClientRequiredError,
QueryFnRequiredError,
QueryKeyRequiredError,
} from "./errors"
import { createWriteUtils } from "./manual-sync"
import type {
QueryClient,
QueryFunctionContext,
QueryKey,
QueryObserverOptions,
} from "@tanstack/query-core"
import type {
BaseCollectionConfig,
ChangeMessage,
CollectionConfig,
DeleteMutationFnParams,
InsertMutationFnParams,
SyncConfig,
UpdateMutationFnParams,
UtilsRecord,
} from "@tanstack/db"
import type { StandardSchemaV1 } from "@standard-schema/spec"
// Re-export for external use
export type { SyncOperation } from "./manual-sync"
// Schema output type inference helper (matches electric.ts pattern)
type InferSchemaOutput<T> = T extends StandardSchemaV1
? StandardSchemaV1.InferOutput<T> extends object
? StandardSchemaV1.InferOutput<T>
: Record<string, unknown>
: Record<string, unknown>
// Schema input type inference helper (matches electric.ts pattern)
type InferSchemaInput<T> = T extends StandardSchemaV1
? StandardSchemaV1.InferInput<T> extends object
? StandardSchemaV1.InferInput<T>
: Record<string, unknown>
: Record<string, unknown>
/**
* Configuration options for creating a Query Collection
* @template T - The explicit type of items stored in the collection
* @template TQueryFn - The queryFn type
* @template TError - The type of errors that can occur during queries
* @template TQueryKey - The type of the query key
* @template TKey - The type of the item keys
* @template TSchema - The schema type for validation
*/
export interface QueryCollectionConfig<
T extends object = object,
TQueryFn extends (
context: QueryFunctionContext<any>
) => Promise<Array<any>> = (
context: QueryFunctionContext<any>
) => Promise<Array<any>>,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
TSchema extends StandardSchemaV1 = never,
> extends BaseCollectionConfig<T, TKey, TSchema> {
/** The query key used by TanStack Query to identify this query */
queryKey: TQueryKey
/** Function that fetches data from the server. Must return the complete collection state */
queryFn: TQueryFn extends (
context: QueryFunctionContext<TQueryKey>
) => Promise<Array<any>>
? TQueryFn
: (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>>
/** The TanStack Query client instance */
queryClient: QueryClient
// Query-specific options
/** Whether the query should automatically run (default: true) */
enabled?: boolean
refetchInterval?: QueryObserverOptions<
Array<T>,
TError,
Array<T>,
Array<T>,
TQueryKey
>[`refetchInterval`]
retry?: QueryObserverOptions<
Array<T>,
TError,
Array<T>,
Array<T>,
TQueryKey
>[`retry`]
retryDelay?: QueryObserverOptions<
Array<T>,
TError,
Array<T>,
Array<T>,
TQueryKey
>[`retryDelay`]
staleTime?: QueryObserverOptions<
Array<T>,
TError,
Array<T>,
Array<T>,
TQueryKey
>[`staleTime`]
/**
* Metadata to pass to the query.
* Available in queryFn via context.meta
*
* @example
* // Using meta for error context
* queryFn: async (context) => {
* try {
* return await api.getTodos(userId)
* } catch (error) {
* // Use meta for better error messages
* throw new Error(
* context.meta?.errorMessage || 'Failed to load todos'
* )
* }
* },
* meta: {
* errorMessage: `Failed to load todos for user ${userId}`
* }
*/
meta?: Record<string, unknown>
}
/**
* Type for the refetch utility function
*/
export type RefetchFn = (opts?: { throwOnError?: boolean }) => Promise<void>
/**
* Utility methods available on Query Collections for direct writes and manual operations.
* Direct writes bypass the normal query/mutation flow and write directly to the synced data store.
* @template TItem - The type of items stored in the collection
* @template TKey - The type of the item keys
* @template TInsertInput - The type accepted for insert operations
* @template TError - The type of errors that can occur during queries
*/
export interface QueryCollectionUtils<
TItem extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TInsertInput extends object = TItem,
TError = unknown,
> extends UtilsRecord {
/** Manually trigger a refetch of the query */
refetch: RefetchFn
/** Insert one or more items directly into the synced data store without triggering a query refetch or optimistic update */
writeInsert: (data: TInsertInput | Array<TInsertInput>) => void
/** Update one or more items directly in the synced data store without triggering a query refetch or optimistic update */
writeUpdate: (updates: Partial<TItem> | Array<Partial<TItem>>) => void
/** Delete one or more items directly from the synced data store without triggering a query refetch or optimistic update */
writeDelete: (keys: TKey | Array<TKey>) => void
/** Insert or update one or more items directly in the synced data store without triggering a query refetch or optimistic update */
writeUpsert: (data: Partial<TItem> | Array<Partial<TItem>>) => void
/** Execute multiple write operations as a single atomic batch to the synced data store */
writeBatch: (callback: () => void) => void
/** Get the last error encountered by the query (if any); reset on success */
lastError: () => TError | undefined
/** Check if the collection is in an error state */
isError: () => boolean
/**
* Get the number of consecutive sync failures.
* Incremented only when query fails completely (not per retry attempt); reset on success.
*/
errorCount: () => number
/**
* Clear the error state and trigger a refetch of the query
* @returns Promise that resolves when the refetch completes successfully
* @throws Error if the refetch fails
*/
clearError: () => Promise<void>
}
/**
* Creates query collection options for use with a standard Collection.
* This integrates TanStack Query with TanStack DB for automatic synchronization.
*
* Supports automatic type inference following the priority order:
* 1. Schema inference (highest priority)
* 2. QueryFn return type inference (second priority)
*
* @template T - Type of the schema if a schema is provided otherwise it is the type of the values returned by the queryFn
* @template TError - The type of errors that can occur during queries
* @template TQueryKey - The type of the query key
* @template TKey - The type of the item keys
* @param config - Configuration options for the Query collection
* @returns Collection options with utilities for direct writes and manual operations
*
* @example
* // Type inferred from queryFn return type (NEW!)
* const todosCollection = createCollection(
* queryCollectionOptions({
* queryKey: ['todos'],
* queryFn: async () => {
* const response = await fetch('/api/todos')
* return response.json() as Todo[] // Type automatically inferred!
* },
* queryClient,
* getKey: (item) => item.id, // item is typed as Todo
* })
* )
*
* @example
* // Explicit type
* const todosCollection = createCollection<Todo>(
* queryCollectionOptions({
* queryKey: ['todos'],
* queryFn: async () => fetch('/api/todos').then(r => r.json()),
* queryClient,
* getKey: (item) => item.id,
* })
* )
*
* @example
* // Schema inference
* const todosCollection = createCollection(
* queryCollectionOptions({
* queryKey: ['todos'],
* queryFn: async () => fetch('/api/todos').then(r => r.json()),
* queryClient,
* schema: todoSchema, // Type inferred from schema
* getKey: (item) => item.id,
* })
* )
*
* @example
* // With persistence handlers
* const todosCollection = createCollection(
* queryCollectionOptions({
* queryKey: ['todos'],
* queryFn: fetchTodos,
* queryClient,
* getKey: (item) => item.id,
* onInsert: async ({ transaction }) => {
* await api.createTodos(transaction.mutations.map(m => m.modified))
* },
* onUpdate: async ({ transaction }) => {
* await api.updateTodos(transaction.mutations)
* },
* onDelete: async ({ transaction }) => {
* await api.deleteTodos(transaction.mutations.map(m => m.key))
* }
* })
* )
*/
// Overload for when schema is provided
export function queryCollectionOptions<
T extends StandardSchemaV1,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
>(
config: QueryCollectionConfig<
InferSchemaOutput<T>,
(
context: QueryFunctionContext<any>
) => Promise<Array<InferSchemaOutput<T>>>,
TError,
TQueryKey,
TKey,
T
> & {
schema: T
}
): CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
schema: T
utils: QueryCollectionUtils<
InferSchemaOutput<T>,
TKey,
InferSchemaInput<T>,
TError
>
}
// Overload for when no schema is provided
export function queryCollectionOptions<
T extends object,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
>(
config: QueryCollectionConfig<
T,
(context: QueryFunctionContext<any>) => Promise<Array<T>>,
TError,
TQueryKey,
TKey
> & {
schema?: never // prohibit schema
}
): CollectionConfig<T, TKey> & {
schema?: never // no schema in the result
utils: QueryCollectionUtils<T, TKey, T, TError>
}
export function queryCollectionOptions(
config: QueryCollectionConfig<Record<string, unknown>>
): CollectionConfig & {
utils: QueryCollectionUtils
} {
const {
queryKey,
queryFn,
queryClient,
enabled,
refetchInterval,
retry,
retryDelay,
staleTime,
getKey,
onInsert,
onUpdate,
onDelete,
meta,
...baseCollectionConfig
} = config
// Validate required parameters
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!queryKey) {
throw new QueryKeyRequiredError()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!queryFn) {
throw new QueryFnRequiredError()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!queryClient) {
throw new QueryClientRequiredError()
}
if (!getKey) {
throw new GetKeyRequiredError()
}
/** The last error encountered by the query */
let lastError: any
/** The number of consecutive sync failures */
let errorCount = 0
/** The timestamp for when the query most recently returned the status as "error" */
let lastErrorUpdatedAt = 0
const internalSync: SyncConfig<any>[`sync`] = (params) => {
const { begin, write, commit, markReady, collection } = params
const observerOptions: QueryObserverOptions<
Array<any>,
any,
Array<any>,
Array<any>,
any
> = {
queryKey: queryKey,
queryFn: queryFn,
meta: meta,
enabled: enabled,
refetchInterval: refetchInterval,
retry: retry,
retryDelay: retryDelay,
staleTime: staleTime,
structuralSharing: true,
notifyOnChangeProps: `all`,
}
const localObserver = new QueryObserver<
Array<any>,
any,
Array<any>,
Array<any>,
any
>(queryClient, observerOptions)
type UpdateHandler = Parameters<typeof localObserver.subscribe>[0]
const handleUpdate: UpdateHandler = (result) => {
if (result.isSuccess) {
// Clear error state
lastError = undefined
errorCount = 0
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, any>()
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
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()
// Mark collection as ready after first successful query result
markReady()
} else if (result.isError) {
if (result.errorUpdatedAt !== lastErrorUpdatedAt) {
lastError = result.error
errorCount++
lastErrorUpdatedAt = result.errorUpdatedAt
}
console.error(
`[QueryCollection] Error observing query ${String(queryKey)}:`,
result.error
)
// Mark collection as ready even on error to avoid blocking apps
markReady()
}
}
const actualUnsubscribeFn = localObserver.subscribe(handleUpdate)
// Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial
// state)
handleUpdate(localObserver.getCurrentResult())
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 = (opts) => {
return queryClient.refetchQueries(
{
queryKey: queryKey,
},
{
throwOnError: opts?.throwOnError,
}
)
}
// Create write context for manual write operations
let writeContext: {
collection: any
queryClient: QueryClient
queryKey: Array<unknown>
getKey: (item: any) => string | number
begin: () => void
write: (message: Omit<ChangeMessage<any>, `key`>) => void
commit: () => void
} | null = null
// Enhanced internalSync that captures write functions for manual use
const enhancedInternalSync: SyncConfig<any>[`sync`] = (params) => {
const { begin, write, commit, collection } = params
// Store references for manual write operations
writeContext = {
collection,
queryClient,
queryKey: queryKey as unknown as Array<unknown>,
getKey: getKey as (item: any) => string | number,
begin,
write,
commit,
}
// Call the original internalSync logic
return internalSync(params)
}
// Create write utils using the manual-sync module
const writeUtils = createWriteUtils<any, string | number, any>(
() => writeContext
)
// Create wrapper handlers for direct persistence operations that handle refetching
const wrappedOnInsert = onInsert
? async (params: InsertMutationFnParams<any>) => {
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<any>) => {
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<any>) => {
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: enhancedInternalSync },
onInsert: wrappedOnInsert,
onUpdate: wrappedOnUpdate,
onDelete: wrappedOnDelete,
utils: {
refetch,
...writeUtils,
lastError: () => lastError,
isError: () => !!lastError,
errorCount: () => errorCount,
clearError: () => {
lastError = undefined
errorCount = 0
lastErrorUpdatedAt = 0
return refetch({ throwOnError: true })
},
},
}
}