@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
441 lines (440 loc) • 14.2 kB
JavaScript
import { createTransaction, createOptimisticAction } from "@tanstack/db";
import { IndexedDBAdapter } from "./storage/IndexedDBAdapter.js";
import { LocalStorageAdapter } from "./storage/LocalStorageAdapter.js";
import { OutboxManager } from "./outbox/OutboxManager.js";
import { KeyScheduler } from "./executor/KeyScheduler.js";
import { TransactionExecutor } from "./executor/TransactionExecutor.js";
import { WebLocksLeader } from "./coordination/WebLocksLeader.js";
import { BroadcastChannelLeader } from "./coordination/BroadcastChannelLeader.js";
import { WebOnlineDetector } from "./connectivity/OnlineDetector.js";
import { OfflineTransaction } from "./api/OfflineTransaction.js";
import { createOfflineAction } from "./api/OfflineAction.js";
import { withSpan, withNestedSpan } from "./telemetry/tracer.js";
let OfflineExecutor$1 = class OfflineExecutor {
constructor(config) {
this.isLeaderState = false;
this.unsubscribeOnline = null;
this.unsubscribeLeadership = null;
this.pendingTransactionPromises = /* @__PURE__ */ new Map();
this.restorationTransactions = /* @__PURE__ */ new Map();
this.config = config;
this.scheduler = new KeyScheduler();
this.onlineDetector = config.onlineDetector ?? new WebOnlineDetector();
this.storage = null;
this.outbox = null;
this.executor = null;
this.leaderElection = null;
this.mode = `offline`;
this.storageDiagnostic = {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Initializing storage...`
};
this.initPromise = new Promise((resolve, reject) => {
this.initResolve = resolve;
this.initReject = reject;
});
this.initialize();
}
/**
* Probe storage availability and create appropriate adapter.
* Returns null if no storage is available (online-only mode).
*/
async createStorage() {
if (this.config.storage) {
return {
storage: this.config.storage,
diagnostic: {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Using custom storage adapter`
}
};
}
const idbProbe = await IndexedDBAdapter.probe();
if (idbProbe.available) {
return {
storage: new IndexedDBAdapter(),
diagnostic: {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Using IndexedDB for offline storage`
}
};
}
const lsProbe = LocalStorageAdapter.probe();
if (lsProbe.available) {
return {
storage: new LocalStorageAdapter(),
diagnostic: {
code: `INDEXEDDB_UNAVAILABLE`,
mode: `offline`,
message: `IndexedDB unavailable, using localStorage fallback`,
error: idbProbe.error
}
};
}
const isSecurityError = idbProbe.error?.name === `SecurityError` || lsProbe.error?.name === `SecurityError`;
const isQuotaError = idbProbe.error?.name === `QuotaExceededError` || lsProbe.error?.name === `QuotaExceededError`;
let code;
let message;
if (isSecurityError) {
code = `STORAGE_BLOCKED`;
message = `Storage blocked (private mode or security restrictions). Running in online-only mode.`;
} else if (isQuotaError) {
code = `QUOTA_EXCEEDED`;
message = `Storage quota exceeded. Running in online-only mode.`;
} else {
code = `UNKNOWN_ERROR`;
message = `Storage unavailable due to unknown error. Running in online-only mode.`;
}
return {
storage: null,
diagnostic: {
code,
mode: `online-only`,
message,
error: idbProbe.error || lsProbe.error
}
};
}
createLeaderElection() {
if (this.config.leaderElection) {
return this.config.leaderElection;
}
if (WebLocksLeader.isSupported()) {
return new WebLocksLeader();
} else if (BroadcastChannelLeader.isSupported()) {
return new BroadcastChannelLeader();
} else {
return {
requestLeadership: () => Promise.resolve(true),
releaseLeadership: () => {
},
isLeader: () => true,
onLeadershipChange: () => () => {
}
};
}
}
setupEventListeners() {
if (this.leaderElection) {
this.unsubscribeLeadership = this.leaderElection.onLeadershipChange(
(isLeader) => {
this.isLeaderState = isLeader;
if (this.config.onLeadershipChange) {
this.config.onLeadershipChange(isLeader);
}
if (isLeader) {
this.loadAndReplayTransactions();
}
}
);
}
this.unsubscribeOnline = this.onlineDetector.subscribe(() => {
if (this.isOfflineEnabled && this.executor) {
this.executor.resetRetryDelays();
if (this.scheduler.getPendingCount() > 0) {
const barrierPromise = this.executor.executeAll();
for (const collection of Object.values(this.config.collections)) {
collection.deferDataRefresh = barrierPromise;
}
barrierPromise.catch((error) => {
console.warn(
`Failed to execute transactions on connectivity change:`,
error
);
}).finally(() => {
for (const collection of Object.values(this.config.collections)) {
if (collection.deferDataRefresh === barrierPromise) {
collection.deferDataRefresh = null;
}
}
});
} else {
this.executor.executeAll().catch((error) => {
console.warn(
`Failed to execute transactions on connectivity change:`,
error
);
});
}
}
});
}
async initialize() {
return withSpan(`executor.initialize`, {}, async (span) => {
try {
const { storage, diagnostic } = await this.createStorage();
this.storage = storage;
this.storageDiagnostic = diagnostic;
this.mode = diagnostic.mode;
span.setAttribute(`storage.mode`, diagnostic.mode);
span.setAttribute(`storage.code`, diagnostic.code);
if (!storage) {
if (this.config.onStorageFailure) {
this.config.onStorageFailure(diagnostic);
}
span.setAttribute(`result`, `online-only`);
this.initResolve();
return;
}
this.outbox = new OutboxManager(storage, this.config.collections);
this.executor = new TransactionExecutor(
this.scheduler,
this.outbox,
this.config,
this
);
this.leaderElection = this.createLeaderElection();
const isLeader = await this.leaderElection.requestLeadership();
this.isLeaderState = isLeader;
span.setAttribute(`isLeader`, isLeader);
this.setupEventListeners();
if (this.config.onLeadershipChange) {
this.config.onLeadershipChange(isLeader);
}
if (isLeader) {
await this.loadAndReplayTransactions();
}
span.setAttribute(`result`, `offline-enabled`);
this.initResolve();
} catch (error) {
console.warn(`Failed to initialize offline executor:`, error);
span.setAttribute(`result`, `failed`);
this.initReject(
error instanceof Error ? error : new Error(String(error))
);
}
});
}
async loadAndReplayTransactions() {
if (!this.executor) {
return;
}
try {
await this.executor.loadPendingTransactions();
this.executor.executeAll().catch((error) => {
console.warn(`Failed to execute transactions:`, error);
});
} catch (error) {
console.warn(`Failed to load and replay transactions:`, error);
}
}
get isOfflineEnabled() {
return this.mode === `offline` && this.isLeaderState;
}
/**
* Wait for the executor to fully initialize.
* This ensures that pending transactions are loaded and optimistic state is restored.
*/
async waitForInit() {
return this.initPromise;
}
createOfflineTransaction(options) {
const mutationFn = this.config.mutationFns[options.mutationFnName];
if (!mutationFn) {
throw new Error(`Unknown mutation function: ${options.mutationFnName}`);
}
if (!this.isOfflineEnabled) {
return createTransaction({
autoCommit: options.autoCommit ?? true,
mutationFn: (params) => mutationFn({
...params,
idempotencyKey: options.idempotencyKey || crypto.randomUUID()
}),
metadata: options.metadata
});
}
return new OfflineTransaction(
options,
mutationFn,
this.persistTransaction.bind(this),
this
);
}
createOfflineAction(options) {
const mutationFn = this.config.mutationFns[options.mutationFnName];
if (!mutationFn) {
throw new Error(`Unknown mutation function: ${options.mutationFnName}`);
}
return (variables) => {
if (!this.isOfflineEnabled) {
const action2 = createOptimisticAction({
mutationFn: (vars, params) => mutationFn({
...vars,
...params,
idempotencyKey: crypto.randomUUID()
}),
onMutate: options.onMutate
});
return action2(variables);
}
const action = createOfflineAction(
options,
mutationFn,
this.persistTransaction.bind(this),
this
);
return action(variables);
};
}
async persistTransaction(transaction) {
await this.initPromise;
return withNestedSpan(
`executor.persistTransaction`,
{
"transaction.id": transaction.id,
"transaction.mutationFnName": transaction.mutationFnName
},
async (span) => {
if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
span.setAttribute(`result`, `skipped_not_leader`);
this.resolveTransaction(transaction.id, void 0);
return;
}
try {
await this.outbox.add(transaction);
await this.executor.execute(transaction);
span.setAttribute(`result`, `persisted`);
} catch (error) {
console.error(
`Failed to persist offline transaction ${transaction.id}:`,
error
);
span.setAttribute(`result`, `failed`);
throw error;
}
}
);
}
// Method for OfflineTransaction to wait for completion
async waitForTransactionCompletion(transactionId) {
const existing = this.pendingTransactionPromises.get(transactionId);
if (existing) {
return existing.promise;
}
const deferred = {};
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
this.pendingTransactionPromises.set(transactionId, deferred);
return deferred.promise;
}
// Method for TransactionExecutor to signal completion
resolveTransaction(transactionId, result) {
const deferred = this.pendingTransactionPromises.get(transactionId);
if (deferred) {
deferred.resolve(result);
this.pendingTransactionPromises.delete(transactionId);
}
this.cleanupRestorationTransaction(transactionId);
}
// Method for TransactionExecutor to signal failure
rejectTransaction(transactionId, error) {
const deferred = this.pendingTransactionPromises.get(transactionId);
if (deferred) {
deferred.reject(error);
this.pendingTransactionPromises.delete(transactionId);
}
this.cleanupRestorationTransaction(transactionId, true);
}
// Method for TransactionExecutor to register restoration transactions
registerRestorationTransaction(offlineTransactionId, restorationTransaction) {
this.restorationTransactions.set(
offlineTransactionId,
restorationTransaction
);
}
cleanupRestorationTransaction(transactionId, shouldRollback = false) {
const restorationTx = this.restorationTransactions.get(transactionId);
if (!restorationTx) {
return;
}
this.restorationTransactions.delete(transactionId);
if (shouldRollback) {
restorationTx.rollback();
return;
}
restorationTx.setState(`completed`);
const touchedCollections = /* @__PURE__ */ new Set();
for (const mutation of restorationTx.mutations) {
if (!mutation.collection) {
continue;
}
const collectionId = mutation.collection.id;
if (touchedCollections.has(collectionId)) {
continue;
}
touchedCollections.add(collectionId);
mutation.collection._state.transactions.delete(restorationTx.id);
mutation.collection._state.recomputeOptimisticState(false);
}
}
async removeFromOutbox(id) {
if (!this.outbox) {
return;
}
await this.outbox.remove(id);
}
async peekOutbox() {
if (!this.outbox) {
return [];
}
return this.outbox.getAll();
}
async clearOutbox() {
if (!this.outbox || !this.executor) {
return;
}
await this.outbox.clear();
this.executor.clear();
}
getPendingCount() {
if (!this.executor) {
return 0;
}
return this.executor.getPendingCount();
}
getRunningCount() {
if (!this.executor) {
return 0;
}
return this.executor.getRunningCount();
}
getOnlineDetector() {
return this.onlineDetector;
}
isOnline() {
return this.onlineDetector.isOnline();
}
dispose() {
for (const collection of Object.values(this.config.collections)) {
collection.deferDataRefresh = null;
}
if (this.unsubscribeOnline) {
this.unsubscribeOnline();
this.unsubscribeOnline = null;
}
if (this.unsubscribeLeadership) {
this.unsubscribeLeadership();
this.unsubscribeLeadership = null;
}
if (this.leaderElection) {
this.leaderElection.releaseLeadership();
if (`dispose` in this.leaderElection) {
this.leaderElection.dispose();
}
}
this.onlineDetector.dispose();
}
};
function startOfflineExecutor(config) {
return new OfflineExecutor$1(config);
}
export {
OfflineExecutor$1 as OfflineExecutor,
startOfflineExecutor
};
//# sourceMappingURL=OfflineExecutor.js.map