UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

204 lines (188 loc) • 6.61 kB
import { createTransaction } from './transactions' import { DebounceCallDroppedError, QueueCapacityExceededError, QueueDisposedError, ThrottleCallDroppedError, } from './errors' import type { MutationFn, Transaction } from './types' import type { Strategy } from './strategies/types' /** * Configuration for creating a paced mutations manager */ export interface PacedMutationsConfig< TVariables = unknown, T extends object = Record<string, unknown>, > { /** * Callback to apply optimistic updates immediately. * Receives the variables passed to the mutate function. */ onMutate: (variables: TVariables) => void /** * Function to execute the mutation on the server. * Receives the transaction parameters containing all merged mutations. */ mutationFn: MutationFn<T> /** * Strategy for controlling mutation execution timing * Examples: debounceStrategy, queueStrategy, throttleStrategy */ strategy: Strategy /** * Custom metadata to associate with transactions */ metadata?: Record<string, unknown> } /** * Creates a paced mutations manager with pluggable timing strategies. * * This function provides a way to control when and how optimistic mutations * are persisted to the backend, using strategies like debouncing, queuing, * or throttling. The optimistic updates are applied immediately via `onMutate`, * and the actual persistence is controlled by the strategy. * * The returned function accepts variables of type TVariables and returns a * Transaction object that can be awaited to know when persistence completes * or to handle errors. * * @param config - Configuration including onMutate, mutationFn and strategy * @returns A function that accepts variables and returns a Transaction * * @example * ```ts * // Debounced mutations for auto-save * const updateTodo = createPacedMutations<string>({ * onMutate: (text) => { * // Apply optimistic update immediately * collection.update(id, draft => { draft.text = text }) * }, * mutationFn: async ({ transaction }) => { * await api.save(transaction.mutations) * }, * strategy: debounceStrategy({ wait: 500 }) * }) * * // Call with variables, returns a transaction * const tx = updateTodo('New text') * * // Await persistence or handle errors * await tx.isPersisted.promise * ``` * * @example * ```ts * // Queue strategy for sequential processing * const addTodo = createPacedMutations<{ text: string }>({ * onMutate: ({ text }) => { * collection.insert({ id: uuid(), text, completed: false }) * }, * mutationFn: async ({ transaction }) => { * await api.save(transaction.mutations) * }, * strategy: queueStrategy({ * wait: 200, * addItemsTo: 'back', * getItemsFrom: 'front' * }) * }) * ``` */ export function createPacedMutations< TVariables = unknown, T extends object = Record<string, unknown>, >( config: PacedMutationsConfig<TVariables, T>, ): (variables: TVariables) => Transaction<T> { const { onMutate, mutationFn, strategy, ...transactionConfig } = config // The currently active transaction (pending, not yet persisting) let activeTransaction: Transaction<T> | null = null // Commit callback that the strategy will call when it's time to persist const commitCallback = () => { if (!activeTransaction) { throw new Error( `Strategy callback called but no active transaction exists. This indicates a bug in the strategy implementation.`, ) } if (activeTransaction.state !== `pending`) { throw new Error( `Strategy callback called but active transaction is in state "${activeTransaction.state}". Expected "pending".`, ) } const txToCommit = activeTransaction // Clear active transaction reference before committing activeTransaction = null // Commit the transaction txToCommit.commit().catch(() => { // Errors are handled via transaction.isPersisted.promise // This catch prevents unhandled promise rejections }) return txToCommit } /** * Executes a mutation with the given variables. Creates a new transaction if none is active, * or adds to the existing active transaction. The strategy controls when * the transaction is actually committed. */ function mutate(variables: TVariables): Transaction<T> { // Create a new transaction if we don't have an active one if (!activeTransaction || activeTransaction.state !== `pending`) { activeTransaction = createTransaction<T>({ ...transactionConfig, mutationFn, autoCommit: false, }) } // Execute onMutate with variables to apply optimistic updates activeTransaction.mutate(() => { onMutate(variables) }) // Save reference before calling strategy.execute const txToReturn = activeTransaction // For queue strategy, pass a function that commits txToReturn // This prevents the error when commitCallback tries to access the cleared activeTransaction if (strategy._type === `queue`) { activeTransaction = null // Clear so next mutation creates a new transaction let admitted: ReturnType<typeof strategy.execute> try { admitted = strategy.execute(() => { txToReturn.commit().catch(() => { // Errors are handled via transaction.isPersisted.promise }) return txToReturn }) } catch (error) { if (!(error instanceof QueueDisposedError)) throw error txToReturn.rollback({ error, isSecondaryRollback: true }) return txToReturn } if (admitted === false) { // Admission failure belongs to this call; admitted same-key writes // must remain in the queue and keep their optimistic state. txToReturn.rollback({ error: new QueueCapacityExceededError(), isSecondaryRollback: true, }) } } else { // Debounce/throttle share pending work until commitCallback runs. With // trailing disabled, a skipped optimistic call must be rejected. const executed = strategy.execute(commitCallback) if ( (strategy._type === `debounce` || strategy._type === `throttle`) && executed === false ) { txToReturn.rollback({ error: strategy._type === `debounce` ? new DebounceCallDroppedError() : new ThrottleCallDroppedError(), isSecondaryRollback: true, }) activeTransaction = null } } return txToReturn } return mutate }