UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

389 lines (388 loc) • 13.1 kB
import { createTransaction } from "@tanstack/db"; import { OutboxTransactionNotFoundError } from "../outbox/OutboxManager.js"; import { DefaultRetryPolicy } from "../retry/RetryPolicy.js"; import { NonRetriableError } from "../types.js"; import { withNestedSpan } from "../telemetry/tracer.js"; const HANDLED_EXECUTION_ERROR = /* @__PURE__ */ Symbol(`HandledExecutionError`); 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 { await withNestedSpan( `transaction.execute`, { "transaction.id": transaction.id, "transaction.mutationFnName": transaction.mutationFnName, "transaction.retryCount": transaction.retryCount, "transaction.keyCount": transaction.keys.length }, async (span) => { const terminalError = transaction.outboxPhase === `rejection-pending` ? this.getTerminalError(transaction) : void 0; this.scheduler.markStarted(transaction); if (transaction.retryCount > 0) { span.setAttribute(`retry.attempt`, transaction.retryCount); } if (!transaction.outboxPhase) { try { await this.runMutationFn(transaction); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); span.setAttribute(`result`, `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; span.recordException(storageError); span.setAttribute(`result`, `outbox_failure`); this.offlineExecutor.rejectTransaction( transaction.id, terminalError ?? storageError ); throw storageError; } if (terminalError) { span.setAttribute(`result`, `permanent_failure`); this.offlineExecutor.rejectTransaction( transaction.id, terminalError ); } else { span.setAttribute(`result`, `success`); this.offlineExecutor.resolveTransaction(transaction.id, void 0); } } ); } catch (error) { if (error instanceof Error && 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) { return withNestedSpan( `transaction.handleError`, { "transaction.id": transaction.id, "error.name": error.name, "error.message": error.message }, async (span) => { const shouldRetry = this.retryPolicy.shouldRetry( error, transaction.retryCount ); span.setAttribute(`shouldRetry`, shouldRetry); if (!shouldRetry) { const rejectionPending = { ...transaction, outboxPhase: `rejection-pending`, lastError: { name: error.name, message: error.message, stack: error.stack } }; console.warn( `Transaction ${transaction.id} failed permanently:`, error ); try { await this.removeSettledTransaction(rejectionPending, true); } catch (storageError) { span.recordException(storageError); span.setAttribute(`result`, `outbox_failure`); this.offlineExecutor.rejectTransaction(transaction.id, error); throw storageError; } span.setAttribute(`result`, `permanent_failure`); this.offlineExecutor.rejectTransaction(transaction.id, error); 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 } }; span.setAttribute(`retryDelay`, delay); span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount); this.scheduler.updateTransaction(updatedTransaction); try { await this.outbox.update(transaction.id, updatedTransaction); span.setAttribute(`result`, `scheduled_retry`); } catch (persistError) { span.recordException(persistError); span.setAttribute(`result`, `persist_failed`); 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 = error instanceof Error ? error : new Error(String(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.isPersisted.promise.catch(() => { }); restorationTx.applyMutations(offlineTx.mutations); const touchedCollections = /* @__PURE__ */ new Set(); for (const mutation of offlineTx.mutations) { if (!mutation.collection) { continue; } const collectionId = mutation.collection.id; if (touchedCollections.has(collectionId)) { continue; } touchedCollections.add(collectionId); mutation.collection._state.transactions.set( restorationTx.id, restorationTx ); mutation.collection._state.recomputeOptimisticState(true); } this.offlineExecutor.registerRestorationTransaction( offlineTx.id, restorationTx ); } catch (error) { 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