UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

374 lines (373 loc) • 12.2 kB
import { createTransaction, DuplicateTransactionIdError } from "@tanstack/db"; import { OutboxTransactionNotFoundError } from "../outbox/OutboxManager.js"; import { DefaultRetryPolicy } from "../retry/RetryPolicy.js"; import { NonRetriableError } from "../types.js"; const HANDLED_EXECUTION_ERROR = /* @__PURE__ */ Symbol(`HandledExecutionError`); function toError(value) { return value instanceof Error || Object.prototype.toString.call(value) === `[object Error]` ? value : new Error(String(value)); } class TransactionExecutor { constructor(scheduler, outbox, config, offlineExecutor) { this.isExecuting = false; this.executionPromise = null; this.retryTimer = null; this.fatalError = null; this.scheduler = scheduler; this.outbox = outbox; this.config = config; this.retryPolicy = new DefaultRetryPolicy( Number.POSITIVE_INFINITY, config.jitter ?? true ); this.offlineExecutor = offlineExecutor; } async execute(transaction) { this.assertHealthy(); this.scheduler.schedule(transaction); await this.executeAll(); } async executeAll() { this.assertHealthy(); if (this.isExecuting) { return this.executionPromise; } this.isExecuting = true; this.executionPromise = this.runExecution(); try { await this.executionPromise; } finally { this.isExecuting = false; this.executionPromise = null; this.scheduleNextRetry(); } } async runExecution() { while (this.scheduler.getPendingCount() > 0) { if (!this.canExecute()) { break; } const transaction = this.scheduler.getNext(); if (!transaction) { break; } await this.executeTransaction(transaction); } } async executeTransaction(transaction) { try { const terminalError = transaction.outboxPhase === `rejection-pending` ? this.getTerminalError(transaction) : void 0; this.scheduler.markStarted(transaction); if (!transaction.outboxPhase) { try { await this.runMutationFn(transaction); } catch (error) { const err = toError(error); await this.handleError(transaction, err); err[HANDLED_EXECUTION_ERROR] = true; throw err; } } const deletionPending = transaction.outboxPhase ? transaction : { ...transaction, outboxPhase: `deletion-pending` }; try { await this.removeSettledTransaction( deletionPending, !transaction.outboxPhase ); } catch (error) { const storageError = error; this.offlineExecutor.rejectTransaction( transaction.id, terminalError ?? storageError ); throw storageError; } if (terminalError) { this.offlineExecutor.rejectTransaction(transaction.id, terminalError); } else { this.offlineExecutor.resolveTransaction(transaction.id, void 0); } } catch (error) { if (error !== null && typeof error === `object` && error[HANDLED_EXECUTION_ERROR] === true) { return; } throw error; } } async runMutationFn(transaction) { const mutationFn = this.config.mutationFns[transaction.mutationFnName]; if (!mutationFn) { const errorMessage = `Unknown mutation function: ${transaction.mutationFnName}`; if (this.config.onUnknownMutationFn) { this.config.onUnknownMutationFn(transaction.mutationFnName, transaction); } throw new NonRetriableError(errorMessage); } const transactionWithMutations = { id: transaction.id, mutations: transaction.mutations, metadata: transaction.metadata ?? {} }; await mutationFn({ transaction: transactionWithMutations, idempotencyKey: transaction.idempotencyKey }); } async handleError(transaction, error) { let shouldRetry; let hookFailure; if (error instanceof NonRetriableError) { shouldRetry = false; } else { let decision; try { decision = this.config.shouldRetry?.(error, transaction.retryCount); if (decision !== void 0 && typeof decision !== `boolean`) { void Promise.resolve(decision).catch(() => { }); throw new TypeError( `OfflineConfig.shouldRetry must return true, false, or undefined` ); } } catch (hookError) { hookFailure = toError(hookError); } if (hookFailure) { shouldRetry = false; } else { shouldRetry = decision ?? this.retryPolicy.shouldRetry(error, transaction.retryCount); } } if (!shouldRetry) { const terminalError = hookFailure ?? error; const rejectionPending = { ...transaction, outboxPhase: `rejection-pending`, lastError: { name: terminalError.name, message: terminalError.message, stack: terminalError.stack } }; if (hookFailure) { console.warn( `Retry decision failed for transaction ${transaction.id}:`, hookFailure, `Named mutation function error:`, error ); } else { console.warn(`Transaction ${transaction.id} failed permanently:`, error); } try { await this.removeSettledTransaction(rejectionPending, true); } catch (storageError) { this.offlineExecutor.rejectTransaction(transaction.id, terminalError); throw storageError; } this.offlineExecutor.rejectTransaction(transaction.id, terminalError); return; } const delay = Math.max( 0, this.retryPolicy.calculateDelay(transaction.retryCount) ); const updatedTransaction = { ...transaction, retryCount: transaction.retryCount + 1, nextAttemptAt: Date.now() + delay, lastError: { name: error.name, message: error.message, stack: error.stack } }; this.scheduler.updateTransaction(updatedTransaction); try { await this.outbox.update(transaction.id, updatedTransaction); } catch (persistError) { if (persistError instanceof OutboxTransactionNotFoundError) { this.scheduler.markCompleted(transaction); this.offlineExecutor.rejectTransaction(transaction.id, error); return; } throw persistError; } finally { this.scheduler.markFailed(transaction); } } async removeSettledTransaction(transaction, persistMarker) { try { if (persistMarker) { try { await this.outbox.update(transaction.id, transaction); } catch (error) { if (!(error instanceof OutboxTransactionNotFoundError)) throw error; } } await this.outbox.remove(transaction.id); this.scheduler.markCompleted(transaction); } catch (error) { const storageError = toError(error); this.fatalError = storageError; this.scheduler.markFailed(transaction); this.clearRetryTimer(); throw storageError; } } assertHealthy() { if (this.fatalError) throw this.fatalError; } getTerminalError(transaction) { if (!transaction.lastError) throw new Error(`Terminal transaction ${transaction.id} has no error`); const { name, message, stack } = transaction.lastError; const error = name === `NonRetriableError` ? new NonRetriableError(message) : new Error(message); error.name = name; error.stack = stack; return error; } async loadPendingTransactions() { let removedIds = []; await this.outbox.withAll((transactions) => { const { isOfflineEnabled } = this.offlineExecutor; if (!isOfflineEnabled) return; const providerPending = transactions.filter( (transaction) => !transaction.outboxPhase ); const selectedProvider = this.config.beforeRetry ? this.config.beforeRetry(providerPending) : providerPending; const selectedById = new Map(selectedProvider.map((tx) => [tx.id, tx])); const filteredTransactions = transactions.flatMap((transaction) => { if (transaction.outboxPhase) return [transaction]; const selected = selectedById.get(transaction.id); return selected ? [selected] : []; }); if (!this.offlineExecutor.isOfflineEnabled) return; const newlyLoaded = filteredTransactions.filter( (transaction) => this.scheduler.schedule(transaction) ); removedIds = providerPending.filter(({ id }) => !selectedById.has(id)).map(({ id }) => id); removedIds = this.scheduler.removePendingTransactions(removedIds); this.restoreOptimisticState( newlyLoaded.filter( (transaction) => transaction.outboxPhase !== `rejection-pending` ) ); this.resetRetryDelays(); this.scheduleNextRetry(); }); if (removedIds.length > 0) { const error = new NonRetriableError(`Transaction excluded by beforeRetry`); await Promise.all( removedIds.map(async (id) => { try { await this.outbox.remove(id); this.offlineExecutor.rejectTransaction(id, error); } catch (cleanupError) { console.warn( `Failed to remove transaction excluded by beforeRetry:`, id, cleanupError ); } }) ); } } /** * Restore optimistic state from loaded transactions. * Creates internal transactions to hold the mutations so the collection's * state manager can show optimistic data while waiting for sync. */ restoreOptimisticState(transactions) { for (const offlineTx of transactions) { if (offlineTx.mutations.length === 0) { continue; } try { const restorationTx = createTransaction({ id: offlineTx.id, autoCommit: false, mutationFn: async () => { } }); restorationTx.applyMutations(offlineTx.mutations); try { for (const mutation of offlineTx.mutations) { if (mutation.collection._state.trackTransaction(restorationTx)) mutation.collection._state.recomputeOptimisticState(true); } } catch (error) { restorationTx.rollback({ isSecondaryRollback: true }); throw error; } this.offlineExecutor.registerRestorationTransaction( offlineTx.id, restorationTx ); } catch (error) { if (error instanceof DuplicateTransactionIdError) continue; console.warn( `Failed to restore optimistic state for transaction ${offlineTx.id}:`, error ); } } } clear() { this.scheduler.clear(); this.clearRetryTimer(); } pause() { this.clearRetryTimer(); } getPendingCount() { return this.scheduler.getPendingCount(); } scheduleNextRetry() { this.clearRetryTimer(); if (this.fatalError || !this.canExecute()) { return; } const nextRetryTime = this.getNextRetryTime(); if (nextRetryTime === null) { return; } const delay = Math.max(0, nextRetryTime - Date.now()); this.retryTimer = setTimeout(() => { this.executeAll().catch((error) => { console.warn(`Failed to execute retry batch:`, error); }); }, delay); } getNextRetryTime() { const allTransactions = this.scheduler.getAllPendingTransactions(); if (allTransactions.length === 0) { return null; } return allTransactions[0].nextAttemptAt; } clearRetryTimer() { if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } } canExecute() { return this.offlineExecutor.isOfflineEnabled && this.offlineExecutor.isOnline(); } getRunningCount() { return this.scheduler.getRunningCount(); } resetRetryDelays() { const allTransactions = this.scheduler.getAllPendingTransactions(); const updatedTransactions = allTransactions.map((transaction) => ({ ...transaction, nextAttemptAt: Date.now() })); this.scheduler.updateTransactions(updatedTransactions); } } export { TransactionExecutor }; //# sourceMappingURL=TransactionExecutor.js.map