UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

441 lines (440 loc) 14.6 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const db = require("@tanstack/db"); const IndexedDBAdapter = require("./storage/IndexedDBAdapter.cjs"); const LocalStorageAdapter = require("./storage/LocalStorageAdapter.cjs"); const OutboxManager = require("./outbox/OutboxManager.cjs"); const KeyScheduler = require("./executor/KeyScheduler.cjs"); const TransactionExecutor = require("./executor/TransactionExecutor.cjs"); const WebLocksLeader = require("./coordination/WebLocksLeader.cjs"); const BroadcastChannelLeader = require("./coordination/BroadcastChannelLeader.cjs"); const OnlineDetector = require("./connectivity/OnlineDetector.cjs"); const OfflineTransaction = require("./api/OfflineTransaction.cjs"); const OfflineAction = require("./api/OfflineAction.cjs"); const tracer = require("./telemetry/tracer.cjs"); 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.KeyScheduler(); this.onlineDetector = config.onlineDetector ?? new OnlineDetector.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.IndexedDBAdapter.probe(); if (idbProbe.available) { return { storage: new IndexedDBAdapter.IndexedDBAdapter(), diagnostic: { code: `STORAGE_AVAILABLE`, mode: `offline`, message: `Using IndexedDB for offline storage` } }; } const lsProbe = LocalStorageAdapter.LocalStorageAdapter.probe(); if (lsProbe.available) { return { storage: new LocalStorageAdapter.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.WebLocksLeader.isSupported()) { return new WebLocksLeader.WebLocksLeader(); } else if (BroadcastChannelLeader.BroadcastChannelLeader.isSupported()) { return new BroadcastChannelLeader.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 tracer.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.OutboxManager(storage, this.config.collections); this.executor = new TransactionExecutor.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 db.createTransaction({ autoCommit: options.autoCommit ?? true, mutationFn: (params) => mutationFn({ ...params, idempotencyKey: options.idempotencyKey || crypto.randomUUID() }), metadata: options.metadata }); } return new OfflineTransaction.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 = db.createOptimisticAction({ mutationFn: (vars, params) => mutationFn({ ...vars, ...params, idempotencyKey: crypto.randomUUID() }), onMutate: options.onMutate }); return action2(variables); } const action = OfflineAction.createOfflineAction( options, mutationFn, this.persistTransaction.bind(this), this ); return action(variables); }; } async persistTransaction(transaction) { await this.initPromise; return tracer.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); } exports.OfflineExecutor = OfflineExecutor$1; exports.startOfflineExecutor = startOfflineExecutor; //# sourceMappingURL=OfflineExecutor.cjs.map