@tanstack/db
Version:
A reactive client store for building super fast apps on sync
204 lines (188 loc) • 6.61 kB
text/typescript
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
}