357 lines
10 KiB
Markdown
357 lines
10 KiB
Markdown
---
|
|
name: offline
|
|
description: >
|
|
Offline transaction support for TanStack DB. OfflineExecutor orchestrates
|
|
persistent outbox (IndexedDB/localStorage), leader election (WebLocks/
|
|
BroadcastChannel), retry with backoff, and connectivity detection.
|
|
createOfflineTransaction/createOfflineAction wrap TanStack DB primitives
|
|
with offline persistence. Idempotency keys for at-least-once delivery.
|
|
Graceful degradation to online-only mode when storage unavailable.
|
|
React Native support via separate entry point.
|
|
type: composition
|
|
library: db
|
|
library_version: '0.6.0'
|
|
requires:
|
|
- db-core
|
|
- db-core/mutations-optimistic
|
|
sources:
|
|
- 'TanStack/db:packages/offline-transactions/src/OfflineExecutor.ts'
|
|
- 'TanStack/db:packages/offline-transactions/src/types.ts'
|
|
- 'TanStack/db:packages/offline-transactions/src/index.ts'
|
|
---
|
|
|
|
This skill builds on db-core and mutations-optimistic. Read those first.
|
|
|
|
# TanStack DB — Offline Transactions
|
|
|
|
## Setup
|
|
|
|
```ts
|
|
import {
|
|
startOfflineExecutor,
|
|
IndexedDBAdapter,
|
|
} from '@tanstack/offline-transactions'
|
|
import { todoCollection } from './collections'
|
|
|
|
const executor = startOfflineExecutor({
|
|
collections: { todos: todoCollection },
|
|
mutationFns: {
|
|
createTodo: async ({ transaction, idempotencyKey }) => {
|
|
const mutation = transaction.mutations[0]
|
|
await api.todos.create({
|
|
...mutation.modified,
|
|
idempotencyKey,
|
|
})
|
|
},
|
|
updateTodo: async ({ transaction, idempotencyKey }) => {
|
|
const mutation = transaction.mutations[0]
|
|
await api.todos.update(mutation.key, {
|
|
...mutation.changes,
|
|
idempotencyKey,
|
|
})
|
|
},
|
|
},
|
|
})
|
|
|
|
// Wait for initialization (storage probe, leader election, outbox replay)
|
|
await executor.waitForInit()
|
|
```
|
|
|
|
## Core API
|
|
|
|
### createOfflineTransaction
|
|
|
|
```ts
|
|
const tx = executor.createOfflineTransaction({
|
|
mutationFnName: 'createTodo',
|
|
})
|
|
|
|
// Mutations run inside tx.mutate() — uses ambient transaction context
|
|
tx.mutate(() => {
|
|
todoCollection.insert({ id: crypto.randomUUID(), text: 'New todo' })
|
|
})
|
|
tx.commit()
|
|
```
|
|
|
|
If the executor is not the leader tab, falls back to `createTransaction` directly (no offline persistence).
|
|
|
|
### createOfflineAction
|
|
|
|
```ts
|
|
const addTodo = executor.createOfflineAction({
|
|
mutationFnName: 'createTodo',
|
|
onMutate: (variables) => {
|
|
todoCollection.insert({
|
|
id: crypto.randomUUID(),
|
|
text: variables.text,
|
|
})
|
|
},
|
|
})
|
|
|
|
// Call it
|
|
addTodo({ text: 'Buy milk' })
|
|
```
|
|
|
|
If the executor is not the leader tab, falls back to `createOptimisticAction` directly.
|
|
|
|
## Architecture
|
|
|
|
### Components
|
|
|
|
| Component | Purpose | Default |
|
|
| ----------------------- | ------------------------------------------- | --------------------------------- |
|
|
| **Storage** | Persist transactions to survive page reload | IndexedDB → localStorage fallback |
|
|
| **OutboxManager** | FIFO queue of pending transactions | Automatic |
|
|
| **KeyScheduler** | Serialize transactions touching same keys | Automatic |
|
|
| **TransactionExecutor** | Execute with retry + backoff | Automatic |
|
|
| **LeaderElection** | Only one tab processes the outbox | WebLocks → BroadcastChannel |
|
|
| **OnlineDetector** | Pause/resume on connectivity changes | navigator.onLine + events |
|
|
|
|
### Transaction lifecycle
|
|
|
|
1. Mutation applied optimistically to collection (instant UI update)
|
|
2. Transaction serialized and persisted to storage (outbox)
|
|
3. Leader tab picks up transaction and executes `mutationFn`
|
|
4. On success: removed from outbox, optimistic state resolved
|
|
5. On failure: retried with exponential backoff
|
|
6. On page reload: outbox replayed, optimistic state restored
|
|
|
|
### Leader election
|
|
|
|
Only one tab processes the outbox to prevent duplicate execution. Non-leader tabs use regular `createTransaction`/`createOptimisticAction` (online-only, no persistence).
|
|
|
|
```ts
|
|
const executor = startOfflineExecutor({
|
|
// ...
|
|
onLeadershipChange: (isLeader) => {
|
|
console.log(
|
|
isLeader
|
|
? 'This tab is processing offline transactions'
|
|
: 'Another tab is leader',
|
|
)
|
|
},
|
|
})
|
|
|
|
executor.isOfflineEnabled // true only if leader AND storage available
|
|
```
|
|
|
|
### Storage degradation
|
|
|
|
The executor probes storage availability on startup:
|
|
|
|
```ts
|
|
const executor = startOfflineExecutor({
|
|
// ...
|
|
onStorageFailure: (diagnostic) => {
|
|
// diagnostic.code: 'STORAGE_BLOCKED' | 'QUOTA_EXCEEDED' | 'UNKNOWN_ERROR'
|
|
// diagnostic.mode: 'online-only'
|
|
console.warn(diagnostic.message)
|
|
},
|
|
})
|
|
|
|
executor.mode // 'offline' | 'online-only'
|
|
executor.storageDiagnostic // Full diagnostic info
|
|
```
|
|
|
|
When storage is unavailable (private browsing, quota exceeded), the executor operates in online-only mode — mutations work normally but aren't persisted across page reloads.
|
|
|
|
## Configuration
|
|
|
|
```ts
|
|
interface OfflineConfig {
|
|
collections: Record<string, Collection> // Collections for optimistic state restoration
|
|
mutationFns: Record<string, OfflineMutationFn> // Named mutation functions
|
|
storage?: StorageAdapter // Custom storage (default: auto-detect)
|
|
maxConcurrency?: number // Parallel execution limit
|
|
jitter?: boolean // Add jitter to retry delays
|
|
beforeRetry?: (txs) => txs // Transform/filter before retry
|
|
onUnknownMutationFn?: (name, tx) => void // Handle orphaned transactions
|
|
onLeadershipChange?: (isLeader) => void // Leadership state callback
|
|
onStorageFailure?: (diagnostic) => void // Storage probe failure callback
|
|
leaderElection?: LeaderElection // Custom leader election
|
|
onlineDetector?: OnlineDetector // Custom connectivity detection
|
|
}
|
|
```
|
|
|
|
### Custom storage adapter
|
|
|
|
```ts
|
|
interface StorageAdapter {
|
|
get: (key: string) => Promise<string | null>
|
|
set: (key: string, value: string) => Promise<void>
|
|
delete: (key: string) => Promise<void>
|
|
keys: () => Promise<Array<string>>
|
|
clear: () => Promise<void>
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### NonRetriableError
|
|
|
|
```ts
|
|
import { NonRetriableError } from '@tanstack/offline-transactions'
|
|
|
|
const executor = startOfflineExecutor({
|
|
mutationFns: {
|
|
createTodo: async ({ transaction, idempotencyKey }) => {
|
|
const res = await fetch('/api/todos', { method: 'POST', body: ... })
|
|
if (res.status === 409) {
|
|
throw new NonRetriableError('Duplicate detected')
|
|
}
|
|
if (!res.ok) throw new Error('Server error')
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
Throwing `NonRetriableError` stops retry and removes the transaction from the outbox. Use for permanent failures (validation errors, conflicts, 4xx responses).
|
|
|
|
### Idempotency keys
|
|
|
|
Every offline transaction includes an `idempotencyKey`. Pass it to your API to prevent duplicate execution on retry:
|
|
|
|
```ts
|
|
mutationFns: {
|
|
createTodo: async ({ transaction, idempotencyKey }) => {
|
|
await fetch('/api/todos', {
|
|
method: 'POST',
|
|
headers: { 'Idempotency-Key': idempotencyKey },
|
|
body: JSON.stringify(transaction.mutations[0].modified),
|
|
})
|
|
},
|
|
}
|
|
```
|
|
|
|
## React Native
|
|
|
|
```ts
|
|
import {
|
|
startOfflineExecutor,
|
|
} from '@tanstack/offline-transactions/react-native'
|
|
|
|
// Uses ReactNativeOnlineDetector automatically
|
|
// Uses AsyncStorage-compatible storage
|
|
const executor = startOfflineExecutor({ ... })
|
|
```
|
|
|
|
## Outbox Management
|
|
|
|
```ts
|
|
// Inspect pending transactions
|
|
const pending = await executor.peekOutbox()
|
|
|
|
// Get counts
|
|
executor.getPendingCount() // Queued transactions
|
|
executor.getRunningCount() // Currently executing
|
|
|
|
// Clear all pending transactions
|
|
await executor.clearOutbox()
|
|
|
|
// Cleanup
|
|
executor.dispose()
|
|
```
|
|
|
|
## Common Mistakes
|
|
|
|
### CRITICAL Not passing idempotencyKey to the API
|
|
|
|
Wrong:
|
|
|
|
```ts
|
|
mutationFns: {
|
|
createTodo: async ({ transaction }) => {
|
|
await api.todos.create(transaction.mutations[0].modified)
|
|
},
|
|
}
|
|
```
|
|
|
|
Correct:
|
|
|
|
```ts
|
|
mutationFns: {
|
|
createTodo: async ({ transaction, idempotencyKey }) => {
|
|
await api.todos.create({
|
|
...transaction.mutations[0].modified,
|
|
idempotencyKey,
|
|
})
|
|
},
|
|
}
|
|
```
|
|
|
|
Offline transactions retry on failure. Without idempotency keys, retries can create duplicate records on the server.
|
|
|
|
### HIGH Not waiting for initialization
|
|
|
|
Wrong:
|
|
|
|
```ts
|
|
const executor = startOfflineExecutor({ ... })
|
|
const tx = executor.createOfflineTransaction({ mutationFnName: 'createTodo' })
|
|
```
|
|
|
|
Correct:
|
|
|
|
```ts
|
|
const executor = startOfflineExecutor({ ... })
|
|
await executor.waitForInit()
|
|
const tx = executor.createOfflineTransaction({ mutationFnName: 'createTodo' })
|
|
```
|
|
|
|
`startOfflineExecutor` initializes asynchronously (probes storage, requests leadership, replays outbox). Creating transactions before initialization completes may miss the leader election result and use the wrong code path.
|
|
|
|
### HIGH Missing collection in collections map
|
|
|
|
Wrong:
|
|
|
|
```ts
|
|
const executor = startOfflineExecutor({
|
|
collections: {},
|
|
mutationFns: { createTodo: ... },
|
|
})
|
|
```
|
|
|
|
Correct:
|
|
|
|
```ts
|
|
const executor = startOfflineExecutor({
|
|
collections: { todos: todoCollection },
|
|
mutationFns: { createTodo: ... },
|
|
})
|
|
```
|
|
|
|
The `collections` map is used to restore optimistic state from the outbox on page reload. Without it, previously pending mutations won't show their optimistic state while being replayed.
|
|
|
|
### MEDIUM Not handling NonRetriableError for permanent failures
|
|
|
|
Wrong:
|
|
|
|
```ts
|
|
mutationFns: {
|
|
createTodo: async ({ transaction }) => {
|
|
const res = await fetch('/api/todos', { ... })
|
|
if (!res.ok) throw new Error('Failed')
|
|
},
|
|
}
|
|
```
|
|
|
|
Correct:
|
|
|
|
```ts
|
|
mutationFns: {
|
|
createTodo: async ({ transaction }) => {
|
|
const res = await fetch('/api/todos', { ... })
|
|
if (res.status >= 400 && res.status < 500) {
|
|
throw new NonRetriableError(`Client error: ${res.status}`)
|
|
}
|
|
if (!res.ok) throw new Error('Server error')
|
|
},
|
|
}
|
|
```
|
|
|
|
Without distinguishing retriable from permanent errors, 4xx responses (validation, auth, not found) will retry forever until max retries, wasting resources and filling logs.
|
|
|
|
See also: db-core/mutations-optimistic/SKILL.md — for the underlying mutation primitives.
|
|
|
|
See also: db-core/collection-setup/SKILL.md — for setting up collections used with offline transactions.
|