UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

205 lines (170 loc) 5.56 kB
import { createDeferred } from "./deferred" import type { Deferred } from "./deferred" import type { MutationFn, PendingMutation, TransactionConfig, TransactionState, TransactionWithMutations, } from "./types" const transactions: Array<Transaction<any>> = [] let transactionStack: Array<Transaction<any>> = [] export function createTransaction< TData extends object = Record<string, unknown>, >(config: TransactionConfig<TData>): Transaction<TData> { if (typeof config.mutationFn === `undefined`) { throw `mutationFn is required when creating a transaction` } let transactionId = config.id if (!transactionId) { transactionId = crypto.randomUUID() } const newTransaction = new Transaction<TData>({ ...config, id: transactionId, }) transactions.push(newTransaction) return newTransaction } export function getActiveTransaction(): Transaction | undefined { if (transactionStack.length > 0) { return transactionStack.slice(-1)[0] } else { return undefined } } function registerTransaction(tx: Transaction<any>) { transactionStack.push(tx) } function unregisterTransaction(tx: Transaction<any>) { transactionStack = transactionStack.filter((t) => t.id !== tx.id) } function removeFromPendingList(tx: Transaction<any>) { const index = transactions.findIndex((t) => t.id === tx.id) if (index !== -1) { transactions.splice(index, 1) } } export class Transaction<T extends object = Record<string, unknown>> { public id: string public state: TransactionState public mutationFn: MutationFn<T> public mutations: Array<PendingMutation<T>> public isPersisted: Deferred<Transaction<T>> public autoCommit: boolean public createdAt: Date public metadata: Record<string, unknown> public error?: { message: string error: Error } constructor(config: TransactionConfig<T>) { this.id = config.id! this.mutationFn = config.mutationFn this.state = `pending` this.mutations = [] this.isPersisted = createDeferred<Transaction<T>>() this.autoCommit = config.autoCommit ?? true this.createdAt = new Date() this.metadata = config.metadata ?? {} } setState(newState: TransactionState) { this.state = newState if (newState === `completed` || newState === `failed`) { removeFromPendingList(this) } } mutate(callback: () => void): Transaction<T> { if (this.state !== `pending`) { throw `You can no longer call .mutate() as the transaction is no longer pending` } registerTransaction(this) try { callback() } finally { unregisterTransaction(this) } if (this.autoCommit) { this.commit() } return this } applyMutations(mutations: Array<PendingMutation<any>>): void { for (const newMutation of mutations) { const existingIndex = this.mutations.findIndex( (m) => m.globalKey === newMutation.globalKey ) if (existingIndex >= 0) { // Replace existing mutation this.mutations[existingIndex] = newMutation } else { // Insert new mutation this.mutations.push(newMutation) } } } rollback(config?: { isSecondaryRollback?: boolean }): Transaction<T> { const isSecondaryRollback = config?.isSecondaryRollback ?? false if (this.state === `completed`) { throw `You can no longer call .rollback() as the transaction is already completed` } this.setState(`failed`) // See if there's any other transactions w/ mutations on the same ids // and roll them back as well. if (!isSecondaryRollback) { const mutationIds = new Set() this.mutations.forEach((m) => mutationIds.add(m.globalKey)) for (const t of transactions) { t.state === `pending` && t.mutations.some((m) => mutationIds.has(m.globalKey)) && t.rollback({ isSecondaryRollback: true }) } } // Reject the promise this.isPersisted.reject(this.error?.error) this.touchCollection() return this } // Tell collection that something has changed with the transaction touchCollection(): void { const hasCalled = new Set() for (const mutation of this.mutations) { if (!hasCalled.has(mutation.collection.id)) { mutation.collection.onTransactionStateChange() mutation.collection.commitPendingTransactions() hasCalled.add(mutation.collection.id) } } } async commit(): Promise<Transaction<T>> { if (this.state !== `pending`) { throw `You can no longer call .commit() as the transaction is no longer pending` } this.setState(`persisting`) if (this.mutations.length === 0) { this.setState(`completed`) return this } // Run mutationFn try { // At this point we know there's at least one mutation // We've already verified mutations is non-empty, so this cast is safe // Use a direct type assertion instead of object spreading to preserve the original type await this.mutationFn({ transaction: this as unknown as TransactionWithMutations<T>, }) this.setState(`completed`) this.touchCollection() this.isPersisted.resolve(this) } catch (error) { // Update transaction with error information this.error = { message: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error : new Error(String(error)), } // rollback the transaction return this.rollback() } return this } }