@tanstack/offline-transactions 1.0.20 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +2 -17
  2. package/dist/cjs/OfflineExecutor.cjs +3 -3
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/OfflineExecutor.d.cts +1 -1
  5. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +13 -1
  6. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
  7. package/dist/cjs/connectivity/ReactNativeOnlineDetector.d.cts +2 -0
  8. package/dist/cjs/executor/TransactionExecutor.cjs +17 -2
  9. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  10. package/dist/cjs/executor/TransactionExecutor.d.cts +3 -2
  11. package/dist/cjs/retry/RetryPolicy.cjs +1 -1
  12. package/dist/cjs/retry/RetryPolicy.cjs.map +1 -1
  13. package/dist/cjs/types.cjs.map +1 -1
  14. package/dist/cjs/types.d.cts +9 -2
  15. package/dist/esm/OfflineExecutor.d.ts +1 -1
  16. package/dist/esm/OfflineExecutor.js +3 -3
  17. package/dist/esm/OfflineExecutor.js.map +1 -1
  18. package/dist/esm/connectivity/ReactNativeOnlineDetector.d.ts +2 -0
  19. package/dist/esm/connectivity/ReactNativeOnlineDetector.js +13 -1
  20. package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
  21. package/dist/esm/executor/TransactionExecutor.d.ts +3 -2
  22. package/dist/esm/executor/TransactionExecutor.js +17 -2
  23. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  24. package/dist/esm/retry/RetryPolicy.js +1 -1
  25. package/dist/esm/retry/RetryPolicy.js.map +1 -1
  26. package/dist/esm/types.d.ts +9 -2
  27. package/dist/esm/types.js.map +1 -1
  28. package/package.json +4 -3
  29. package/skills/offline/SKILL.md +356 -0
  30. package/src/OfflineExecutor.ts +4 -5
  31. package/src/connectivity/ReactNativeOnlineDetector.ts +22 -2
  32. package/src/executor/TransactionExecutor.ts +27 -5
  33. package/src/retry/RetryPolicy.ts +1 -1
  34. package/src/types.ts +13 -1
@@ -0,0 +1,356 @@
1
+ ---
2
+ name: offline
3
+ description: >
4
+ Offline transaction support for TanStack DB. OfflineExecutor orchestrates
5
+ persistent outbox (IndexedDB/localStorage), leader election (WebLocks/
6
+ BroadcastChannel), retry with backoff, and connectivity detection.
7
+ createOfflineTransaction/createOfflineAction wrap TanStack DB primitives
8
+ with offline persistence. Idempotency keys for at-least-once delivery.
9
+ Graceful degradation to online-only mode when storage unavailable.
10
+ React Native support via separate entry point.
11
+ type: composition
12
+ library: db
13
+ library_version: '0.5.30'
14
+ requires:
15
+ - db-core
16
+ - db-core/mutations-optimistic
17
+ sources:
18
+ - 'TanStack/db:packages/offline-transactions/src/OfflineExecutor.ts'
19
+ - 'TanStack/db:packages/offline-transactions/src/types.ts'
20
+ - 'TanStack/db:packages/offline-transactions/src/index.ts'
21
+ ---
22
+
23
+ This skill builds on db-core and mutations-optimistic. Read those first.
24
+
25
+ # TanStack DB — Offline Transactions
26
+
27
+ ## Setup
28
+
29
+ ```ts
30
+ import {
31
+ startOfflineExecutor,
32
+ IndexedDBAdapter,
33
+ } from '@tanstack/offline-transactions'
34
+ import { todoCollection } from './collections'
35
+
36
+ const executor = startOfflineExecutor({
37
+ collections: { todos: todoCollection },
38
+ mutationFns: {
39
+ createTodo: async ({ transaction, idempotencyKey }) => {
40
+ const mutation = transaction.mutations[0]
41
+ await api.todos.create({
42
+ ...mutation.modified,
43
+ idempotencyKey,
44
+ })
45
+ },
46
+ updateTodo: async ({ transaction, idempotencyKey }) => {
47
+ const mutation = transaction.mutations[0]
48
+ await api.todos.update(mutation.key, {
49
+ ...mutation.changes,
50
+ idempotencyKey,
51
+ })
52
+ },
53
+ },
54
+ })
55
+
56
+ // Wait for initialization (storage probe, leader election, outbox replay)
57
+ await executor.waitForInit()
58
+ ```
59
+
60
+ ## Core API
61
+
62
+ ### createOfflineTransaction
63
+
64
+ ```ts
65
+ const tx = executor.createOfflineTransaction({
66
+ mutationFnName: 'createTodo',
67
+ })
68
+
69
+ // Mutations run inside tx.mutate() — uses ambient transaction context
70
+ tx.mutate(() => {
71
+ todoCollection.insert({ id: crypto.randomUUID(), text: 'New todo' })
72
+ })
73
+ tx.commit()
74
+ ```
75
+
76
+ If the executor is not the leader tab, falls back to `createTransaction` directly (no offline persistence).
77
+
78
+ ### createOfflineAction
79
+
80
+ ```ts
81
+ const addTodo = executor.createOfflineAction({
82
+ mutationFnName: 'createTodo',
83
+ onMutate: (variables) => {
84
+ todoCollection.insert({
85
+ id: crypto.randomUUID(),
86
+ text: variables.text,
87
+ })
88
+ },
89
+ })
90
+
91
+ // Call it
92
+ addTodo({ text: 'Buy milk' })
93
+ ```
94
+
95
+ If the executor is not the leader tab, falls back to `createOptimisticAction` directly.
96
+
97
+ ## Architecture
98
+
99
+ ### Components
100
+
101
+ | Component | Purpose | Default |
102
+ | ----------------------- | ------------------------------------------- | --------------------------------- |
103
+ | **Storage** | Persist transactions to survive page reload | IndexedDB → localStorage fallback |
104
+ | **OutboxManager** | FIFO queue of pending transactions | Automatic |
105
+ | **KeyScheduler** | Serialize transactions touching same keys | Automatic |
106
+ | **TransactionExecutor** | Execute with retry + backoff | Automatic |
107
+ | **LeaderElection** | Only one tab processes the outbox | WebLocks → BroadcastChannel |
108
+ | **OnlineDetector** | Pause/resume on connectivity changes | navigator.onLine + events |
109
+
110
+ ### Transaction lifecycle
111
+
112
+ 1. Mutation applied optimistically to collection (instant UI update)
113
+ 2. Transaction serialized and persisted to storage (outbox)
114
+ 3. Leader tab picks up transaction and executes `mutationFn`
115
+ 4. On success: removed from outbox, optimistic state resolved
116
+ 5. On failure: retried with exponential backoff
117
+ 6. On page reload: outbox replayed, optimistic state restored
118
+
119
+ ### Leader election
120
+
121
+ Only one tab processes the outbox to prevent duplicate execution. Non-leader tabs use regular `createTransaction`/`createOptimisticAction` (online-only, no persistence).
122
+
123
+ ```ts
124
+ const executor = startOfflineExecutor({
125
+ // ...
126
+ onLeadershipChange: (isLeader) => {
127
+ console.log(
128
+ isLeader
129
+ ? 'This tab is processing offline transactions'
130
+ : 'Another tab is leader',
131
+ )
132
+ },
133
+ })
134
+
135
+ executor.isOfflineEnabled // true only if leader AND storage available
136
+ ```
137
+
138
+ ### Storage degradation
139
+
140
+ The executor probes storage availability on startup:
141
+
142
+ ```ts
143
+ const executor = startOfflineExecutor({
144
+ // ...
145
+ onStorageFailure: (diagnostic) => {
146
+ // diagnostic.code: 'STORAGE_BLOCKED' | 'QUOTA_EXCEEDED' | 'UNKNOWN_ERROR'
147
+ // diagnostic.mode: 'online-only'
148
+ console.warn(diagnostic.message)
149
+ },
150
+ })
151
+
152
+ executor.mode // 'offline' | 'online-only'
153
+ executor.storageDiagnostic // Full diagnostic info
154
+ ```
155
+
156
+ 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.
157
+
158
+ ## Configuration
159
+
160
+ ```ts
161
+ interface OfflineConfig {
162
+ collections: Record<string, Collection> // Collections for optimistic state restoration
163
+ mutationFns: Record<string, OfflineMutationFn> // Named mutation functions
164
+ storage?: StorageAdapter // Custom storage (default: auto-detect)
165
+ maxConcurrency?: number // Parallel execution limit
166
+ jitter?: boolean // Add jitter to retry delays
167
+ beforeRetry?: (txs) => txs // Transform/filter before retry
168
+ onUnknownMutationFn?: (name, tx) => void // Handle orphaned transactions
169
+ onLeadershipChange?: (isLeader) => void // Leadership state callback
170
+ onStorageFailure?: (diagnostic) => void // Storage probe failure callback
171
+ leaderElection?: LeaderElection // Custom leader election
172
+ onlineDetector?: OnlineDetector // Custom connectivity detection
173
+ }
174
+ ```
175
+
176
+ ### Custom storage adapter
177
+
178
+ ```ts
179
+ interface StorageAdapter {
180
+ get: (key: string) => Promise<string | null>
181
+ set: (key: string, value: string) => Promise<void>
182
+ delete: (key: string) => Promise<void>
183
+ keys: () => Promise<Array<string>>
184
+ clear: () => Promise<void>
185
+ }
186
+ ```
187
+
188
+ ## Error Handling
189
+
190
+ ### NonRetriableError
191
+
192
+ ```ts
193
+ import { NonRetriableError } from '@tanstack/offline-transactions'
194
+
195
+ const executor = startOfflineExecutor({
196
+ mutationFns: {
197
+ createTodo: async ({ transaction, idempotencyKey }) => {
198
+ const res = await fetch('/api/todos', { method: 'POST', body: ... })
199
+ if (res.status === 409) {
200
+ throw new NonRetriableError('Duplicate detected')
201
+ }
202
+ if (!res.ok) throw new Error('Server error')
203
+ },
204
+ },
205
+ })
206
+ ```
207
+
208
+ Throwing `NonRetriableError` stops retry and removes the transaction from the outbox. Use for permanent failures (validation errors, conflicts, 4xx responses).
209
+
210
+ ### Idempotency keys
211
+
212
+ Every offline transaction includes an `idempotencyKey`. Pass it to your API to prevent duplicate execution on retry:
213
+
214
+ ```ts
215
+ mutationFns: {
216
+ createTodo: async ({ transaction, idempotencyKey }) => {
217
+ await fetch('/api/todos', {
218
+ method: 'POST',
219
+ headers: { 'Idempotency-Key': idempotencyKey },
220
+ body: JSON.stringify(transaction.mutations[0].modified),
221
+ })
222
+ },
223
+ }
224
+ ```
225
+
226
+ ## React Native
227
+
228
+ ```ts
229
+ import {
230
+ startOfflineExecutor,
231
+ } from '@tanstack/offline-transactions/react-native'
232
+
233
+ // Uses ReactNativeOnlineDetector automatically
234
+ // Uses AsyncStorage-compatible storage
235
+ const executor = startOfflineExecutor({ ... })
236
+ ```
237
+
238
+ ## Outbox Management
239
+
240
+ ```ts
241
+ // Inspect pending transactions
242
+ const pending = await executor.peekOutbox()
243
+
244
+ // Get counts
245
+ executor.getPendingCount() // Queued transactions
246
+ executor.getRunningCount() // Currently executing
247
+
248
+ // Clear all pending transactions
249
+ await executor.clearOutbox()
250
+
251
+ // Cleanup
252
+ executor.dispose()
253
+ ```
254
+
255
+ ## Common Mistakes
256
+
257
+ ### CRITICAL Not passing idempotencyKey to the API
258
+
259
+ Wrong:
260
+
261
+ ```ts
262
+ mutationFns: {
263
+ createTodo: async ({ transaction }) => {
264
+ await api.todos.create(transaction.mutations[0].modified)
265
+ },
266
+ }
267
+ ```
268
+
269
+ Correct:
270
+
271
+ ```ts
272
+ mutationFns: {
273
+ createTodo: async ({ transaction, idempotencyKey }) => {
274
+ await api.todos.create({
275
+ ...transaction.mutations[0].modified,
276
+ idempotencyKey,
277
+ })
278
+ },
279
+ }
280
+ ```
281
+
282
+ Offline transactions retry on failure. Without idempotency keys, retries can create duplicate records on the server.
283
+
284
+ ### HIGH Not waiting for initialization
285
+
286
+ Wrong:
287
+
288
+ ```ts
289
+ const executor = startOfflineExecutor({ ... })
290
+ const tx = executor.createOfflineTransaction({ mutationFnName: 'createTodo' })
291
+ ```
292
+
293
+ Correct:
294
+
295
+ ```ts
296
+ const executor = startOfflineExecutor({ ... })
297
+ await executor.waitForInit()
298
+ const tx = executor.createOfflineTransaction({ mutationFnName: 'createTodo' })
299
+ ```
300
+
301
+ `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.
302
+
303
+ ### HIGH Missing collection in collections map
304
+
305
+ Wrong:
306
+
307
+ ```ts
308
+ const executor = startOfflineExecutor({
309
+ collections: {},
310
+ mutationFns: { createTodo: ... },
311
+ })
312
+ ```
313
+
314
+ Correct:
315
+
316
+ ```ts
317
+ const executor = startOfflineExecutor({
318
+ collections: { todos: todoCollection },
319
+ mutationFns: { createTodo: ... },
320
+ })
321
+ ```
322
+
323
+ 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.
324
+
325
+ ### MEDIUM Not handling NonRetriableError for permanent failures
326
+
327
+ Wrong:
328
+
329
+ ```ts
330
+ mutationFns: {
331
+ createTodo: async ({ transaction }) => {
332
+ const res = await fetch('/api/todos', { ... })
333
+ if (!res.ok) throw new Error('Failed')
334
+ },
335
+ }
336
+ ```
337
+
338
+ Correct:
339
+
340
+ ```ts
341
+ mutationFns: {
342
+ createTodo: async ({ transaction }) => {
343
+ const res = await fetch('/api/todos', { ... })
344
+ if (res.status >= 400 && res.status < 500) {
345
+ throw new NonRetriableError(`Client error: ${res.status}`)
346
+ }
347
+ if (!res.ok) throw new Error('Server error')
348
+ },
349
+ }
350
+ ```
351
+
352
+ Without distinguishing retriable from permanent errors, 4xx responses (validation, auth, not found) will retry forever until max retries, wasting resources and filling logs.
353
+
354
+ See also: db-core/mutations-optimistic/SKILL.md — for the underlying mutation primitives.
355
+
356
+ See also: db-core/collection-setup/SKILL.md — for setting up collections used with offline transactions.
@@ -220,7 +220,6 @@ export class OfflineExecutor {
220
220
 
221
221
  this.unsubscribeOnline = this.onlineDetector.subscribe(() => {
222
222
  if (this.isOfflineEnabled && this.executor) {
223
- // Reset retry delays so transactions can execute immediately when back online
224
223
  this.executor.resetRetryDelays()
225
224
  this.executor.executeAll().catch((error) => {
226
225
  console.warn(
@@ -546,10 +545,6 @@ export class OfflineExecutor {
546
545
  this.executor.clear()
547
546
  }
548
547
 
549
- notifyOnline(): void {
550
- this.onlineDetector.notifyOnline()
551
- }
552
-
553
548
  getPendingCount(): number {
554
549
  if (!this.executor) {
555
550
  return 0
@@ -568,6 +563,10 @@ export class OfflineExecutor {
568
563
  return this.onlineDetector
569
564
  }
570
565
 
566
+ isOnline(): boolean {
567
+ return this.onlineDetector.isOnline()
568
+ }
569
+
571
570
  dispose(): void {
572
571
  if (this.unsubscribeOnline) {
573
572
  this.unsubscribeOnline()
@@ -27,10 +27,19 @@ export class ReactNativeOnlineDetector implements OnlineDetector {
27
27
 
28
28
  this.isListening = true
29
29
 
30
+ if (typeof NetInfo.fetch === `function`) {
31
+ void NetInfo.fetch()
32
+ .then((state) => {
33
+ this.wasConnected = this.toConnectivityState(state)
34
+ })
35
+ .catch(() => {
36
+ // Ignore initial fetch failures and rely on subscription updates.
37
+ })
38
+ }
39
+
30
40
  // Subscribe to network state changes
31
41
  this.netInfoUnsubscribe = NetInfo.addEventListener((state) => {
32
- const isConnected =
33
- state.isConnected === true && state.isInternetReachable !== false
42
+ const isConnected = this.toConnectivityState(state)
34
43
 
35
44
  // Only notify when transitioning to online
36
45
  if (isConnected && !this.wasConnected) {
@@ -98,8 +107,19 @@ export class ReactNativeOnlineDetector implements OnlineDetector {
98
107
  this.notifyListeners()
99
108
  }
100
109
 
110
+ isOnline(): boolean {
111
+ return this.wasConnected
112
+ }
113
+
101
114
  dispose(): void {
102
115
  this.stopListening()
103
116
  this.listeners.clear()
104
117
  }
118
+
119
+ private toConnectivityState(state: {
120
+ isConnected: boolean | null
121
+ isInternetReachable: boolean | null
122
+ }): boolean {
123
+ return !!state.isConnected && state.isInternetReachable !== false
124
+ }
105
125
  }
@@ -4,7 +4,11 @@ import { NonRetriableError } from '../types'
4
4
  import { withNestedSpan } from '../telemetry/tracer'
5
5
  import type { KeyScheduler } from './KeyScheduler'
6
6
  import type { OutboxManager } from '../outbox/OutboxManager'
7
- import type { OfflineConfig, OfflineTransaction } from '../types'
7
+ import type {
8
+ OfflineConfig,
9
+ OfflineTransaction,
10
+ TransactionSignaler,
11
+ } from '../types'
8
12
 
9
13
  const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)
10
14
 
@@ -15,19 +19,22 @@ export class TransactionExecutor {
15
19
  private retryPolicy: DefaultRetryPolicy
16
20
  private isExecuting = false
17
21
  private executionPromise: Promise<void> | null = null
18
- private offlineExecutor: any // Reference to OfflineExecutor for signaling
22
+ private offlineExecutor: TransactionSignaler
19
23
  private retryTimer: ReturnType<typeof setTimeout> | null = null
20
24
 
21
25
  constructor(
22
26
  scheduler: KeyScheduler,
23
27
  outbox: OutboxManager,
24
28
  config: OfflineConfig,
25
- offlineExecutor: any,
29
+ offlineExecutor: TransactionSignaler,
26
30
  ) {
27
31
  this.scheduler = scheduler
28
32
  this.outbox = outbox
29
33
  this.config = config
30
- this.retryPolicy = new DefaultRetryPolicy(10, config.jitter ?? true)
34
+ this.retryPolicy = new DefaultRetryPolicy(
35
+ Number.POSITIVE_INFINITY,
36
+ config.jitter ?? true,
37
+ )
31
38
  this.offlineExecutor = offlineExecutor
32
39
  }
33
40
 
@@ -54,6 +61,10 @@ export class TransactionExecutor {
54
61
 
55
62
  private async runExecution(): Promise<void> {
56
63
  while (this.scheduler.getPendingCount() > 0) {
64
+ if (!this.isOnline()) {
65
+ break
66
+ }
67
+
57
68
  const transaction = this.scheduler.getNext()
58
69
 
59
70
  if (!transaction) {
@@ -178,7 +189,10 @@ export class TransactionExecutor {
178
189
  return
179
190
  }
180
191
 
181
- const delay = this.retryPolicy.calculateDelay(transaction.retryCount)
192
+ const delay = Math.max(
193
+ 0,
194
+ this.retryPolicy.calculateDelay(transaction.retryCount),
195
+ )
182
196
  const updatedTransaction: OfflineTransaction = {
183
197
  ...transaction,
184
198
  retryCount: transaction.retryCount + 1,
@@ -320,6 +334,10 @@ export class TransactionExecutor {
320
334
  // Clear existing timer
321
335
  this.clearRetryTimer()
322
336
 
337
+ if (!this.isOnline()) {
338
+ return
339
+ }
340
+
323
341
  // Find the earliest retry time among pending transactions
324
342
  const earliestRetryTime = this.getEarliestRetryTime()
325
343
 
@@ -353,6 +371,10 @@ export class TransactionExecutor {
353
371
  }
354
372
  }
355
373
 
374
+ private isOnline(): boolean {
375
+ return this.offlineExecutor.isOnline()
376
+ }
377
+
356
378
  getRunningCount(): number {
357
379
  return this.scheduler.getRunningCount()
358
380
  }
@@ -6,7 +6,7 @@ export class DefaultRetryPolicy implements RetryPolicy {
6
6
  private backoffCalculator: BackoffCalculator
7
7
  private maxRetries: number
8
8
 
9
- constructor(maxRetries = 10, jitter = true) {
9
+ constructor(maxRetries = Number.POSITIVE_INFINITY, jitter = true) {
10
10
  this.backoffCalculator = new BackoffCalculator(jitter)
11
11
  this.maxRetries = maxRetries
12
12
  }
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  Collection,
3
3
  MutationFnParams,
4
4
  PendingMutation,
5
+ Transaction,
5
6
  } from '@tanstack/db'
6
7
 
7
8
  // Extended mutation function that includes idempotency key
@@ -104,7 +105,7 @@ export interface OfflineConfig {
104
105
  /**
105
106
  * Custom online detector implementation.
106
107
  * Defaults to WebOnlineDetector for browser environments.
107
- * Use ReactNativeOnlineDetector from '@tanstack/offline-transactions/react-native' for RN/Expo.
108
+ * The '@tanstack/offline-transactions/react-native' entry point uses ReactNativeOnlineDetector automatically.
108
109
  */
109
110
  onlineDetector?: OnlineDetector
110
111
  }
@@ -129,9 +130,20 @@ export interface LeaderElection {
129
130
  onLeadershipChange: (callback: (isLeader: boolean) => void) => () => void
130
131
  }
131
132
 
133
+ export interface TransactionSignaler {
134
+ resolveTransaction: (transactionId: string, result: any) => void
135
+ rejectTransaction: (transactionId: string, error: Error) => void
136
+ registerRestorationTransaction: (
137
+ offlineTransactionId: string,
138
+ restorationTransaction: Transaction,
139
+ ) => void
140
+ isOnline: () => boolean
141
+ }
142
+
132
143
  export interface OnlineDetector {
133
144
  subscribe: (callback: () => void) => () => void
134
145
  notifyOnline: () => void
146
+ isOnline: () => boolean
135
147
  dispose: () => void
136
148
  }
137
149