@tanstack/offline-transactions 1.0.20 → 1.0.23
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.
- package/README.md +2 -17
- package/dist/cjs/OfflineExecutor.cjs +3 -3
- package/dist/cjs/OfflineExecutor.cjs.map +1 -1
- package/dist/cjs/OfflineExecutor.d.cts +1 -1
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +13 -1
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.d.cts +2 -0
- package/dist/cjs/executor/TransactionExecutor.cjs +17 -2
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
- package/dist/cjs/executor/TransactionExecutor.d.cts +3 -2
- package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
- package/dist/cjs/retry/RetryPolicy.cjs +1 -1
- package/dist/cjs/retry/RetryPolicy.cjs.map +1 -1
- package/dist/cjs/types.cjs.map +1 -1
- package/dist/cjs/types.d.cts +9 -2
- package/dist/esm/OfflineExecutor.d.ts +1 -1
- package/dist/esm/OfflineExecutor.js +3 -3
- package/dist/esm/OfflineExecutor.js.map +1 -1
- package/dist/esm/connectivity/ReactNativeOnlineDetector.d.ts +2 -0
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js +13 -1
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
- package/dist/esm/executor/TransactionExecutor.d.ts +3 -2
- package/dist/esm/executor/TransactionExecutor.js +17 -2
- package/dist/esm/executor/TransactionExecutor.js.map +1 -1
- package/dist/esm/outbox/OutboxManager.js.map +1 -1
- package/dist/esm/retry/RetryPolicy.js +1 -1
- package/dist/esm/retry/RetryPolicy.js.map +1 -1
- package/dist/esm/types.d.ts +9 -2
- package/dist/esm/types.js.map +1 -1
- package/package.json +4 -3
- package/skills/offline/SKILL.md +356 -0
- package/src/OfflineExecutor.ts +4 -5
- package/src/connectivity/ReactNativeOnlineDetector.ts +22 -2
- package/src/executor/TransactionExecutor.ts +27 -5
- package/src/outbox/OutboxManager.ts +1 -1
- package/src/retry/RetryPolicy.ts +1 -1
- 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.
|
package/src/OfflineExecutor.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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(
|
|
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 =
|
|
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
|
}
|
package/src/retry/RetryPolicy.ts
CHANGED
|
@@ -6,7 +6,7 @@ export class DefaultRetryPolicy implements RetryPolicy {
|
|
|
6
6
|
private backoffCalculator: BackoffCalculator
|
|
7
7
|
private maxRetries: number
|
|
8
8
|
|
|
9
|
-
constructor(maxRetries =
|
|
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
|
-
*
|
|
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
|
|