UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

246 lines (235 loc) • 8.32 kB
import { createTransaction } from './transactions' import { normalizeError } from './utils/error' import { DebounceCallDroppedError, PacedTransactionManualCommitError, 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. The strategy owns `commit()`; calling it on the returned * transaction throws before persistence starts. `rollback()` remains available. * If a synchronous `onMutate` calls this manager again and then throws, every * call merged into that pending transaction rejects together. * * @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.when('settled') * ``` * * @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 let activeTransaction: Transaction<T> | null = null const strategyCommits = new WeakMap< Transaction<T>, () => Promise<Transaction<T>> >() let optimisticFrame: { transaction: Transaction<T>; admittedNestedCall: boolean } | undefined function getTransaction(isolated = false): Transaction<T> { if (!isolated && activeTransaction?.state === `pending`) return activeTransaction const transaction = createTransaction<T>({ ...transactionConfig, mutationFn, autoCommit: false, }) strategyCommits.set(transaction, transaction.commit.bind(transaction)) transaction.commit = () => { throw new PacedTransactionManualCommitError() } if (!isolated) activeTransaction = transaction return transaction } function commit( transaction: Transaction<T>, onStarted?: (completion: Promise<Transaction<T>>) => void, ): Transaction<T> { if (activeTransaction === transaction) activeTransaction = null // A pending transaction can be rolled back directly or by a prior same-key // failure. Its scheduled callback must not revive canceled mutations. if (transaction.state === `failed`) return transaction if (transaction.state !== `pending`) { throw new Error( `Strategy callback called but transaction is in state "${transaction.state}". Expected "pending".`, ) } const strategyCommit = strategyCommits.get(transaction) if (!strategyCommit) throw new Error(`Paced transaction has no strategy-owned commit`) const completion = strategyCommit() onStarted?.(completion) completion.catch(() => { // Persistence failures are reported by transaction.isPersisted.promise. }) return transaction } function applyOptimistic( transaction: Transaction<T>, variables: TVariables, newlyCreated: boolean, ): void { const parent = optimisticFrame if (parent?.transaction === transaction) parent.admittedNestedCall = true const frame = { transaction, admittedNestedCall: false } optimisticFrame = frame try { transaction.mutate(() => onMutate(variables)) } catch (error) { if (newlyCreated || frame.admittedNestedCall) { // Calls merged into this pending transaction share a failure. A newly // created transaction also needs release when no receipt was returned. void transaction.isPersisted.promise.catch(() => {}) transaction.rollback({ error: normalizeError(error), isSecondaryRollback: !frame.admittedNestedCall, }) if (activeTransaction === transaction) activeTransaction = null } throw error } finally { optimisticFrame = parent } } function mutate(variables: TVariables): Transaction<T> { if (strategy._type === `debounce` || strategy._type === `throttle`) { let transaction: Transaction<T> | undefined let completion: Promise<Transaction<T>> | undefined const onAdmit = (): Transaction<T> => { if (transaction) return transaction const previous = activeTransaction transaction = getTransaction() applyOptimistic(transaction, variables, transaction !== previous) return transaction } const admitted = strategy.execute( () => { // Legacy custom strategies may ignore the optional admission callback. return commit(onAdmit(), (started) => { completion = started }) }, onAdmit, () => completion, ) if (admitted !== false) return onAdmit() // Rejected calls must never join an already-admitted pending transaction. const dropped = getTransaction(true) applyOptimistic(dropped, variables, true) dropped.rollback({ error: strategy._type === `debounce` ? new DebounceCallDroppedError() : new ThrottleCallDroppedError(), isSecondaryRollback: true, }) return dropped } const previous = activeTransaction const transaction = getTransaction(strategy._type === `queue`) applyOptimistic(transaction, variables, transaction !== previous) try { let completion: Promise<Transaction<T>> | undefined const admitted = strategy.execute( () => commit(transaction, (started) => { completion = started }), undefined, () => completion, ) if (strategy._type === `queue` && admitted === false) { transaction.rollback({ error: new QueueCapacityExceededError(), isSecondaryRollback: true, }) } } catch (error) { if (!(error instanceof QueueDisposedError)) throw error transaction.rollback({ error, isSecondaryRollback: true }) } return transaction } return mutate }