@tanstack/db
Version:
A reactive client store for building super fast apps on sync
522 lines (476 loc) • 15.7 kB
text/typescript
import { createDeferred } from "./deferred"
import {
MissingMutationFunctionError,
TransactionAlreadyCompletedRollbackError,
TransactionNotPendingCommitError,
TransactionNotPendingMutateError,
} from "./errors"
import type { Deferred } from "./deferred"
import type {
MutationFn,
PendingMutation,
TransactionConfig,
TransactionState,
TransactionWithMutations,
} from "./types"
const transactions: Array<Transaction<any>> = []
let transactionStack: Array<Transaction<any>> = []
let sequenceNumber = 0
/**
* Merges two pending mutations for the same item within a transaction
*
* Merge behavior truth table:
* - (insert, update) → insert (merge changes, keep empty original)
* - (insert, delete) → null (cancel both mutations)
* - (update, delete) → delete (delete dominates)
* - (update, update) → update (replace with latest, union changes)
* - (delete, delete) → delete (replace with latest)
* - (insert, insert) → insert (replace with latest)
*
* Note: (delete, update) and (delete, insert) should never occur as the collection
* layer prevents operations on deleted items within the same transaction.
*
* @param existing - The existing mutation in the transaction
* @param incoming - The new mutation being applied
* @returns The merged mutation, or null if both should be removed
*/
function mergePendingMutations<T extends object>(
existing: PendingMutation<T>,
incoming: PendingMutation<T>
): PendingMutation<T> | null {
// Truth table implementation
switch (`${existing.type}-${incoming.type}` as const) {
case `insert-update`: {
// Update after insert: keep as insert but merge changes
// For insert-update, the key should remain the same since collections don't allow key changes
return {
...existing,
type: `insert` as const,
original: {},
modified: incoming.modified,
changes: { ...existing.changes, ...incoming.changes },
// Keep existing keys (key changes not allowed in updates)
key: existing.key,
globalKey: existing.globalKey,
// Merge metadata (last-write-wins)
metadata: incoming.metadata ?? existing.metadata,
syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },
// Update tracking info
mutationId: incoming.mutationId,
updatedAt: incoming.updatedAt,
}
}
case `insert-delete`:
// Delete after insert: cancel both mutations
return null
case `update-delete`:
// Delete after update: delete dominates
return incoming
case `update-update`: {
// Update after update: replace with latest, union changes
return {
...incoming,
// Keep original from first update
original: existing.original,
// Union the changes from both updates
changes: { ...existing.changes, ...incoming.changes },
// Merge metadata
metadata: incoming.metadata ?? existing.metadata,
syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },
}
}
case `delete-delete`:
case `insert-insert`:
// Same type: replace with latest
return incoming
default: {
// Exhaustiveness check
const _exhaustive: never = `${existing.type}-${incoming.type}` as never
throw new Error(`Unhandled mutation combination: ${_exhaustive}`)
}
}
}
/**
* Creates a new transaction for grouping multiple collection operations
* @param config - Transaction configuration with mutation function
* @returns A new Transaction instance
* @example
* // Basic transaction usage
* const tx = createTransaction({
* mutationFn: async ({ transaction }) => {
* // Send all mutations to API
* await api.saveChanges(transaction.mutations)
* }
* })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Buy milk" })
* collection.update("2", draft => { draft.completed = true })
* })
*
* await tx.isPersisted.promise
*
* @example
* // Handle transaction errors
* try {
* const tx = createTransaction({
* mutationFn: async () => { throw new Error("API failed") }
* })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "New item" })
* })
*
* await tx.isPersisted.promise
* } catch (error) {
* console.log('Transaction failed:', error)
* }
*
* @example
* // Manual commit control
* const tx = createTransaction({
* autoCommit: false,
* mutationFn: async () => {
* // API call
* }
* })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Item" })
* })
*
* // Commit later
* await tx.commit()
*/
export function createTransaction<T extends object = Record<string, unknown>>(
config: TransactionConfig<T>
): Transaction<T> {
const newTransaction = new Transaction<T>(config)
transactions.push(newTransaction)
return newTransaction
}
/**
* Gets the currently active ambient transaction, if any
* Used internally by collection operations to join existing transactions
* @returns The active transaction or undefined if none is active
* @example
* // Check if operations will join an ambient transaction
* const ambientTx = getActiveTransaction()
* if (ambientTx) {
* console.log('Operations will join transaction:', ambientTx.id)
* }
*/
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)
}
}
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 sequenceNumber: number
public metadata: Record<string, unknown>
public error?: {
message: string
error: Error
}
constructor(config: TransactionConfig<T>) {
if (typeof config.mutationFn === `undefined`) {
throw new MissingMutationFunctionError()
}
this.id = config.id ?? crypto.randomUUID()
this.mutationFn = config.mutationFn
this.state = `pending`
this.mutations = []
this.isPersisted = createDeferred<Transaction<T>>()
this.autoCommit = config.autoCommit ?? true
this.createdAt = new Date()
this.sequenceNumber = sequenceNumber++
this.metadata = config.metadata ?? {}
}
setState(newState: TransactionState) {
this.state = newState
if (newState === `completed` || newState === `failed`) {
removeFromPendingList(this)
}
}
/**
* Execute collection operations within this transaction
* @param callback - Function containing collection operations to group together
* @returns This transaction for chaining
* @example
* // Group multiple operations
* const tx = createTransaction({ mutationFn: async () => {
* // Send to API
* }})
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Buy milk" })
* collection.update("2", draft => { draft.completed = true })
* collection.delete("3")
* })
*
* await tx.isPersisted.promise
*
* @example
* // Handle mutate errors
* try {
* tx.mutate(() => {
* collection.insert({ id: "invalid" }) // This might throw
* })
* } catch (error) {
* console.log('Mutation failed:', error)
* }
*
* @example
* // Manual commit control
* const tx = createTransaction({ autoCommit: false, mutationFn: async () => {} })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Item" })
* })
*
* // Commit later when ready
* await tx.commit()
*/
mutate(callback: () => void): Transaction<T> {
if (this.state !== `pending`) {
throw new TransactionNotPendingMutateError()
}
registerTransaction(this)
try {
callback()
} finally {
unregisterTransaction(this)
}
if (this.autoCommit) {
this.commit().catch(() => {
// Errors from autoCommit are handled via isPersisted.promise
// This catch prevents unhandled promise rejections
})
}
return this
}
/**
* Apply new mutations to this transaction, intelligently merging with existing mutations
*
* When mutations operate on the same item (same globalKey), they are merged according to
* the following rules:
*
* - **insert + update** → insert (merge changes, keep empty original)
* - **insert + delete** → removed (mutations cancel each other out)
* - **update + delete** → delete (delete dominates)
* - **update + update** → update (union changes, keep first original)
* - **same type** → replace with latest
*
* This merging reduces over-the-wire churn and keeps the optimistic local view
* aligned with user intent.
*
* @param mutations - Array of new mutations to apply
*/
applyMutations(mutations: Array<PendingMutation<any>>): void {
for (const newMutation of mutations) {
const existingIndex = this.mutations.findIndex(
(m) => m.globalKey === newMutation.globalKey
)
if (existingIndex >= 0) {
const existingMutation = this.mutations[existingIndex]!
const mergeResult = mergePendingMutations(existingMutation, newMutation)
if (mergeResult === null) {
// Remove the mutation (e.g., delete after insert cancels both)
this.mutations.splice(existingIndex, 1)
} else {
// Replace with merged mutation
this.mutations[existingIndex] = mergeResult
}
} else {
// Insert new mutation
this.mutations.push(newMutation)
}
}
}
/**
* Rollback the transaction and any conflicting transactions
* @param config - Configuration for rollback behavior
* @returns This transaction for chaining
* @example
* // Manual rollback
* const tx = createTransaction({ mutationFn: async () => {
* // Send to API
* }})
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Buy milk" })
* })
*
* // Rollback if needed
* if (shouldCancel) {
* tx.rollback()
* }
*
* @example
* // Handle rollback cascade (automatic)
* const tx1 = createTransaction({ mutationFn: async () => {} })
* const tx2 = createTransaction({ mutationFn: async () => {} })
*
* tx1.mutate(() => collection.update("1", draft => { draft.value = "A" }))
* tx2.mutate(() => collection.update("1", draft => { draft.value = "B" })) // Same item
*
* tx1.rollback() // This will also rollback tx2 due to conflict
*
* @example
* // Handle rollback in error scenarios
* try {
* await tx.isPersisted.promise
* } catch (error) {
* console.log('Transaction was rolled back:', error)
* // Transaction automatically rolled back on mutation function failure
* }
*/
rollback(config?: { isSecondaryRollback?: boolean }): Transaction<T> {
const isSecondaryRollback = config?.isSecondaryRollback ?? false
if (this.state === `completed`) {
throw new TransactionAlreadyCompletedRollbackError()
}
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()
// Only call commitPendingTransactions if there are pending sync transactions
if (mutation.collection.pendingSyncedTransactions.length > 0) {
mutation.collection.commitPendingTransactions()
}
hasCalled.add(mutation.collection.id)
}
}
}
/**
* Commit the transaction and execute the mutation function
* @returns Promise that resolves to this transaction when complete
* @example
* // Manual commit (when autoCommit is false)
* const tx = createTransaction({
* autoCommit: false,
* mutationFn: async ({ transaction }) => {
* await api.saveChanges(transaction.mutations)
* }
* })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Buy milk" })
* })
*
* await tx.commit() // Manually commit
*
* @example
* // Handle commit errors
* try {
* const tx = createTransaction({
* mutationFn: async () => { throw new Error("API failed") }
* })
*
* tx.mutate(() => {
* collection.insert({ id: "1", text: "Item" })
* })
*
* await tx.commit()
* } catch (error) {
* console.log('Commit failed, transaction rolled back:', error)
* }
*
* @example
* // Check transaction state after commit
* await tx.commit()
* console.log(tx.state) // "completed" or "failed"
*/
async commit(): Promise<Transaction<T>> {
if (this.state !== `pending`) {
throw new TransactionNotPendingCommitError()
}
this.setState(`persisting`)
if (this.mutations.length === 0) {
this.setState(`completed`)
this.isPersisted.resolve(this)
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) {
// Preserve the original error for rethrowing
const originalError =
error instanceof Error ? error : new Error(String(error))
// Update transaction with error information
this.error = {
message: originalError.message,
error: originalError,
}
// rollback the transaction
this.rollback()
// Re-throw the original error to preserve identity and stack
throw originalError
}
return this
}
/**
* Compare two transactions by their createdAt time and sequence number in order
* to sort them in the order they were created.
* @param other - The other transaction to compare to
* @returns -1 if this transaction was created before the other, 1 if it was created after, 0 if they were created at the same time
*/
compareCreatedAt(other: Transaction<any>): number {
const createdAtComparison =
this.createdAt.getTime() - other.createdAt.getTime()
if (createdAtComparison !== 0) {
return createdAtComparison
}
return this.sequenceNumber - other.sequenceNumber
}
}
export type { Transaction }