@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
389 lines (388 loc) • 13.1 kB
JavaScript
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