@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
632 lines (545 loc) • 18.9 kB
text/typescript
// Storage adapters
import {
createOptimisticAction,
createTransaction,
safeRandomUUID,
} from '@tanstack/db'
import { IndexedDBAdapter } from './storage/IndexedDBAdapter'
import { LocalStorageAdapter } from './storage/LocalStorageAdapter'
// Core components
import { OutboxManager } from './outbox/OutboxManager'
import { KeyScheduler } from './executor/KeyScheduler'
import { TransactionExecutor } from './executor/TransactionExecutor'
// Coordination
import { WebLocksLeader } from './coordination/WebLocksLeader'
import { BroadcastChannelLeader } from './coordination/BroadcastChannelLeader'
// Connectivity
import { WebOnlineDetector } from './connectivity/OnlineDetector'
// API
import { OfflineTransaction as OfflineTransactionAPI } from './api/OfflineTransaction'
import { createOfflineAction } from './api/OfflineAction'
// TanStack DB primitives
// Replay
import { NonRetriableError } from './types'
import type {
CreateOfflineActionOptions,
CreateOfflineTransactionOptions,
LeaderElection,
OfflineConfig,
OfflineMode,
OfflineTransaction,
OnlineDetector,
StorageAdapter,
StorageDiagnostic,
} from './types'
import type { Transaction } from '@tanstack/db'
/** How many settled ids an executor remembers to reject late waiters. */
const SETTLED_TRANSACTIONS_REMEMBERED = 1000
export class OfflineExecutor {
private config: OfflineConfig
// @ts-expect-error - Set during async initialization in initialize()
private storage: StorageAdapter | null
private outbox: OutboxManager | null
private scheduler: KeyScheduler
private executor: TransactionExecutor | null
private leaderElection: LeaderElection | null
private onlineDetector: OnlineDetector
private isLeaderState = false
private disposed = false
private unsubscribeOnline: (() => void) | null = null
private unsubscribeLeadership: (() => void) | null = null
// Public diagnostic properties
public readonly mode: OfflineMode
public readonly storageDiagnostic: StorageDiagnostic
// Track initialization completion
private initPromise: Promise<void>
private initResolve!: () => void
private initReject!: (error: Error) => void
// Coordination mechanism for blocking transactions
private pendingTransactionPromises: Map<
string,
{
promise: Promise<any>
resolve: (result: any) => void
reject: (error: Error) => void
}
> = new Map()
/**
* Ids this executor settled, oldest first. A waiter for one of them has
* nothing left to wait for. An unknown id can still arrive from another tab.
*/
private readonly settledTransactions = new Set<string>()
// Track restoration transactions for cleanup when offline transactions complete
private restorationTransactions: Map<string, Transaction> = new Map()
constructor(config: OfflineConfig) {
this.config = config
this.scheduler = new KeyScheduler()
this.onlineDetector = config.onlineDetector ?? new WebOnlineDetector()
// Initialize as pending - will be set by async initialization
this.storage = null
this.outbox = null
this.executor = null
this.leaderElection = null
// Temporary diagnostic - will be updated by async initialization
this.mode = `offline`
this.storageDiagnostic = {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Initializing storage...`,
}
// Create initialization promise
this.initPromise = new Promise((resolve, reject) => {
this.initResolve = resolve
this.initReject = reject
})
// Handle constructor-started rejection; waitForInit still observes it.
void this.initPromise.catch(() => {})
this.initialize()
}
/**
* Probe storage availability and create appropriate adapter.
* Returns null if no storage is available (online-only mode).
*/
private async createStorage(): Promise<{
storage: StorageAdapter | null
diagnostic: StorageDiagnostic
}> {
// If user provided custom storage, use it without probing
if (this.config.storage) {
return {
storage: this.config.storage,
diagnostic: {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Using custom storage adapter`,
},
}
}
// Probe IndexedDB first
const idbProbe = await IndexedDBAdapter.probe()
if (idbProbe.available) {
return {
storage: new IndexedDBAdapter(),
diagnostic: {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Using IndexedDB for offline storage`,
},
}
}
// IndexedDB failed, try localStorage
const lsProbe = LocalStorageAdapter.probe()
if (lsProbe.available) {
return {
storage: new LocalStorageAdapter(),
diagnostic: {
code: `INDEXEDDB_UNAVAILABLE`,
mode: `offline`,
message: `IndexedDB unavailable, using localStorage fallback`,
error: idbProbe.error,
},
}
}
// Both failed - determine the diagnostic code
const isSecurityError =
idbProbe.error?.name === `SecurityError` ||
lsProbe.error?.name === `SecurityError`
const isQuotaError =
idbProbe.error?.name === `QuotaExceededError` ||
lsProbe.error?.name === `QuotaExceededError`
let code: StorageDiagnostic[`code`]
let message: string
if (isSecurityError) {
code = `STORAGE_BLOCKED`
message = `Storage blocked (private mode or security restrictions). Running in online-only mode.`
} else if (isQuotaError) {
code = `QUOTA_EXCEEDED`
message = `Storage quota exceeded. Running in online-only mode.`
} else {
code = `UNKNOWN_ERROR`
message = `Storage unavailable due to unknown error. Running in online-only mode.`
}
return {
storage: null,
diagnostic: {
code,
mode: `online-only`,
message,
error: idbProbe.error || lsProbe.error,
},
}
}
private createLeaderElection(): LeaderElection {
if (this.config.leaderElection) {
return this.config.leaderElection
}
if (WebLocksLeader.isSupported()) {
return new WebLocksLeader()
} else if (BroadcastChannelLeader.isSupported()) {
return new BroadcastChannelLeader()
} else {
// Fallback: always be leader in environments without multi-tab support
return {
requestLeadership: () => Promise.resolve(true),
releaseLeadership: () => {},
isLeader: () => true,
onLeadershipChange: () => () => {},
}
}
}
private setupEventListeners(): void {
// Only set up leader election listeners if we have storage
if (this.leaderElection) {
this.unsubscribeLeadership = this.leaderElection.onLeadershipChange(
(isLeader) => {
// A custom elector may repeat the initial result while replay is active.
if (this.disposed || isLeader === this.isLeaderState) return
this.isLeaderState = isLeader
if (!isLeader) this.executor?.pause()
if (this.config.onLeadershipChange) {
this.config.onLeadershipChange(isLeader)
}
if (isLeader) {
this.loadAndReplayTransactions().catch((error) => {
console.warn(`Failed to load and replay transactions:`, error)
})
}
},
)
}
this.unsubscribeOnline = this.onlineDetector.subscribe(() => {
if (this.isOfflineEnabled && this.executor) {
this.executor.resetRetryDelays()
if (this.scheduler.getPendingCount() > 0) {
const barrierPromise = this.executor.executeAll()
for (const collection of Object.values(this.config.collections)) {
collection.deferDataRefresh = barrierPromise
}
barrierPromise
.catch((error) => {
console.warn(
`Failed to execute transactions on connectivity change:`,
error,
)
})
.finally(() => {
for (const collection of Object.values(this.config.collections)) {
if (collection.deferDataRefresh === barrierPromise) {
collection.deferDataRefresh = null
}
}
})
} else {
this.executor.executeAll().catch((error) => {
console.warn(
`Failed to execute transactions on connectivity change:`,
error,
)
})
}
}
})
}
private async initialize(): Promise<void> {
try {
// Probe storage and create adapter
const { storage, diagnostic } = await this.createStorage()
if (this.disposed) {
this.initResolve()
return
}
// Cast to writable to set readonly properties
;(this as any).storage = storage
;(this as any).storageDiagnostic = diagnostic
;(this as any).mode = diagnostic.mode
if (!storage) {
// Online-only mode - notify callback and skip offline setup
if (this.config.onStorageFailure) {
this.config.onStorageFailure(diagnostic)
}
this.initResolve()
return
}
// Storage available - set up offline components
this.outbox = new OutboxManager(storage, this.config.collections)
this.executor = new TransactionExecutor(
this.scheduler,
this.outbox,
this.config,
this,
)
this.leaderElection = this.createLeaderElection()
// Request leadership first
const isLeader = await this.leaderElection.requestLeadership()
// Disposal may have run while leadership was pending.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.disposed) {
this.leaderElection.releaseLeadership()
this.initResolve()
return
}
this.isLeaderState = isLeader
// Set up event listeners after leadership is established
// This prevents the callback from being called multiple times
this.setupEventListeners()
// Notify initial leadership state
if (this.config.onLeadershipChange) {
this.config.onLeadershipChange(isLeader)
}
if (isLeader) {
await this.loadAndReplayTransactions()
}
this.initResolve()
} catch (error) {
console.warn(`Failed to initialize offline executor:`, error)
this.initReject(error instanceof Error ? error : new Error(String(error)))
}
}
private async loadAndReplayTransactions(): Promise<void> {
if (!this.executor) {
return
}
// Startup must observe a failed read instead of reporting an empty outbox.
await this.executor.loadPendingTransactions()
// Replay completion is separate from initialization and durable admission.
this.executor.executeAll().catch((error) => {
console.warn(`Failed to execute transactions:`, error)
})
}
get isOfflineEnabled(): boolean {
return !this.disposed && this.mode === `offline` && this.isLeaderState
}
/**
* Wait for the executor to fully initialize.
* This ensures that pending transactions are loaded and optimistic state is restored.
*/
async waitForInit(): Promise<void> {
return this.initPromise
}
createOfflineTransaction(
options: CreateOfflineTransactionOptions,
): Transaction | OfflineTransactionAPI {
const mutationFn = this.config.mutationFns[options.mutationFnName]
if (!mutationFn) {
throw new Error(`Unknown mutation function: ${options.mutationFnName}`)
}
// Check leadership immediately and use the appropriate primitive
if (!this.isOfflineEnabled) {
// Non-leader: use createTransaction directly with the resolved mutation function
// We need to wrap it to add the idempotency key
return createTransaction({
autoCommit: options.autoCommit ?? true,
mutationFn: (params) =>
mutationFn({
...params,
idempotencyKey: options.idempotencyKey || safeRandomUUID(),
}),
metadata: options.metadata,
})
}
// Leader: use OfflineTransaction wrapper for offline persistence
return new OfflineTransactionAPI(
options,
mutationFn,
this.persistTransaction.bind(this),
this,
)
}
createOfflineAction<T>(options: CreateOfflineActionOptions<T>) {
const mutationFn = this.config.mutationFns[options.mutationFnName]
if (!mutationFn) {
throw new Error(`Unknown mutation function: ${options.mutationFnName}`)
}
// Return a wrapper that checks leadership status at call time
return (variables: T) => {
// Check leadership when action is called, not when it's created
if (!this.isOfflineEnabled) {
// Non-leader: use createOptimisticAction directly
const action = createOptimisticAction({
mutationFn: (vars, params) =>
mutationFn({
...vars,
...params,
idempotencyKey: safeRandomUUID(),
}),
onMutate: options.onMutate,
})
return action(variables)
}
// Leader: use the offline action wrapper
const action = createOfflineAction(
options,
mutationFn,
this.persistTransaction.bind(this),
this,
)
return action(variables)
}
}
private async persistTransaction(
transaction: OfflineTransaction,
): Promise<void> {
// Wait for initialization to complete
await this.initPromise
if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
throw new NonRetriableError(`Offline transaction was not admitted`)
}
try {
this.executor.assertHealthy()
await this.outbox.add(transaction)
// A shared queue error belongs to its execution, not every caller
// that durably admitted a transaction. Per-ID signals settle callers.
this.executor.execute(transaction).catch((error) => {
console.warn(`Failed to execute transactions:`, error)
})
} catch (error) {
console.error(
`Failed to persist offline transaction ${transaction.id}:`,
error,
)
throw error
}
}
private rememberSettled(transactionId: string): void {
this.settledTransactions.add(transactionId)
if (this.settledTransactions.size > SETTLED_TRANSACTIONS_REMEMBERED)
this.settledTransactions.delete(
this.settledTransactions.values().next().value!,
)
}
// Method for OfflineTransaction to wait for completion
async waitForTransactionCompletion(transactionId: string): Promise<any> {
const existing = this.pendingTransactionPromises.get(transactionId)
if (existing) {
return existing.promise
}
if (this.settledTransactions.has(transactionId))
throw new Error(
`Offline transaction ${transactionId} already settled; wait for it before it settles`,
)
const deferred: {
promise: Promise<any>
resolve: (result: any) => void
reject: (error: Error) => void
} = {} as any
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve
deferred.reject = reject
})
this.pendingTransactionPromises.set(transactionId, deferred)
return deferred.promise
}
// Method for TransactionExecutor to signal completion
resolveTransaction(transactionId: string, result: any): void {
this.rememberSettled(transactionId)
const deferred = this.pendingTransactionPromises.get(transactionId)
if (deferred) {
deferred.resolve(result)
this.pendingTransactionPromises.delete(transactionId)
}
// Clean up the restoration transaction - the sync will provide authoritative data
this.cleanupRestorationTransaction(transactionId)
}
// Method for TransactionExecutor to signal failure
rejectTransaction(transactionId: string, error: Error): void {
this.rememberSettled(transactionId)
const deferred = this.pendingTransactionPromises.get(transactionId)
if (deferred) {
deferred.reject(error)
this.pendingTransactionPromises.delete(transactionId)
}
// Clean up the restoration transaction and rollback optimistic state
this.cleanupRestorationTransaction(transactionId, true)
}
// Method for TransactionExecutor to register restoration transactions
registerRestorationTransaction(
offlineTransactionId: string,
restorationTransaction: Transaction,
): void {
this.restorationTransactions.set(
offlineTransactionId,
restorationTransaction,
)
}
private cleanupRestorationTransaction(
transactionId: string,
shouldRollback = false,
): void {
const restorationTx = this.restorationTransactions.get(transactionId)
if (!restorationTx) {
return
}
this.restorationTransactions.delete(transactionId)
if (shouldRollback) {
restorationTx.rollback()
return
}
// Mark as completed: every Collection that tracked it recomputes and
// releases it. The actual data will come from the sync.
restorationTx.setState(`completed`)
restorationTx.isPersisted.resolve(restorationTx)
restorationTx.touchCollection()
}
async removeFromOutbox(id: string): Promise<void> {
if (!this.outbox) {
return
}
await this.outbox.remove(id)
}
async peekOutbox(): Promise<Array<OfflineTransaction>> {
if (!this.outbox) {
return []
}
return this.outbox.getAll()
}
async clearOutbox(): Promise<void> {
if (!this.outbox || !this.executor) {
return
}
await this.outbox.clear()
this.executor.clear()
}
getPendingCount(): number {
if (!this.executor) {
return 0
}
return this.executor.getPendingCount()
}
getRunningCount(): number {
if (!this.executor) {
return 0
}
return this.executor.getRunningCount()
}
getOnlineDetector(): OnlineDetector {
return this.onlineDetector
}
isOnline(): boolean {
return this.onlineDetector.isOnline()
}
dispose(): void {
this.disposed = true
this.executor?.pause()
for (const collection of Object.values(this.config.collections)) {
collection.deferDataRefresh = null
}
if (this.unsubscribeOnline) {
this.unsubscribeOnline()
this.unsubscribeOnline = null
}
if (this.unsubscribeLeadership) {
this.unsubscribeLeadership()
this.unsubscribeLeadership = null
}
if (this.leaderElection) {
this.leaderElection.releaseLeadership()
if (`dispose` in this.leaderElection) {
;(this.leaderElection as any).dispose()
}
}
this.onlineDetector.dispose()
}
}
export function startOfflineExecutor(config: OfflineConfig): OfflineExecutor {
return new OfflineExecutor(config)
}