@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
624 lines (537 loc) • 18.6 kB
text/typescript
// Storage adapters
import { createOptimisticAction, createTransaction } from '@tanstack/db'
import { IndexedDBAdapter } from './storage/IndexedDBAdapter'
import { LocalStorageAdapter } from './storage/LocalStorageAdapter'
// Core components
import { OutboxManager } from './outbox/OutboxManager'
import { KeyScheduler } from './executor/KeyScheduler'
import { TransactionExecutor } from './executor/TransactionExecutor'
// Coordination
import { WebLocksLeader } from './coordination/WebLocksLeader'
import { BroadcastChannelLeader } from './coordination/BroadcastChannelLeader'
// Connectivity
import { WebOnlineDetector } from './connectivity/OnlineDetector'
// API
import { OfflineTransaction as OfflineTransactionAPI } from './api/OfflineTransaction'
import { createOfflineAction } from './api/OfflineAction'
// TanStack DB primitives
// Replay
import { withNestedSpan, withSpan } from './telemetry/tracer'
import type {
CreateOfflineActionOptions,
CreateOfflineTransactionOptions,
LeaderElection,
OfflineConfig,
OfflineMode,
OfflineTransaction,
OnlineDetector,
StorageAdapter,
StorageDiagnostic,
} from './types'
import type { Transaction } from '@tanstack/db'
export class OfflineExecutor {
private config: OfflineConfig
// @ts-expect-error - Set during async initialization in initialize()
private storage: StorageAdapter | null
private outbox: OutboxManager | null
private scheduler: KeyScheduler
private executor: TransactionExecutor | null
private leaderElection: LeaderElection | null
private onlineDetector: OnlineDetector
private isLeaderState = false
private unsubscribeOnline: (() => void) | null = null
private unsubscribeLeadership: (() => void) | null = null
// Public diagnostic properties
public readonly mode: OfflineMode
public readonly storageDiagnostic: StorageDiagnostic
// Track initialization completion
private initPromise: Promise<void>
private initResolve!: () => void
private initReject!: (error: Error) => void
// Coordination mechanism for blocking transactions
private pendingTransactionPromises: Map<
string,
{
promise: Promise<any>
resolve: (result: any) => void
reject: (error: Error) => void
}
> = new Map()
// Track restoration transactions for cleanup when offline transactions complete
private restorationTransactions: Map<string, Transaction> = new Map()
constructor(config: OfflineConfig) {
this.config = config
this.scheduler = new KeyScheduler()
this.onlineDetector = config.onlineDetector ?? new WebOnlineDetector()
// Initialize as pending - will be set by async initialization
this.storage = null
this.outbox = null
this.executor = null
this.leaderElection = null
// Temporary diagnostic - will be updated by async initialization
this.mode = `offline`
this.storageDiagnostic = {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Initializing storage...`,
}
// Create initialization promise
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).
*/
private async createStorage(): Promise<{
storage: StorageAdapter | null
diagnostic: StorageDiagnostic
}> {
// If user provided custom storage, use it without probing
if (this.config.storage) {
return {
storage: this.config.storage,
diagnostic: {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Using custom storage adapter`,
},
}
}
// Probe IndexedDB first
const idbProbe = await IndexedDBAdapter.probe()
if (idbProbe.available) {
return {
storage: new IndexedDBAdapter(),
diagnostic: {
code: `STORAGE_AVAILABLE`,
mode: `offline`,
message: `Using IndexedDB for offline storage`,
},
}
}
// IndexedDB failed, try localStorage
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,
},
}
}
// Both failed - determine the diagnostic code
const isSecurityError =
idbProbe.error?.name === `SecurityError` ||
lsProbe.error?.name === `SecurityError`
const isQuotaError =
idbProbe.error?.name === `QuotaExceededError` ||
lsProbe.error?.name === `QuotaExceededError`
let code: StorageDiagnostic[`code`]
let message: string
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,
},
}
}
private createLeaderElection(): LeaderElection {
if (this.config.leaderElection) {
return this.config.leaderElection
}
if (WebLocksLeader.isSupported()) {
return new WebLocksLeader()
} else if (BroadcastChannelLeader.isSupported()) {
return new BroadcastChannelLeader()
} else {
// Fallback: always be leader in environments without multi-tab support
return {
requestLeadership: () => Promise.resolve(true),
releaseLeadership: () => {},
isLeader: () => true,
onLeadershipChange: () => () => {},
}
}
}
private setupEventListeners(): void {
// Only set up leader election listeners if we have storage
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,
)
})
}
}
})
}
private async initialize(): Promise<void> {
return withSpan(`executor.initialize`, {}, async (span) => {
try {
// Probe storage and create adapter
const { storage, diagnostic } = await this.createStorage()
// Cast to writable to set readonly properties
;(this as any).storage = storage
;(this as any).storageDiagnostic = diagnostic
;(this as any).mode = diagnostic.mode
span.setAttribute(`storage.mode`, diagnostic.mode)
span.setAttribute(`storage.code`, diagnostic.code)
if (!storage) {
// Online-only mode - notify callback and skip offline setup
if (this.config.onStorageFailure) {
this.config.onStorageFailure(diagnostic)
}
span.setAttribute(`result`, `online-only`)
this.initResolve()
return
}
// Storage available - set up offline components
this.outbox = new OutboxManager(storage, this.config.collections)
this.executor = new TransactionExecutor(
this.scheduler,
this.outbox,
this.config,
this,
)
this.leaderElection = this.createLeaderElection()
// Request leadership first
const isLeader = await this.leaderElection.requestLeadership()
this.isLeaderState = isLeader
span.setAttribute(`isLeader`, isLeader)
// Set up event listeners after leadership is established
// This prevents the callback from being called multiple times
this.setupEventListeners()
// Notify initial leadership state
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)),
)
}
})
}
private async loadAndReplayTransactions(): Promise<void> {
if (!this.executor) {
return
}
try {
// Load pending transactions and restore optimistic state
await this.executor.loadPendingTransactions()
// Start execution in the background - don't await to avoid blocking initialization
// The transactions will execute and complete asynchronously
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(): boolean {
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(): Promise<void> {
return this.initPromise
}
createOfflineTransaction(
options: CreateOfflineTransactionOptions,
): Transaction | OfflineTransactionAPI {
const mutationFn = this.config.mutationFns[options.mutationFnName]
if (!mutationFn) {
throw new Error(`Unknown mutation function: ${options.mutationFnName}`)
}
// Check leadership immediately and use the appropriate primitive
if (!this.isOfflineEnabled) {
// Non-leader: use createTransaction directly with the resolved mutation function
// We need to wrap it to add the idempotency key
return createTransaction({
autoCommit: options.autoCommit ?? true,
mutationFn: (params) =>
mutationFn({
...params,
idempotencyKey: options.idempotencyKey || crypto.randomUUID(),
}),
metadata: options.metadata,
})
}
// Leader: use OfflineTransaction wrapper for offline persistence
return new OfflineTransactionAPI(
options,
mutationFn,
this.persistTransaction.bind(this),
this,
)
}
createOfflineAction<T>(options: CreateOfflineActionOptions<T>) {
const mutationFn = this.config.mutationFns[options.mutationFnName]
if (!mutationFn) {
throw new Error(`Unknown mutation function: ${options.mutationFnName}`)
}
// Return a wrapper that checks leadership status at call time
return (variables: T) => {
// Check leadership when action is called, not when it's created
if (!this.isOfflineEnabled) {
// Non-leader: use createOptimisticAction directly
const action = createOptimisticAction({
mutationFn: (vars, params) =>
mutationFn({
...vars,
...params,
idempotencyKey: crypto.randomUUID(),
}),
onMutate: options.onMutate,
})
return action(variables)
}
// Leader: use the offline action wrapper
const action = createOfflineAction(
options,
mutationFn,
this.persistTransaction.bind(this),
this,
)
return action(variables)
}
}
private async persistTransaction(
transaction: OfflineTransaction,
): Promise<void> {
// Wait for initialization to complete
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, undefined)
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: string): Promise<any> {
const existing = this.pendingTransactionPromises.get(transactionId)
if (existing) {
return existing.promise
}
const deferred: {
promise: Promise<any>
resolve: (result: any) => void
reject: (error: Error) => void
} = {} as any
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: string, result: any): void {
const deferred = this.pendingTransactionPromises.get(transactionId)
if (deferred) {
deferred.resolve(result)
this.pendingTransactionPromises.delete(transactionId)
}
// Clean up the restoration transaction - the sync will provide authoritative data
this.cleanupRestorationTransaction(transactionId)
}
// Method for TransactionExecutor to signal failure
rejectTransaction(transactionId: string, error: Error): void {
const deferred = this.pendingTransactionPromises.get(transactionId)
if (deferred) {
deferred.reject(error)
this.pendingTransactionPromises.delete(transactionId)
}
// Clean up the restoration transaction and rollback optimistic state
this.cleanupRestorationTransaction(transactionId, true)
}
// Method for TransactionExecutor to register restoration transactions
registerRestorationTransaction(
offlineTransactionId: string,
restorationTransaction: Transaction,
): void {
this.restorationTransactions.set(
offlineTransactionId,
restorationTransaction,
)
}
private cleanupRestorationTransaction(
transactionId: string,
shouldRollback = false,
): void {
const restorationTx = this.restorationTransactions.get(transactionId)
if (!restorationTx) {
return
}
this.restorationTransactions.delete(transactionId)
if (shouldRollback) {
restorationTx.rollback()
return
}
// Mark as completed so recomputeOptimisticState removes it from consideration.
// The actual data will come from the sync.
restorationTx.setState(`completed`)
// Remove from each collection's transaction map and recompute
const touchedCollections = new Set<string>()
for (const mutation of restorationTx.mutations) {
// Defensive check for corrupted deserialized data
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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: string): Promise<void> {
if (!this.outbox) {
return
}
await this.outbox.remove(id)
}
async peekOutbox(): Promise<Array<OfflineTransaction>> {
if (!this.outbox) {
return []
}
return this.outbox.getAll()
}
async clearOutbox(): Promise<void> {
if (!this.outbox || !this.executor) {
return
}
await this.outbox.clear()
this.executor.clear()
}
getPendingCount(): number {
if (!this.executor) {
return 0
}
return this.executor.getPendingCount()
}
getRunningCount(): number {
if (!this.executor) {
return 0
}
return this.executor.getRunningCount()
}
getOnlineDetector(): OnlineDetector {
return this.onlineDetector
}
isOnline(): boolean {
return this.onlineDetector.isOnline()
}
dispose(): void {
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 as any).dispose()
}
}
this.onlineDetector.dispose()
}
}
export function startOfflineExecutor(config: OfflineConfig): OfflineExecutor {
return new OfflineExecutor(config)
}