@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
143 lines (129 loc) • 5.12 kB
text/typescript
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
}
}