@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
374 lines (373 loc) • 12.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const db = require("@tanstack/db");
const OutboxManager = require("../outbox/OutboxManager.cjs");
const RetryPolicy = require("../retry/RetryPolicy.cjs");
const types = require("../types.cjs");
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 RetryPolicy.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 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) {
let shouldRetry;
let hookFailure;
if (error instanceof types.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 OutboxManager.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 OutboxManager.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 types.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 types.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 = db.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 db.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);
}
}
exports.TransactionExecutor = TransactionExecutor;
//# sourceMappingURL=TransactionExecutor.cjs.map