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