@tanstack/offline-transactions 0.0.0
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 +219 -0
- package/dist/cjs/OfflineExecutor.cjs +266 -0
- package/dist/cjs/OfflineExecutor.cjs.map +1 -0
- package/dist/cjs/OfflineExecutor.d.cts +39 -0
- package/dist/cjs/api/OfflineAction.cjs +47 -0
- package/dist/cjs/api/OfflineAction.cjs.map +1 -0
- package/dist/cjs/api/OfflineAction.d.cts +3 -0
- package/dist/cjs/api/OfflineTransaction.cjs +96 -0
- package/dist/cjs/api/OfflineTransaction.cjs.map +1 -0
- package/dist/cjs/api/OfflineTransaction.d.cts +18 -0
- package/dist/cjs/connectivity/OnlineDetector.cjs +73 -0
- package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -0
- package/dist/cjs/connectivity/OnlineDetector.d.cts +15 -0
- package/dist/cjs/coordination/BroadcastChannelLeader.cjs +146 -0
- package/dist/cjs/coordination/BroadcastChannelLeader.cjs.map +1 -0
- package/dist/cjs/coordination/BroadcastChannelLeader.d.cts +26 -0
- package/dist/cjs/coordination/LeaderElection.cjs +31 -0
- package/dist/cjs/coordination/LeaderElection.cjs.map +1 -0
- package/dist/cjs/coordination/LeaderElection.d.cts +10 -0
- package/dist/cjs/coordination/WebLocksLeader.cjs +71 -0
- package/dist/cjs/coordination/WebLocksLeader.cjs.map +1 -0
- package/dist/cjs/coordination/WebLocksLeader.d.cts +10 -0
- package/dist/cjs/executor/KeyScheduler.cjs +106 -0
- package/dist/cjs/executor/KeyScheduler.cjs.map +1 -0
- package/dist/cjs/executor/KeyScheduler.d.cts +18 -0
- package/dist/cjs/executor/TransactionExecutor.cjs +236 -0
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -0
- package/dist/cjs/executor/TransactionExecutor.d.cts +28 -0
- package/dist/cjs/index.cjs +34 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/index.d.cts +16 -0
- package/dist/cjs/outbox/OutboxManager.cjs +114 -0
- package/dist/cjs/outbox/OutboxManager.cjs.map +1 -0
- package/dist/cjs/outbox/OutboxManager.d.cts +18 -0
- package/dist/cjs/outbox/TransactionSerializer.cjs +135 -0
- package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -0
- package/dist/cjs/outbox/TransactionSerializer.d.cts +15 -0
- package/dist/cjs/retry/BackoffCalculator.cjs +14 -0
- package/dist/cjs/retry/BackoffCalculator.cjs.map +1 -0
- package/dist/cjs/retry/BackoffCalculator.d.cts +5 -0
- package/dist/cjs/retry/NonRetriableError.d.cts +1 -0
- package/dist/cjs/retry/RetryPolicy.cjs +33 -0
- package/dist/cjs/retry/RetryPolicy.cjs.map +1 -0
- package/dist/cjs/retry/RetryPolicy.d.cts +8 -0
- package/dist/cjs/storage/IndexedDBAdapter.cjs +104 -0
- package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -0
- package/dist/cjs/storage/IndexedDBAdapter.d.cts +14 -0
- package/dist/cjs/storage/LocalStorageAdapter.cjs +71 -0
- package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -0
- package/dist/cjs/storage/LocalStorageAdapter.d.cts +11 -0
- package/dist/cjs/storage/StorageAdapter.cjs +6 -0
- package/dist/cjs/storage/StorageAdapter.cjs.map +1 -0
- package/dist/cjs/storage/StorageAdapter.d.cts +9 -0
- package/dist/cjs/telemetry/tracer.cjs +91 -0
- package/dist/cjs/telemetry/tracer.cjs.map +1 -0
- package/dist/cjs/telemetry/tracer.d.cts +29 -0
- package/dist/cjs/types.cjs +10 -0
- package/dist/cjs/types.cjs.map +1 -0
- package/dist/cjs/types.d.cts +101 -0
- package/dist/esm/OfflineExecutor.d.ts +39 -0
- package/dist/esm/OfflineExecutor.js +266 -0
- package/dist/esm/OfflineExecutor.js.map +1 -0
- package/dist/esm/api/OfflineAction.d.ts +3 -0
- package/dist/esm/api/OfflineAction.js +47 -0
- package/dist/esm/api/OfflineAction.js.map +1 -0
- package/dist/esm/api/OfflineTransaction.d.ts +18 -0
- package/dist/esm/api/OfflineTransaction.js +96 -0
- package/dist/esm/api/OfflineTransaction.js.map +1 -0
- package/dist/esm/connectivity/OnlineDetector.d.ts +15 -0
- package/dist/esm/connectivity/OnlineDetector.js +73 -0
- package/dist/esm/connectivity/OnlineDetector.js.map +1 -0
- package/dist/esm/coordination/BroadcastChannelLeader.d.ts +26 -0
- package/dist/esm/coordination/BroadcastChannelLeader.js +146 -0
- package/dist/esm/coordination/BroadcastChannelLeader.js.map +1 -0
- package/dist/esm/coordination/LeaderElection.d.ts +10 -0
- package/dist/esm/coordination/LeaderElection.js +31 -0
- package/dist/esm/coordination/LeaderElection.js.map +1 -0
- package/dist/esm/coordination/WebLocksLeader.d.ts +10 -0
- package/dist/esm/coordination/WebLocksLeader.js +71 -0
- package/dist/esm/coordination/WebLocksLeader.js.map +1 -0
- package/dist/esm/executor/KeyScheduler.d.ts +18 -0
- package/dist/esm/executor/KeyScheduler.js +106 -0
- package/dist/esm/executor/KeyScheduler.js.map +1 -0
- package/dist/esm/executor/TransactionExecutor.d.ts +28 -0
- package/dist/esm/executor/TransactionExecutor.js +236 -0
- package/dist/esm/executor/TransactionExecutor.js.map +1 -0
- package/dist/esm/index.d.ts +16 -0
- package/dist/esm/index.js +34 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/outbox/OutboxManager.d.ts +18 -0
- package/dist/esm/outbox/OutboxManager.js +114 -0
- package/dist/esm/outbox/OutboxManager.js.map +1 -0
- package/dist/esm/outbox/TransactionSerializer.d.ts +15 -0
- package/dist/esm/outbox/TransactionSerializer.js +135 -0
- package/dist/esm/outbox/TransactionSerializer.js.map +1 -0
- package/dist/esm/retry/BackoffCalculator.d.ts +5 -0
- package/dist/esm/retry/BackoffCalculator.js +14 -0
- package/dist/esm/retry/BackoffCalculator.js.map +1 -0
- package/dist/esm/retry/NonRetriableError.d.ts +1 -0
- package/dist/esm/retry/RetryPolicy.d.ts +8 -0
- package/dist/esm/retry/RetryPolicy.js +33 -0
- package/dist/esm/retry/RetryPolicy.js.map +1 -0
- package/dist/esm/storage/IndexedDBAdapter.d.ts +14 -0
- package/dist/esm/storage/IndexedDBAdapter.js +104 -0
- package/dist/esm/storage/IndexedDBAdapter.js.map +1 -0
- package/dist/esm/storage/LocalStorageAdapter.d.ts +11 -0
- package/dist/esm/storage/LocalStorageAdapter.js +71 -0
- package/dist/esm/storage/LocalStorageAdapter.js.map +1 -0
- package/dist/esm/storage/StorageAdapter.d.ts +9 -0
- package/dist/esm/storage/StorageAdapter.js +6 -0
- package/dist/esm/storage/StorageAdapter.js.map +1 -0
- package/dist/esm/telemetry/tracer.d.ts +29 -0
- package/dist/esm/telemetry/tracer.js +91 -0
- package/dist/esm/telemetry/tracer.js.map +1 -0
- package/dist/esm/types.d.ts +101 -0
- package/dist/esm/types.js +10 -0
- package/dist/esm/types.js.map +1 -0
- package/package.json +66 -0
- package/src/OfflineExecutor.ts +360 -0
- package/src/api/OfflineAction.ts +68 -0
- package/src/api/OfflineTransaction.ts +134 -0
- package/src/connectivity/OnlineDetector.ts +87 -0
- package/src/coordination/BroadcastChannelLeader.ts +181 -0
- package/src/coordination/LeaderElection.ts +35 -0
- package/src/coordination/WebLocksLeader.ts +82 -0
- package/src/executor/KeyScheduler.ts +123 -0
- package/src/executor/TransactionExecutor.ts +330 -0
- package/src/index.ts +47 -0
- package/src/outbox/OutboxManager.ts +141 -0
- package/src/outbox/TransactionSerializer.ts +163 -0
- package/src/retry/BackoffCalculator.ts +13 -0
- package/src/retry/NonRetriableError.ts +1 -0
- package/src/retry/RetryPolicy.ts +41 -0
- package/src/storage/IndexedDBAdapter.ts +119 -0
- package/src/storage/LocalStorageAdapter.ts +79 -0
- package/src/storage/StorageAdapter.ts +11 -0
- package/src/telemetry/tracer.ts +156 -0
- package/src/types.ts +133 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Storage adapters
|
|
2
|
+
import { createOptimisticAction, createTransaction } from "@tanstack/db"
|
|
3
|
+
import { IndexedDBAdapter } from "./storage/IndexedDBAdapter"
|
|
4
|
+
import { LocalStorageAdapter } from "./storage/LocalStorageAdapter"
|
|
5
|
+
|
|
6
|
+
// Core components
|
|
7
|
+
import { OutboxManager } from "./outbox/OutboxManager"
|
|
8
|
+
import { KeyScheduler } from "./executor/KeyScheduler"
|
|
9
|
+
import { TransactionExecutor } from "./executor/TransactionExecutor"
|
|
10
|
+
|
|
11
|
+
// Coordination
|
|
12
|
+
import { WebLocksLeader } from "./coordination/WebLocksLeader"
|
|
13
|
+
import { BroadcastChannelLeader } from "./coordination/BroadcastChannelLeader"
|
|
14
|
+
|
|
15
|
+
// Connectivity
|
|
16
|
+
import { DefaultOnlineDetector } from "./connectivity/OnlineDetector"
|
|
17
|
+
|
|
18
|
+
// API
|
|
19
|
+
import { OfflineTransaction as OfflineTransactionAPI } from "./api/OfflineTransaction"
|
|
20
|
+
import { createOfflineAction } from "./api/OfflineAction"
|
|
21
|
+
|
|
22
|
+
// TanStack DB primitives
|
|
23
|
+
|
|
24
|
+
// Replay
|
|
25
|
+
import { withNestedSpan, withSpan } from "./telemetry/tracer"
|
|
26
|
+
import type {
|
|
27
|
+
CreateOfflineActionOptions,
|
|
28
|
+
CreateOfflineTransactionOptions,
|
|
29
|
+
LeaderElection,
|
|
30
|
+
OfflineConfig,
|
|
31
|
+
OfflineTransaction,
|
|
32
|
+
StorageAdapter,
|
|
33
|
+
} from "./types"
|
|
34
|
+
import type { Transaction } from "@tanstack/db"
|
|
35
|
+
|
|
36
|
+
export class OfflineExecutor {
|
|
37
|
+
private config: OfflineConfig
|
|
38
|
+
private storage: StorageAdapter
|
|
39
|
+
private outbox: OutboxManager
|
|
40
|
+
private scheduler: KeyScheduler
|
|
41
|
+
private executor: TransactionExecutor
|
|
42
|
+
private leaderElection: LeaderElection
|
|
43
|
+
private onlineDetector: DefaultOnlineDetector
|
|
44
|
+
private isLeaderState = false
|
|
45
|
+
private unsubscribeOnline: (() => void) | null = null
|
|
46
|
+
private unsubscribeLeadership: (() => void) | null = null
|
|
47
|
+
|
|
48
|
+
// Coordination mechanism for blocking transactions
|
|
49
|
+
private pendingTransactionPromises: Map<
|
|
50
|
+
string,
|
|
51
|
+
{
|
|
52
|
+
promise: Promise<any>
|
|
53
|
+
resolve: (result: any) => void
|
|
54
|
+
reject: (error: Error) => void
|
|
55
|
+
}
|
|
56
|
+
> = new Map()
|
|
57
|
+
|
|
58
|
+
constructor(config: OfflineConfig) {
|
|
59
|
+
this.config = config
|
|
60
|
+
this.storage = this.createStorage()
|
|
61
|
+
this.outbox = new OutboxManager(this.storage, this.config.collections)
|
|
62
|
+
this.scheduler = new KeyScheduler()
|
|
63
|
+
this.executor = new TransactionExecutor(
|
|
64
|
+
this.scheduler,
|
|
65
|
+
this.outbox,
|
|
66
|
+
this.config,
|
|
67
|
+
this
|
|
68
|
+
)
|
|
69
|
+
this.leaderElection = this.createLeaderElection()
|
|
70
|
+
this.onlineDetector = new DefaultOnlineDetector()
|
|
71
|
+
|
|
72
|
+
this.setupEventListeners()
|
|
73
|
+
this.initialize()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private createStorage(): StorageAdapter {
|
|
77
|
+
if (this.config.storage) {
|
|
78
|
+
return this.config.storage
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
return new IndexedDBAdapter()
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.warn(
|
|
85
|
+
`IndexedDB not available, falling back to localStorage:`,
|
|
86
|
+
error
|
|
87
|
+
)
|
|
88
|
+
return new LocalStorageAdapter()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private createLeaderElection(): LeaderElection {
|
|
93
|
+
if (this.config.leaderElection) {
|
|
94
|
+
return this.config.leaderElection
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (WebLocksLeader.isSupported()) {
|
|
98
|
+
return new WebLocksLeader()
|
|
99
|
+
} else if (BroadcastChannelLeader.isSupported()) {
|
|
100
|
+
return new BroadcastChannelLeader()
|
|
101
|
+
} else {
|
|
102
|
+
// Fallback: always be leader in environments without multi-tab support
|
|
103
|
+
return {
|
|
104
|
+
requestLeadership: () => Promise.resolve(true),
|
|
105
|
+
releaseLeadership: () => {},
|
|
106
|
+
isLeader: () => true,
|
|
107
|
+
onLeadershipChange: () => () => {},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private setupEventListeners(): void {
|
|
113
|
+
this.unsubscribeLeadership = this.leaderElection.onLeadershipChange(
|
|
114
|
+
(isLeader) => {
|
|
115
|
+
this.isLeaderState = isLeader
|
|
116
|
+
|
|
117
|
+
if (this.config.onLeadershipChange) {
|
|
118
|
+
this.config.onLeadershipChange(isLeader)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isLeader) {
|
|
122
|
+
this.loadAndReplayTransactions()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
this.unsubscribeOnline = this.onlineDetector.subscribe(() => {
|
|
128
|
+
if (this.isOfflineEnabled) {
|
|
129
|
+
// Reset retry delays so transactions can execute immediately when back online
|
|
130
|
+
this.executor.resetRetryDelays()
|
|
131
|
+
this.executor.executeAll().catch((error) => {
|
|
132
|
+
console.warn(
|
|
133
|
+
`Failed to execute transactions on connectivity change:`,
|
|
134
|
+
error
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async initialize(): Promise<void> {
|
|
142
|
+
return withSpan(`executor.initialize`, {}, async (span) => {
|
|
143
|
+
try {
|
|
144
|
+
const isLeader = await this.leaderElection.requestLeadership()
|
|
145
|
+
span.setAttribute(`isLeader`, isLeader)
|
|
146
|
+
|
|
147
|
+
if (isLeader) {
|
|
148
|
+
await this.loadAndReplayTransactions()
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.warn(`Failed to initialize offline executor:`, error)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async loadAndReplayTransactions(): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
await this.executor.loadPendingTransactions()
|
|
159
|
+
await this.executor.executeAll()
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.warn(`Failed to load and replay transactions:`, error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get isOfflineEnabled(): boolean {
|
|
166
|
+
return this.isLeaderState
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
createOfflineTransaction(
|
|
170
|
+
options: CreateOfflineTransactionOptions
|
|
171
|
+
): Transaction | OfflineTransactionAPI {
|
|
172
|
+
const mutationFn = this.config.mutationFns[options.mutationFnName]
|
|
173
|
+
|
|
174
|
+
if (!mutationFn) {
|
|
175
|
+
throw new Error(`Unknown mutation function: ${options.mutationFnName}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check leadership immediately and use the appropriate primitive
|
|
179
|
+
if (!this.isOfflineEnabled) {
|
|
180
|
+
// Non-leader: use createTransaction directly with the resolved mutation function
|
|
181
|
+
// We need to wrap it to add the idempotency key
|
|
182
|
+
return createTransaction({
|
|
183
|
+
autoCommit: options.autoCommit ?? true,
|
|
184
|
+
mutationFn: (params) =>
|
|
185
|
+
mutationFn({
|
|
186
|
+
...params,
|
|
187
|
+
idempotencyKey: options.idempotencyKey || crypto.randomUUID(),
|
|
188
|
+
}),
|
|
189
|
+
metadata: options.metadata,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Leader: use OfflineTransaction wrapper for offline persistence
|
|
194
|
+
return new OfflineTransactionAPI(
|
|
195
|
+
options,
|
|
196
|
+
mutationFn,
|
|
197
|
+
this.persistTransaction.bind(this),
|
|
198
|
+
this
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
createOfflineAction<T>(options: CreateOfflineActionOptions<T>) {
|
|
203
|
+
const mutationFn = this.config.mutationFns[options.mutationFnName]
|
|
204
|
+
|
|
205
|
+
if (!mutationFn) {
|
|
206
|
+
throw new Error(`Unknown mutation function: ${options.mutationFnName}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Return a wrapper that checks leadership status at call time
|
|
210
|
+
return (variables: T) => {
|
|
211
|
+
// Check leadership when action is called, not when it's created
|
|
212
|
+
if (!this.isOfflineEnabled) {
|
|
213
|
+
// Non-leader: use createOptimisticAction directly
|
|
214
|
+
const action = createOptimisticAction({
|
|
215
|
+
mutationFn: (vars, params) =>
|
|
216
|
+
mutationFn({
|
|
217
|
+
...vars,
|
|
218
|
+
...params,
|
|
219
|
+
idempotencyKey: crypto.randomUUID(),
|
|
220
|
+
}),
|
|
221
|
+
onMutate: options.onMutate,
|
|
222
|
+
})
|
|
223
|
+
return action(variables)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Leader: use the offline action wrapper
|
|
227
|
+
const action = createOfflineAction(
|
|
228
|
+
options,
|
|
229
|
+
mutationFn,
|
|
230
|
+
this.persistTransaction.bind(this),
|
|
231
|
+
this
|
|
232
|
+
)
|
|
233
|
+
return action(variables)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async persistTransaction(
|
|
238
|
+
transaction: OfflineTransaction
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
return withNestedSpan(
|
|
241
|
+
`executor.persistTransaction`,
|
|
242
|
+
{
|
|
243
|
+
"transaction.id": transaction.id,
|
|
244
|
+
"transaction.mutationFnName": transaction.mutationFnName,
|
|
245
|
+
},
|
|
246
|
+
async (span) => {
|
|
247
|
+
if (!this.isOfflineEnabled) {
|
|
248
|
+
span.setAttribute(`result`, `skipped_not_leader`)
|
|
249
|
+
this.resolveTransaction(transaction.id, undefined)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await this.outbox.add(transaction)
|
|
255
|
+
await this.executor.execute(transaction)
|
|
256
|
+
span.setAttribute(`result`, `persisted`)
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error(
|
|
259
|
+
`Failed to persist offline transaction ${transaction.id}:`,
|
|
260
|
+
error
|
|
261
|
+
)
|
|
262
|
+
span.setAttribute(`result`, `failed`)
|
|
263
|
+
throw error
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Method for OfflineTransaction to wait for completion
|
|
270
|
+
async waitForTransactionCompletion(transactionId: string): Promise<any> {
|
|
271
|
+
const existing = this.pendingTransactionPromises.get(transactionId)
|
|
272
|
+
if (existing) {
|
|
273
|
+
return existing.promise
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const deferred: {
|
|
277
|
+
promise: Promise<any>
|
|
278
|
+
resolve: (result: any) => void
|
|
279
|
+
reject: (error: Error) => void
|
|
280
|
+
} = {} as any
|
|
281
|
+
|
|
282
|
+
deferred.promise = new Promise((resolve, reject) => {
|
|
283
|
+
deferred.resolve = resolve
|
|
284
|
+
deferred.reject = reject
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
this.pendingTransactionPromises.set(transactionId, deferred)
|
|
288
|
+
return deferred.promise
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Method for TransactionExecutor to signal completion
|
|
292
|
+
resolveTransaction(transactionId: string, result: any): void {
|
|
293
|
+
const deferred = this.pendingTransactionPromises.get(transactionId)
|
|
294
|
+
if (deferred) {
|
|
295
|
+
deferred.resolve(result)
|
|
296
|
+
this.pendingTransactionPromises.delete(transactionId)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Method for TransactionExecutor to signal failure
|
|
301
|
+
rejectTransaction(transactionId: string, error: Error): void {
|
|
302
|
+
const deferred = this.pendingTransactionPromises.get(transactionId)
|
|
303
|
+
if (deferred) {
|
|
304
|
+
deferred.reject(error)
|
|
305
|
+
this.pendingTransactionPromises.delete(transactionId)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async removeFromOutbox(id: string): Promise<void> {
|
|
310
|
+
await this.outbox.remove(id)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async peekOutbox(): Promise<Array<OfflineTransaction>> {
|
|
314
|
+
return this.outbox.getAll()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async clearOutbox(): Promise<void> {
|
|
318
|
+
await this.outbox.clear()
|
|
319
|
+
this.executor.clear()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
notifyOnline(): void {
|
|
323
|
+
this.onlineDetector.notifyOnline()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
getPendingCount(): number {
|
|
327
|
+
return this.executor.getPendingCount()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
getRunningCount(): number {
|
|
331
|
+
return this.executor.getRunningCount()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
getOnlineDetector(): DefaultOnlineDetector {
|
|
335
|
+
return this.onlineDetector
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
dispose(): void {
|
|
339
|
+
if (this.unsubscribeOnline) {
|
|
340
|
+
this.unsubscribeOnline()
|
|
341
|
+
this.unsubscribeOnline = null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (this.unsubscribeLeadership) {
|
|
345
|
+
this.unsubscribeLeadership()
|
|
346
|
+
this.unsubscribeLeadership = null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
this.leaderElection.releaseLeadership()
|
|
350
|
+
this.onlineDetector.dispose()
|
|
351
|
+
|
|
352
|
+
if (`dispose` in this.leaderElection) {
|
|
353
|
+
;(this.leaderElection as any).dispose()
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function startOfflineExecutor(config: OfflineConfig): OfflineExecutor {
|
|
359
|
+
return new OfflineExecutor(config)
|
|
360
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { SpanStatusCode, context, trace } from "@opentelemetry/api"
|
|
2
|
+
import { OfflineTransaction } from "./OfflineTransaction"
|
|
3
|
+
import type { Transaction } from "@tanstack/db"
|
|
4
|
+
import type {
|
|
5
|
+
CreateOfflineActionOptions,
|
|
6
|
+
OfflineMutationFn,
|
|
7
|
+
OfflineTransaction as OfflineTransactionType,
|
|
8
|
+
} from "../types"
|
|
9
|
+
|
|
10
|
+
export function createOfflineAction<T>(
|
|
11
|
+
options: CreateOfflineActionOptions<T>,
|
|
12
|
+
mutationFn: OfflineMutationFn,
|
|
13
|
+
persistTransaction: (tx: OfflineTransactionType) => Promise<void>,
|
|
14
|
+
executor: any
|
|
15
|
+
): (variables: T) => Transaction {
|
|
16
|
+
const { mutationFnName, onMutate } = options
|
|
17
|
+
console.log(`createOfflineAction 2`, options)
|
|
18
|
+
|
|
19
|
+
return (variables: T): Transaction => {
|
|
20
|
+
const offlineTransaction = new OfflineTransaction(
|
|
21
|
+
{
|
|
22
|
+
mutationFnName,
|
|
23
|
+
autoCommit: false,
|
|
24
|
+
},
|
|
25
|
+
mutationFn,
|
|
26
|
+
persistTransaction,
|
|
27
|
+
executor
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const transaction = offlineTransaction.mutate(() => {
|
|
31
|
+
console.log(`mutate`)
|
|
32
|
+
onMutate(variables)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Immediately commit with span instrumentation
|
|
36
|
+
const tracer = trace.getTracer(`@tanstack/offline-transactions`, `0.0.1`)
|
|
37
|
+
const span = tracer.startSpan(`offlineAction.${mutationFnName}`)
|
|
38
|
+
const ctx = trace.setSpan(context.active(), span)
|
|
39
|
+
console.log(`starting offlineAction span`, { tracer, span, ctx })
|
|
40
|
+
|
|
41
|
+
// Execute the commit within the span context
|
|
42
|
+
// The key is to return the promise synchronously from context.with() so context binds to it
|
|
43
|
+
const commitPromise = context.with(ctx, () => {
|
|
44
|
+
// Return the promise synchronously - this is critical for context propagation in browsers
|
|
45
|
+
return (async () => {
|
|
46
|
+
try {
|
|
47
|
+
await transaction.commit()
|
|
48
|
+
span.setStatus({ code: SpanStatusCode.OK })
|
|
49
|
+
span.end()
|
|
50
|
+
console.log(`ended offlineAction span - success`)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
span.recordException(error as Error)
|
|
53
|
+
span.setStatus({ code: SpanStatusCode.ERROR })
|
|
54
|
+
span.end()
|
|
55
|
+
console.log(`ended offlineAction span - error`)
|
|
56
|
+
}
|
|
57
|
+
})()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Don't await - this is fire-and-forget for optimistic actions
|
|
61
|
+
// But catch to prevent unhandled rejection
|
|
62
|
+
commitPromise.catch(() => {
|
|
63
|
+
// Already handled in try/catch above
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return transaction
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { context, trace } from "@opentelemetry/api"
|
|
2
|
+
import { createTransaction } from "@tanstack/db"
|
|
3
|
+
import { NonRetriableError } from "../types"
|
|
4
|
+
import type { PendingMutation, Transaction } from "@tanstack/db"
|
|
5
|
+
import type {
|
|
6
|
+
CreateOfflineTransactionOptions,
|
|
7
|
+
OfflineMutationFn,
|
|
8
|
+
OfflineTransaction as OfflineTransactionType,
|
|
9
|
+
} from "../types"
|
|
10
|
+
|
|
11
|
+
export class OfflineTransaction {
|
|
12
|
+
private offlineId: string
|
|
13
|
+
private mutationFnName: string
|
|
14
|
+
private autoCommit: boolean
|
|
15
|
+
private idempotencyKey: string
|
|
16
|
+
private metadata: Record<string, any>
|
|
17
|
+
private transaction: Transaction | null = null
|
|
18
|
+
private persistTransaction: (tx: OfflineTransactionType) => Promise<void>
|
|
19
|
+
private executor: any // Will be typed properly - reference to OfflineExecutor
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
options: CreateOfflineTransactionOptions,
|
|
23
|
+
mutationFn: OfflineMutationFn,
|
|
24
|
+
persistTransaction: (tx: OfflineTransactionType) => Promise<void>,
|
|
25
|
+
executor: any
|
|
26
|
+
) {
|
|
27
|
+
this.offlineId = crypto.randomUUID()
|
|
28
|
+
this.mutationFnName = options.mutationFnName
|
|
29
|
+
this.autoCommit = options.autoCommit ?? true
|
|
30
|
+
this.idempotencyKey = options.idempotencyKey ?? crypto.randomUUID()
|
|
31
|
+
this.metadata = options.metadata ?? {}
|
|
32
|
+
this.persistTransaction = persistTransaction
|
|
33
|
+
this.executor = executor
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
mutate(callback: () => void): Transaction {
|
|
37
|
+
this.transaction = createTransaction({
|
|
38
|
+
id: this.offlineId,
|
|
39
|
+
autoCommit: false,
|
|
40
|
+
mutationFn: async () => {
|
|
41
|
+
// This is the blocking mutationFn that waits for the executor
|
|
42
|
+
// First persist the transaction to the outbox
|
|
43
|
+
const activeSpan = trace.getSpan(context.active())
|
|
44
|
+
const spanContext = activeSpan?.spanContext()
|
|
45
|
+
|
|
46
|
+
const offlineTransaction: OfflineTransactionType = {
|
|
47
|
+
id: this.offlineId,
|
|
48
|
+
mutationFnName: this.mutationFnName,
|
|
49
|
+
mutations: this.transaction!.mutations,
|
|
50
|
+
keys: this.extractKeys(this.transaction!.mutations),
|
|
51
|
+
idempotencyKey: this.idempotencyKey,
|
|
52
|
+
createdAt: new Date(),
|
|
53
|
+
retryCount: 0,
|
|
54
|
+
nextAttemptAt: Date.now(),
|
|
55
|
+
metadata: this.metadata,
|
|
56
|
+
spanContext: spanContext
|
|
57
|
+
? {
|
|
58
|
+
traceId: spanContext.traceId,
|
|
59
|
+
spanId: spanContext.spanId,
|
|
60
|
+
traceFlags: spanContext.traceFlags,
|
|
61
|
+
traceState: spanContext.traceState?.serialize(),
|
|
62
|
+
}
|
|
63
|
+
: undefined,
|
|
64
|
+
version: 1,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const completionPromise = this.executor.waitForTransactionCompletion(
|
|
68
|
+
this.offlineId
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await this.persistTransaction(offlineTransaction)
|
|
73
|
+
// Now block and wait for the executor to complete the real mutation
|
|
74
|
+
await completionPromise
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const normalizedError =
|
|
77
|
+
error instanceof Error ? error : new Error(String(error))
|
|
78
|
+
this.executor.rejectTransaction(this.offlineId, normalizedError)
|
|
79
|
+
throw error
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return
|
|
83
|
+
},
|
|
84
|
+
metadata: this.metadata,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
this.transaction.mutate(() => {
|
|
88
|
+
callback()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (this.autoCommit) {
|
|
92
|
+
// Auto-commit for direct OfflineTransaction usage
|
|
93
|
+
this.commit().catch((error) => {
|
|
94
|
+
console.error(`Auto-commit failed:`, error)
|
|
95
|
+
throw error
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.transaction
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async commit(): Promise<Transaction> {
|
|
103
|
+
if (!this.transaction) {
|
|
104
|
+
throw new Error(`No mutations to commit. Call mutate() first.`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Commit the TanStack DB transaction
|
|
109
|
+
// This will trigger the mutationFn which handles persistence and waiting
|
|
110
|
+
await this.transaction.commit()
|
|
111
|
+
return this.transaction
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// Only rollback for NonRetriableError - other errors should allow retry
|
|
114
|
+
if (error instanceof NonRetriableError) {
|
|
115
|
+
this.transaction.rollback()
|
|
116
|
+
}
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
rollback(): void {
|
|
122
|
+
if (this.transaction) {
|
|
123
|
+
this.transaction.rollback()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private extractKeys(mutations: Array<PendingMutation>): Array<string> {
|
|
128
|
+
return mutations.map((mutation) => mutation.globalKey)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get id(): string {
|
|
132
|
+
return this.offlineId
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { OnlineDetector } from "../types"
|
|
2
|
+
|
|
3
|
+
export class DefaultOnlineDetector implements OnlineDetector {
|
|
4
|
+
private listeners: Set<() => void> = new Set()
|
|
5
|
+
private isListening = false
|
|
6
|
+
|
|
7
|
+
constructor() {
|
|
8
|
+
this.startListening()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private startListening(): void {
|
|
12
|
+
if (this.isListening) {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
this.isListening = true
|
|
17
|
+
|
|
18
|
+
if (typeof window !== `undefined`) {
|
|
19
|
+
window.addEventListener(`online`, this.handleOnline)
|
|
20
|
+
document.addEventListener(`visibilitychange`, this.handleVisibilityChange)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private stopListening(): void {
|
|
25
|
+
if (!this.isListening) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.isListening = false
|
|
30
|
+
|
|
31
|
+
if (typeof window !== `undefined`) {
|
|
32
|
+
window.removeEventListener(`online`, this.handleOnline)
|
|
33
|
+
document.removeEventListener(
|
|
34
|
+
`visibilitychange`,
|
|
35
|
+
this.handleVisibilityChange
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private handleOnline = (): void => {
|
|
41
|
+
this.notifyListeners()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private handleVisibilityChange = (): void => {
|
|
45
|
+
if (document.visibilityState === `visible`) {
|
|
46
|
+
this.notifyListeners()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private notifyListeners(): void {
|
|
51
|
+
for (const listener of this.listeners) {
|
|
52
|
+
try {
|
|
53
|
+
listener()
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.warn(`OnlineDetector listener error:`, error)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
subscribe(callback: () => void): () => void {
|
|
61
|
+
this.listeners.add(callback)
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
this.listeners.delete(callback)
|
|
65
|
+
|
|
66
|
+
if (this.listeners.size === 0) {
|
|
67
|
+
this.stopListening()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
notifyOnline(): void {
|
|
73
|
+
this.notifyListeners()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
isOnline(): boolean {
|
|
77
|
+
if (typeof navigator !== `undefined`) {
|
|
78
|
+
return navigator.onLine
|
|
79
|
+
}
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
dispose(): void {
|
|
84
|
+
this.stopListening()
|
|
85
|
+
this.listeners.clear()
|
|
86
|
+
}
|
|
87
|
+
}
|