UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

143 lines (129 loc) • 5.12 kB
import { createTransaction, safeRandomUUID } from '@tanstack/db' import type { PendingMutation, Transaction } from '@tanstack/db' import type { CreateOfflineTransactionOptions, OfflineMutationFn, OfflineTransaction as OfflineTransactionType, } from '../types' export class OfflineTransaction { private offlineId: string private mutationFnName: string private autoCommit: boolean | undefined private idempotencyKey: string private metadata: Record<string, any> private transaction: Transaction | null = null /** The mutation function ran, so the executor settles the waiter. */ private persisting = false private persistTransaction: (tx: OfflineTransactionType) => Promise<void> private executor: any // Will be typed properly - reference to OfflineExecutor constructor( options: CreateOfflineTransactionOptions, mutationFn: OfflineMutationFn, persistTransaction: (tx: OfflineTransactionType) => Promise<void>, executor: any, ) { this.offlineId = safeRandomUUID() this.mutationFnName = options.mutationFnName // The TanStack DB transaction applies the default. this.autoCommit = options.autoCommit this.idempotencyKey = options.idempotencyKey ?? safeRandomUUID() this.metadata = options.metadata ?? {} this.persistTransaction = persistTransaction this.executor = executor } mutate(callback: () => void): Transaction { // One offline transaction is one transaction, and its id is unique among // live transactions. Repeated calls add to it while it is pending. With // autoCommit, it commits after this callback and reports a failure // through `when('settled')`, so a later call throws: it is no longer pending. if (!this.transaction) { this.transaction = createTransaction({ id: this.offlineId, autoCommit: this.autoCommit, mutationFn: async () => { this.persisting = true // This is the blocking mutationFn that waits for the executor // First persist the transaction to the outbox const offlineTransaction: OfflineTransactionType = { id: this.offlineId, mutationFnName: this.mutationFnName, mutations: this.transaction!.mutations, keys: this.extractKeys(this.transaction!.mutations), idempotencyKey: this.idempotencyKey, createdAt: new Date(), retryCount: 0, nextAttemptAt: Date.now(), metadata: this.metadata, spanContext: undefined, version: 1, } const completionPromise = this.executor.waitForTransactionCompletion( this.offlineId, ) try { // Persistence also drives the shared queue. This transaction can finish // before that queue drains; observe both promises from the outset. await Promise.race([ this.persistTransaction(offlineTransaction), completionPromise, ]) // A queue pause (offline or retry) is not transaction completion. await completionPromise } catch (error) { const normalizedError = error instanceof Error ? error : new Error(String(error)) this.executor.rejectTransaction(this.offlineId, normalizedError) throw error } return }, metadata: this.metadata, }) // A transaction without mutations completes, and one rolled back before // it commits fails, without its mutation function, so the executor never // settles its waiter. Settle that waiter here with the same outcome. Once // the mutation function runs, only the executor settles it. void this.transaction.when(`settled`).then( () => { if (!this.persisting) this.executor.resolveTransaction(this.offlineId, undefined) }, (error: unknown) => { if (!this.persisting) this.executor.rejectTransaction( this.offlineId, error instanceof Error ? error : new Error(`Offline transaction was rolled back`), ) }, ) } this.transaction.mutate(() => { callback() }) return this.transaction } async commit(): Promise<Transaction> { if (!this.transaction) { throw new Error(`No mutations to commit. Call mutate() first.`) } // The mutationFn persists the transaction and waits for the executor. A // commit that rejects has already rolled the transaction back. A sibling's // failure can also roll it back while it waits, and then commit() resolves, // so `when('settled')` is the receipt. await this.transaction.commit() return this.transaction } rollback(): void { if (this.transaction) { this.transaction.rollback() } } private extractKeys(mutations: Array<PendingMutation>): Array<string> { return mutations.map((mutation) => mutation.globalKey) } get id(): string { return this.offlineId } }