@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.
Files changed (138) hide show
  1. package/README.md +219 -0
  2. package/dist/cjs/OfflineExecutor.cjs +266 -0
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -0
  4. package/dist/cjs/OfflineExecutor.d.cts +39 -0
  5. package/dist/cjs/api/OfflineAction.cjs +47 -0
  6. package/dist/cjs/api/OfflineAction.cjs.map +1 -0
  7. package/dist/cjs/api/OfflineAction.d.cts +3 -0
  8. package/dist/cjs/api/OfflineTransaction.cjs +96 -0
  9. package/dist/cjs/api/OfflineTransaction.cjs.map +1 -0
  10. package/dist/cjs/api/OfflineTransaction.d.cts +18 -0
  11. package/dist/cjs/connectivity/OnlineDetector.cjs +73 -0
  12. package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -0
  13. package/dist/cjs/connectivity/OnlineDetector.d.cts +15 -0
  14. package/dist/cjs/coordination/BroadcastChannelLeader.cjs +146 -0
  15. package/dist/cjs/coordination/BroadcastChannelLeader.cjs.map +1 -0
  16. package/dist/cjs/coordination/BroadcastChannelLeader.d.cts +26 -0
  17. package/dist/cjs/coordination/LeaderElection.cjs +31 -0
  18. package/dist/cjs/coordination/LeaderElection.cjs.map +1 -0
  19. package/dist/cjs/coordination/LeaderElection.d.cts +10 -0
  20. package/dist/cjs/coordination/WebLocksLeader.cjs +71 -0
  21. package/dist/cjs/coordination/WebLocksLeader.cjs.map +1 -0
  22. package/dist/cjs/coordination/WebLocksLeader.d.cts +10 -0
  23. package/dist/cjs/executor/KeyScheduler.cjs +106 -0
  24. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -0
  25. package/dist/cjs/executor/KeyScheduler.d.cts +18 -0
  26. package/dist/cjs/executor/TransactionExecutor.cjs +236 -0
  27. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -0
  28. package/dist/cjs/executor/TransactionExecutor.d.cts +28 -0
  29. package/dist/cjs/index.cjs +34 -0
  30. package/dist/cjs/index.cjs.map +1 -0
  31. package/dist/cjs/index.d.cts +16 -0
  32. package/dist/cjs/outbox/OutboxManager.cjs +114 -0
  33. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -0
  34. package/dist/cjs/outbox/OutboxManager.d.cts +18 -0
  35. package/dist/cjs/outbox/TransactionSerializer.cjs +135 -0
  36. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -0
  37. package/dist/cjs/outbox/TransactionSerializer.d.cts +15 -0
  38. package/dist/cjs/retry/BackoffCalculator.cjs +14 -0
  39. package/dist/cjs/retry/BackoffCalculator.cjs.map +1 -0
  40. package/dist/cjs/retry/BackoffCalculator.d.cts +5 -0
  41. package/dist/cjs/retry/NonRetriableError.d.cts +1 -0
  42. package/dist/cjs/retry/RetryPolicy.cjs +33 -0
  43. package/dist/cjs/retry/RetryPolicy.cjs.map +1 -0
  44. package/dist/cjs/retry/RetryPolicy.d.cts +8 -0
  45. package/dist/cjs/storage/IndexedDBAdapter.cjs +104 -0
  46. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -0
  47. package/dist/cjs/storage/IndexedDBAdapter.d.cts +14 -0
  48. package/dist/cjs/storage/LocalStorageAdapter.cjs +71 -0
  49. package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -0
  50. package/dist/cjs/storage/LocalStorageAdapter.d.cts +11 -0
  51. package/dist/cjs/storage/StorageAdapter.cjs +6 -0
  52. package/dist/cjs/storage/StorageAdapter.cjs.map +1 -0
  53. package/dist/cjs/storage/StorageAdapter.d.cts +9 -0
  54. package/dist/cjs/telemetry/tracer.cjs +91 -0
  55. package/dist/cjs/telemetry/tracer.cjs.map +1 -0
  56. package/dist/cjs/telemetry/tracer.d.cts +29 -0
  57. package/dist/cjs/types.cjs +10 -0
  58. package/dist/cjs/types.cjs.map +1 -0
  59. package/dist/cjs/types.d.cts +101 -0
  60. package/dist/esm/OfflineExecutor.d.ts +39 -0
  61. package/dist/esm/OfflineExecutor.js +266 -0
  62. package/dist/esm/OfflineExecutor.js.map +1 -0
  63. package/dist/esm/api/OfflineAction.d.ts +3 -0
  64. package/dist/esm/api/OfflineAction.js +47 -0
  65. package/dist/esm/api/OfflineAction.js.map +1 -0
  66. package/dist/esm/api/OfflineTransaction.d.ts +18 -0
  67. package/dist/esm/api/OfflineTransaction.js +96 -0
  68. package/dist/esm/api/OfflineTransaction.js.map +1 -0
  69. package/dist/esm/connectivity/OnlineDetector.d.ts +15 -0
  70. package/dist/esm/connectivity/OnlineDetector.js +73 -0
  71. package/dist/esm/connectivity/OnlineDetector.js.map +1 -0
  72. package/dist/esm/coordination/BroadcastChannelLeader.d.ts +26 -0
  73. package/dist/esm/coordination/BroadcastChannelLeader.js +146 -0
  74. package/dist/esm/coordination/BroadcastChannelLeader.js.map +1 -0
  75. package/dist/esm/coordination/LeaderElection.d.ts +10 -0
  76. package/dist/esm/coordination/LeaderElection.js +31 -0
  77. package/dist/esm/coordination/LeaderElection.js.map +1 -0
  78. package/dist/esm/coordination/WebLocksLeader.d.ts +10 -0
  79. package/dist/esm/coordination/WebLocksLeader.js +71 -0
  80. package/dist/esm/coordination/WebLocksLeader.js.map +1 -0
  81. package/dist/esm/executor/KeyScheduler.d.ts +18 -0
  82. package/dist/esm/executor/KeyScheduler.js +106 -0
  83. package/dist/esm/executor/KeyScheduler.js.map +1 -0
  84. package/dist/esm/executor/TransactionExecutor.d.ts +28 -0
  85. package/dist/esm/executor/TransactionExecutor.js +236 -0
  86. package/dist/esm/executor/TransactionExecutor.js.map +1 -0
  87. package/dist/esm/index.d.ts +16 -0
  88. package/dist/esm/index.js +34 -0
  89. package/dist/esm/index.js.map +1 -0
  90. package/dist/esm/outbox/OutboxManager.d.ts +18 -0
  91. package/dist/esm/outbox/OutboxManager.js +114 -0
  92. package/dist/esm/outbox/OutboxManager.js.map +1 -0
  93. package/dist/esm/outbox/TransactionSerializer.d.ts +15 -0
  94. package/dist/esm/outbox/TransactionSerializer.js +135 -0
  95. package/dist/esm/outbox/TransactionSerializer.js.map +1 -0
  96. package/dist/esm/retry/BackoffCalculator.d.ts +5 -0
  97. package/dist/esm/retry/BackoffCalculator.js +14 -0
  98. package/dist/esm/retry/BackoffCalculator.js.map +1 -0
  99. package/dist/esm/retry/NonRetriableError.d.ts +1 -0
  100. package/dist/esm/retry/RetryPolicy.d.ts +8 -0
  101. package/dist/esm/retry/RetryPolicy.js +33 -0
  102. package/dist/esm/retry/RetryPolicy.js.map +1 -0
  103. package/dist/esm/storage/IndexedDBAdapter.d.ts +14 -0
  104. package/dist/esm/storage/IndexedDBAdapter.js +104 -0
  105. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -0
  106. package/dist/esm/storage/LocalStorageAdapter.d.ts +11 -0
  107. package/dist/esm/storage/LocalStorageAdapter.js +71 -0
  108. package/dist/esm/storage/LocalStorageAdapter.js.map +1 -0
  109. package/dist/esm/storage/StorageAdapter.d.ts +9 -0
  110. package/dist/esm/storage/StorageAdapter.js +6 -0
  111. package/dist/esm/storage/StorageAdapter.js.map +1 -0
  112. package/dist/esm/telemetry/tracer.d.ts +29 -0
  113. package/dist/esm/telemetry/tracer.js +91 -0
  114. package/dist/esm/telemetry/tracer.js.map +1 -0
  115. package/dist/esm/types.d.ts +101 -0
  116. package/dist/esm/types.js +10 -0
  117. package/dist/esm/types.js.map +1 -0
  118. package/package.json +66 -0
  119. package/src/OfflineExecutor.ts +360 -0
  120. package/src/api/OfflineAction.ts +68 -0
  121. package/src/api/OfflineTransaction.ts +134 -0
  122. package/src/connectivity/OnlineDetector.ts +87 -0
  123. package/src/coordination/BroadcastChannelLeader.ts +181 -0
  124. package/src/coordination/LeaderElection.ts +35 -0
  125. package/src/coordination/WebLocksLeader.ts +82 -0
  126. package/src/executor/KeyScheduler.ts +123 -0
  127. package/src/executor/TransactionExecutor.ts +330 -0
  128. package/src/index.ts +47 -0
  129. package/src/outbox/OutboxManager.ts +141 -0
  130. package/src/outbox/TransactionSerializer.ts +163 -0
  131. package/src/retry/BackoffCalculator.ts +13 -0
  132. package/src/retry/NonRetriableError.ts +1 -0
  133. package/src/retry/RetryPolicy.ts +41 -0
  134. package/src/storage/IndexedDBAdapter.ts +119 -0
  135. package/src/storage/LocalStorageAdapter.ts +79 -0
  136. package/src/storage/StorageAdapter.ts +11 -0
  137. package/src/telemetry/tracer.ts +156 -0
  138. 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
+ }