UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

283 lines (282 loc) 9.25 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const db = require("@tanstack/db"); const RetryPolicy = require("../retry/RetryPolicy.cjs"); const types = require("../types.cjs"); const tracer = require("../telemetry/tracer.cjs"); const HANDLED_EXECUTION_ERROR = /* @__PURE__ */ Symbol(`HandledExecutionError`); class TransactionExecutor { constructor(scheduler, outbox, config, offlineExecutor) { this.isExecuting = false; this.executionPromise = null; this.retryTimer = null; this.scheduler = scheduler; this.outbox = outbox; this.config = config; this.retryPolicy = new RetryPolicy.DefaultRetryPolicy( Number.POSITIVE_INFINITY, config.jitter ?? true ); this.offlineExecutor = offlineExecutor; } async execute(transaction) { this.scheduler.schedule(transaction); await this.executeAll(); } async executeAll() { if (this.isExecuting) { return this.executionPromise; } this.isExecuting = true; this.executionPromise = this.runExecution(); try { await this.executionPromise; } finally { this.isExecuting = false; this.executionPromise = null; } } async runExecution() { while (this.scheduler.getPendingCount() > 0) { if (!this.isOnline()) { break; } const transaction = this.scheduler.getNext(); if (!transaction) { break; } await this.executeTransaction(transaction); } this.scheduleNextRetry(); } async executeTransaction(transaction) { try { await tracer.withNestedSpan( `transaction.execute`, { "transaction.id": transaction.id, "transaction.mutationFnName": transaction.mutationFnName, "transaction.retryCount": transaction.retryCount, "transaction.keyCount": transaction.keys.length }, async (span) => { this.scheduler.markStarted(transaction); if (transaction.retryCount > 0) { span.setAttribute(`retry.attempt`, transaction.retryCount); } try { const result = await this.runMutationFn(transaction); this.scheduler.markCompleted(transaction); await this.outbox.remove(transaction.id); span.setAttribute(`result`, `success`); this.offlineExecutor.resolveTransaction(transaction.id, result); } 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; } } ); } 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 types.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 tracer.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) { this.scheduler.markCompleted(transaction); await this.outbox.remove(transaction.id); console.warn( `Transaction ${transaction.id} failed permanently:`, error ); 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.markFailed(transaction); 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; } this.scheduleNextRetry(); } ); } async loadPendingTransactions() { const transactions = await this.outbox.getAll(); let filteredTransactions = transactions; if (this.config.beforeRetry) { filteredTransactions = this.config.beforeRetry(transactions); } for (const transaction of filteredTransactions) { this.scheduler.schedule(transaction); } this.restoreOptimisticState(filteredTransactions); this.resetRetryDelays(); this.scheduleNextRetry(); const removedTransactions = transactions.filter( (tx) => !filteredTransactions.some((filtered) => filtered.id === tx.id) ); if (removedTransactions.length > 0) { await this.outbox.removeMany(removedTransactions.map((tx) => tx.id)); } } /** * 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 = db.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(); } getPendingCount() { return this.scheduler.getPendingCount(); } scheduleNextRetry() { this.clearRetryTimer(); if (!this.isOnline()) { return; } const earliestRetryTime = this.getEarliestRetryTime(); if (earliestRetryTime === null) { return; } const delay = Math.max(0, earliestRetryTime - Date.now()); this.retryTimer = setTimeout(() => { this.executeAll().catch((error) => { console.warn(`Failed to execute retry batch:`, error); }); }, delay); } getEarliestRetryTime() { const allTransactions = this.scheduler.getAllPendingTransactions(); if (allTransactions.length === 0) { return null; } return Math.min(...allTransactions.map((tx) => tx.nextAttemptAt)); } clearRetryTimer() { if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } } isOnline() { return 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); } } exports.TransactionExecutor = TransactionExecutor; //# sourceMappingURL=TransactionExecutor.cjs.map