UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

624 lines (537 loc) 18.6 kB
// Storage adapters import { createOptimisticAction, createTransaction } 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 { withNestedSpan, withSpan } from './telemetry/tracer' import type { CreateOfflineActionOptions, CreateOfflineTransactionOptions, LeaderElection, OfflineConfig, OfflineMode, OfflineTransaction, OnlineDetector, StorageAdapter, StorageDiagnostic, } from './types' import type { Transaction } from '@tanstack/db' 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 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() // 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 }) 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) => { this.isLeaderState = isLeader if (this.config.onLeadershipChange) { this.config.onLeadershipChange(isLeader) } if (isLeader) { this.loadAndReplayTransactions() } }, ) } 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> { return withSpan(`executor.initialize`, {}, async (span) => { try { // Probe storage and create adapter const { storage, diagnostic } = await this.createStorage() // Cast to writable to set readonly properties ;(this as any).storage = storage ;(this as any).storageDiagnostic = diagnostic ;(this as any).mode = diagnostic.mode span.setAttribute(`storage.mode`, diagnostic.mode) span.setAttribute(`storage.code`, diagnostic.code) if (!storage) { // Online-only mode - notify callback and skip offline setup if (this.config.onStorageFailure) { this.config.onStorageFailure(diagnostic) } span.setAttribute(`result`, `online-only`) 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() this.isLeaderState = isLeader span.setAttribute(`isLeader`, 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() } span.setAttribute(`result`, `offline-enabled`) this.initResolve() } catch (error) { console.warn(`Failed to initialize offline executor:`, error) span.setAttribute(`result`, `failed`) this.initReject( error instanceof Error ? error : new Error(String(error)), ) } }) } private async loadAndReplayTransactions(): Promise<void> { if (!this.executor) { return } try { // Load pending transactions and restore optimistic state await this.executor.loadPendingTransactions() // Start execution in the background - don't await to avoid blocking initialization // The transactions will execute and complete asynchronously this.executor.executeAll().catch((error) => { console.warn(`Failed to execute transactions:`, error) }) } catch (error) { console.warn(`Failed to load and replay transactions:`, error) } } get isOfflineEnabled(): boolean { return 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 || crypto.randomUUID(), }), 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: crypto.randomUUID(), }), 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 return withNestedSpan( `executor.persistTransaction`, { 'transaction.id': transaction.id, 'transaction.mutationFnName': transaction.mutationFnName, }, async (span) => { if (!this.isOfflineEnabled || !this.outbox || !this.executor) { span.setAttribute(`result`, `skipped_not_leader`) this.resolveTransaction(transaction.id, undefined) return } try { await this.outbox.add(transaction) await this.executor.execute(transaction) span.setAttribute(`result`, `persisted`) } catch (error) { console.error( `Failed to persist offline transaction ${transaction.id}:`, error, ) span.setAttribute(`result`, `failed`) throw error } }, ) } // Method for OfflineTransaction to wait for completion async waitForTransactionCompletion(transactionId: string): Promise<any> { const existing = this.pendingTransactionPromises.get(transactionId) if (existing) { return existing.promise } 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 { 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 { 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 so recomputeOptimisticState removes it from consideration. // The actual data will come from the sync. restorationTx.setState(`completed`) // Remove from each collection's transaction map and recompute const touchedCollections = new Set<string>() for (const mutation of restorationTx.mutations) { // Defensive check for corrupted deserialized data // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!mutation.collection) { continue } const collectionId = mutation.collection.id if (touchedCollections.has(collectionId)) { continue } touchedCollections.add(collectionId) mutation.collection._state.transactions.delete(restorationTx.id) mutation.collection._state.recomputeOptimisticState(false) } } 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 { 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) }