@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,330 @@
1
+ import { createTraceState } from "@opentelemetry/api"
2
+ import { DefaultRetryPolicy } from "../retry/RetryPolicy"
3
+ import { NonRetriableError } from "../types"
4
+ import { withNestedSpan } from "../telemetry/tracer"
5
+ import type { SpanContext } from "@opentelemetry/api"
6
+ import type { KeyScheduler } from "./KeyScheduler"
7
+ import type { OutboxManager } from "../outbox/OutboxManager"
8
+ import type {
9
+ OfflineConfig,
10
+ OfflineTransaction,
11
+ SerializedSpanContext,
12
+ } from "../types"
13
+
14
+ const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)
15
+
16
+ function toSpanContext(
17
+ serialized?: SerializedSpanContext
18
+ ): SpanContext | undefined {
19
+ if (!serialized) {
20
+ return undefined
21
+ }
22
+
23
+ return {
24
+ traceId: serialized.traceId,
25
+ spanId: serialized.spanId,
26
+ traceFlags: serialized.traceFlags,
27
+ traceState: serialized.traceState
28
+ ? createTraceState(serialized.traceState)
29
+ : undefined,
30
+ }
31
+ }
32
+
33
+ export class TransactionExecutor {
34
+ private scheduler: KeyScheduler
35
+ private outbox: OutboxManager
36
+ private config: OfflineConfig
37
+ private retryPolicy: DefaultRetryPolicy
38
+ private isExecuting = false
39
+ private executionPromise: Promise<void> | null = null
40
+ private offlineExecutor: any // Reference to OfflineExecutor for signaling
41
+ private retryTimer: ReturnType<typeof setTimeout> | null = null
42
+
43
+ constructor(
44
+ scheduler: KeyScheduler,
45
+ outbox: OutboxManager,
46
+ config: OfflineConfig,
47
+ offlineExecutor: any
48
+ ) {
49
+ this.scheduler = scheduler
50
+ this.outbox = outbox
51
+ this.config = config
52
+ this.retryPolicy = new DefaultRetryPolicy(10, config.jitter ?? true)
53
+ this.offlineExecutor = offlineExecutor
54
+ }
55
+
56
+ async execute(transaction: OfflineTransaction): Promise<void> {
57
+ this.scheduler.schedule(transaction)
58
+ await this.executeAll()
59
+ }
60
+
61
+ async executeAll(): Promise<void> {
62
+ if (this.isExecuting) {
63
+ return this.executionPromise!
64
+ }
65
+
66
+ this.isExecuting = true
67
+ this.executionPromise = this.runExecution()
68
+
69
+ try {
70
+ await this.executionPromise
71
+ } finally {
72
+ this.isExecuting = false
73
+ this.executionPromise = null
74
+ }
75
+ }
76
+
77
+ private async runExecution(): Promise<void> {
78
+ const maxConcurrency = this.config.maxConcurrency ?? 3
79
+
80
+ while (this.scheduler.getPendingCount() > 0) {
81
+ const batch = this.scheduler.getNextBatch(maxConcurrency)
82
+
83
+ if (batch.length === 0) {
84
+ break
85
+ }
86
+
87
+ const executions = batch.map((transaction) =>
88
+ this.executeTransaction(transaction)
89
+ )
90
+ await Promise.allSettled(executions)
91
+ }
92
+
93
+ // Schedule next retry after execution completes
94
+ this.scheduleNextRetry()
95
+ }
96
+
97
+ private async executeTransaction(
98
+ transaction: OfflineTransaction
99
+ ): Promise<void> {
100
+ try {
101
+ await withNestedSpan(
102
+ `transaction.execute`,
103
+ {
104
+ "transaction.id": transaction.id,
105
+ "transaction.mutationFnName": transaction.mutationFnName,
106
+ "transaction.retryCount": transaction.retryCount,
107
+ "transaction.keyCount": transaction.keys.length,
108
+ },
109
+ async (span) => {
110
+ this.scheduler.markStarted(transaction)
111
+
112
+ if (transaction.retryCount > 0) {
113
+ span.setAttribute(`retry.attempt`, transaction.retryCount)
114
+ }
115
+
116
+ try {
117
+ const result = await this.runMutationFn(transaction)
118
+
119
+ this.scheduler.markCompleted(transaction)
120
+ await this.outbox.remove(transaction.id)
121
+
122
+ span.setAttribute(`result`, `success`)
123
+ this.offlineExecutor.resolveTransaction(transaction.id, result)
124
+ } catch (error) {
125
+ const err =
126
+ error instanceof Error ? error : new Error(String(error))
127
+
128
+ span.setAttribute(`result`, `error`)
129
+
130
+ await this.handleError(transaction, err)
131
+ ;(err as any)[HANDLED_EXECUTION_ERROR] = true
132
+ throw err
133
+ }
134
+ },
135
+ {
136
+ parentContext: toSpanContext(transaction.spanContext),
137
+ }
138
+ )
139
+ } catch (error) {
140
+ if (
141
+ error instanceof Error &&
142
+ (error as any)[HANDLED_EXECUTION_ERROR] === true
143
+ ) {
144
+ return
145
+ }
146
+
147
+ throw error
148
+ }
149
+ }
150
+
151
+ private async runMutationFn(transaction: OfflineTransaction): Promise<void> {
152
+ const mutationFn = this.config.mutationFns[transaction.mutationFnName]
153
+
154
+ if (!mutationFn) {
155
+ const errorMessage = `Unknown mutation function: ${transaction.mutationFnName}`
156
+
157
+ if (this.config.onUnknownMutationFn) {
158
+ this.config.onUnknownMutationFn(transaction.mutationFnName, transaction)
159
+ }
160
+
161
+ throw new NonRetriableError(errorMessage)
162
+ }
163
+
164
+ // Mutations are already PendingMutation objects with collections attached
165
+ // from the deserializer, so we can use them directly
166
+ const transactionWithMutations = {
167
+ id: transaction.id,
168
+ mutations: transaction.mutations,
169
+ metadata: transaction.metadata ?? {},
170
+ }
171
+
172
+ await mutationFn({
173
+ transaction: transactionWithMutations as any,
174
+ idempotencyKey: transaction.idempotencyKey,
175
+ })
176
+ }
177
+
178
+ private async handleError(
179
+ transaction: OfflineTransaction,
180
+ error: Error
181
+ ): Promise<void> {
182
+ return withNestedSpan(
183
+ `transaction.handleError`,
184
+ {
185
+ "transaction.id": transaction.id,
186
+ "error.name": error.name,
187
+ "error.message": error.message,
188
+ },
189
+ async (span) => {
190
+ const shouldRetry = this.retryPolicy.shouldRetry(
191
+ error,
192
+ transaction.retryCount
193
+ )
194
+
195
+ span.setAttribute(`shouldRetry`, shouldRetry)
196
+
197
+ if (!shouldRetry) {
198
+ this.scheduler.markCompleted(transaction)
199
+ await this.outbox.remove(transaction.id)
200
+ console.warn(
201
+ `Transaction ${transaction.id} failed permanently:`,
202
+ error
203
+ )
204
+
205
+ span.setAttribute(`result`, `permanent_failure`)
206
+ // Signal permanent failure to the waiting transaction
207
+ this.offlineExecutor.rejectTransaction(transaction.id, error)
208
+ return
209
+ }
210
+
211
+ const delay = this.retryPolicy.calculateDelay(transaction.retryCount)
212
+ const updatedTransaction: OfflineTransaction = {
213
+ ...transaction,
214
+ retryCount: transaction.retryCount + 1,
215
+ nextAttemptAt: Date.now() + delay,
216
+ lastError: {
217
+ name: error.name,
218
+ message: error.message,
219
+ stack: error.stack,
220
+ },
221
+ }
222
+
223
+ span.setAttribute(`retryDelay`, delay)
224
+ span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
225
+
226
+ this.scheduler.markFailed(transaction)
227
+ this.scheduler.updateTransaction(updatedTransaction)
228
+
229
+ try {
230
+ await this.outbox.update(transaction.id, updatedTransaction)
231
+ span.setAttribute(`result`, `scheduled_retry`)
232
+ } catch (persistError) {
233
+ span.recordException(persistError as Error)
234
+ span.setAttribute(`result`, `persist_failed`)
235
+ throw persistError
236
+ }
237
+
238
+ // Schedule retry timer
239
+ this.scheduleNextRetry()
240
+ }
241
+ )
242
+ }
243
+
244
+ async loadPendingTransactions(): Promise<void> {
245
+ const transactions = await this.outbox.getAll()
246
+ let filteredTransactions = transactions
247
+
248
+ if (this.config.beforeRetry) {
249
+ filteredTransactions = this.config.beforeRetry(transactions)
250
+ }
251
+
252
+ for (const transaction of filteredTransactions) {
253
+ this.scheduler.schedule(transaction)
254
+ }
255
+
256
+ // Reset retry delays for all loaded transactions so they can run immediately
257
+ this.resetRetryDelays()
258
+
259
+ // Schedule retry timer for loaded transactions
260
+ this.scheduleNextRetry()
261
+
262
+ const removedTransactions = transactions.filter(
263
+ (tx) => !filteredTransactions.some((filtered) => filtered.id === tx.id)
264
+ )
265
+
266
+ if (removedTransactions.length > 0) {
267
+ await this.outbox.removeMany(removedTransactions.map((tx) => tx.id))
268
+ }
269
+ }
270
+
271
+ clear(): void {
272
+ this.scheduler.clear()
273
+ this.clearRetryTimer()
274
+ }
275
+
276
+ getPendingCount(): number {
277
+ return this.scheduler.getPendingCount()
278
+ }
279
+
280
+ private scheduleNextRetry(): void {
281
+ // Clear existing timer
282
+ this.clearRetryTimer()
283
+
284
+ // Find the earliest retry time among pending transactions
285
+ const earliestRetryTime = this.getEarliestRetryTime()
286
+
287
+ if (earliestRetryTime === null) {
288
+ return // No transactions pending retry
289
+ }
290
+
291
+ const delay = Math.max(0, earliestRetryTime - Date.now())
292
+
293
+ this.retryTimer = setTimeout(() => {
294
+ this.executeAll().catch((error) => {
295
+ console.warn(`Failed to execute retry batch:`, error)
296
+ })
297
+ }, delay)
298
+ }
299
+
300
+ private getEarliestRetryTime(): number | null {
301
+ const allTransactions = this.scheduler.getAllPendingTransactions()
302
+
303
+ if (allTransactions.length === 0) {
304
+ return null
305
+ }
306
+
307
+ return Math.min(...allTransactions.map((tx) => tx.nextAttemptAt))
308
+ }
309
+
310
+ private clearRetryTimer(): void {
311
+ if (this.retryTimer) {
312
+ clearTimeout(this.retryTimer)
313
+ this.retryTimer = null
314
+ }
315
+ }
316
+
317
+ getRunningCount(): number {
318
+ return this.scheduler.getRunningCount()
319
+ }
320
+
321
+ resetRetryDelays(): void {
322
+ const allTransactions = this.scheduler.getAllPendingTransactions()
323
+ const updatedTransactions = allTransactions.map((transaction) => ({
324
+ ...transaction,
325
+ nextAttemptAt: Date.now(),
326
+ }))
327
+
328
+ this.scheduler.updateTransactions(updatedTransactions)
329
+ }
330
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Main API
2
+ export { OfflineExecutor, startOfflineExecutor } from "./OfflineExecutor"
3
+
4
+ // Types
5
+ export type {
6
+ OfflineTransaction,
7
+ OfflineConfig,
8
+ StorageAdapter,
9
+ RetryPolicy,
10
+ LeaderElection,
11
+ OnlineDetector,
12
+ CreateOfflineTransactionOptions,
13
+ CreateOfflineActionOptions,
14
+ SerializedError,
15
+ SerializedMutation,
16
+ } from "./types"
17
+
18
+ export { NonRetriableError } from "./types"
19
+
20
+ // Storage adapters
21
+ export { IndexedDBAdapter } from "./storage/IndexedDBAdapter"
22
+ export { LocalStorageAdapter } from "./storage/LocalStorageAdapter"
23
+
24
+ // Retry policies
25
+ export { DefaultRetryPolicy } from "./retry/RetryPolicy"
26
+ export { BackoffCalculator } from "./retry/BackoffCalculator"
27
+
28
+ // Coordination
29
+ export { WebLocksLeader } from "./coordination/WebLocksLeader"
30
+ export { BroadcastChannelLeader } from "./coordination/BroadcastChannelLeader"
31
+
32
+ // Connectivity
33
+ export { DefaultOnlineDetector } from "./connectivity/OnlineDetector"
34
+
35
+ // API components
36
+ export { OfflineTransaction as OfflineTransactionAPI } from "./api/OfflineTransaction"
37
+ export { createOfflineAction } from "./api/OfflineAction"
38
+
39
+ // Outbox management
40
+ export { OutboxManager } from "./outbox/OutboxManager"
41
+ export { TransactionSerializer } from "./outbox/TransactionSerializer"
42
+
43
+ // Execution engine
44
+ export { KeyScheduler } from "./executor/KeyScheduler"
45
+ export { TransactionExecutor } from "./executor/TransactionExecutor"
46
+
47
+ // Replay
@@ -0,0 +1,141 @@
1
+ import { withSpan } from "../telemetry/tracer"
2
+ import { TransactionSerializer } from "./TransactionSerializer"
3
+ import type { OfflineTransaction, StorageAdapter } from "../types"
4
+ import type { Collection } from "@tanstack/db"
5
+
6
+ export class OutboxManager {
7
+ private storage: StorageAdapter
8
+ private serializer: TransactionSerializer
9
+ private keyPrefix = `tx:`
10
+
11
+ constructor(
12
+ storage: StorageAdapter,
13
+ collections: Record<string, Collection>
14
+ ) {
15
+ this.storage = storage
16
+ this.serializer = new TransactionSerializer(collections)
17
+ }
18
+
19
+ private getStorageKey(id: string): string {
20
+ return `${this.keyPrefix}${id}`
21
+ }
22
+
23
+ async add(transaction: OfflineTransaction): Promise<void> {
24
+ return withSpan(
25
+ `outbox.add`,
26
+ {
27
+ "transaction.id": transaction.id,
28
+ "transaction.mutationFnName": transaction.mutationFnName,
29
+ "transaction.keyCount": transaction.keys.length,
30
+ },
31
+ async () => {
32
+ const key = this.getStorageKey(transaction.id)
33
+ const serialized = this.serializer.serialize(transaction)
34
+ await this.storage.set(key, serialized)
35
+ }
36
+ )
37
+ }
38
+
39
+ async get(id: string): Promise<OfflineTransaction | null> {
40
+ return withSpan(`outbox.get`, { "transaction.id": id }, async (span) => {
41
+ const key = this.getStorageKey(id)
42
+ const data = await this.storage.get(key)
43
+
44
+ if (!data) {
45
+ span.setAttribute(`result`, `not_found`)
46
+ return null
47
+ }
48
+
49
+ try {
50
+ const transaction = this.serializer.deserialize(data)
51
+ span.setAttribute(`result`, `found`)
52
+ return transaction
53
+ } catch (error) {
54
+ console.warn(`Failed to deserialize transaction ${id}:`, error)
55
+ span.setAttribute(`result`, `deserialize_error`)
56
+ return null
57
+ }
58
+ })
59
+ }
60
+
61
+ async getAll(): Promise<Array<OfflineTransaction>> {
62
+ return withSpan(`outbox.getAll`, {}, async (span) => {
63
+ const keys = await this.storage.keys()
64
+ const transactionKeys = keys.filter((key) =>
65
+ key.startsWith(this.keyPrefix)
66
+ )
67
+
68
+ span.setAttribute(`transactionCount`, transactionKeys.length)
69
+
70
+ const transactions: Array<OfflineTransaction> = []
71
+
72
+ for (const key of transactionKeys) {
73
+ const data = await this.storage.get(key)
74
+ if (data) {
75
+ try {
76
+ const transaction = this.serializer.deserialize(data)
77
+ transactions.push(transaction)
78
+ } catch (error) {
79
+ console.warn(
80
+ `Failed to deserialize transaction from key ${key}:`,
81
+ error
82
+ )
83
+ }
84
+ }
85
+ }
86
+
87
+ return transactions.sort(
88
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
89
+ )
90
+ })
91
+ }
92
+
93
+ async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> {
94
+ const allTransactions = await this.getAll()
95
+ const keySet = new Set(keys)
96
+
97
+ return allTransactions.filter((transaction) =>
98
+ transaction.keys.some((key) => keySet.has(key))
99
+ )
100
+ }
101
+
102
+ async update(
103
+ id: string,
104
+ updates: Partial<OfflineTransaction>
105
+ ): Promise<void> {
106
+ return withSpan(`outbox.update`, { "transaction.id": id }, async () => {
107
+ const existing = await this.get(id)
108
+ if (!existing) {
109
+ throw new Error(`Transaction ${id} not found`)
110
+ }
111
+
112
+ const updated = { ...existing, ...updates }
113
+ await this.add(updated)
114
+ })
115
+ }
116
+
117
+ async remove(id: string): Promise<void> {
118
+ return withSpan(`outbox.remove`, { "transaction.id": id }, async () => {
119
+ const key = this.getStorageKey(id)
120
+ await this.storage.delete(key)
121
+ })
122
+ }
123
+
124
+ async removeMany(ids: Array<string>): Promise<void> {
125
+ return withSpan(`outbox.removeMany`, { count: ids.length }, async () => {
126
+ await Promise.all(ids.map((id) => this.remove(id)))
127
+ })
128
+ }
129
+
130
+ async clear(): Promise<void> {
131
+ const keys = await this.storage.keys()
132
+ const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix))
133
+
134
+ await Promise.all(transactionKeys.map((key) => this.storage.delete(key)))
135
+ }
136
+
137
+ async count(): Promise<number> {
138
+ const keys = await this.storage.keys()
139
+ return keys.filter((key) => key.startsWith(this.keyPrefix)).length
140
+ }
141
+ }
@@ -0,0 +1,163 @@
1
+ import type {
2
+ OfflineTransaction,
3
+ SerializedError,
4
+ SerializedMutation,
5
+ SerializedOfflineTransaction,
6
+ } from "../types"
7
+ import type { Collection, PendingMutation } from "@tanstack/db"
8
+
9
+ export class TransactionSerializer {
10
+ private collections: Record<string, Collection>
11
+ private collectionIdToKey: Map<string, string>
12
+
13
+ constructor(collections: Record<string, Collection>) {
14
+ this.collections = collections
15
+ // Create reverse lookup from collection.id to registry key
16
+ this.collectionIdToKey = new Map()
17
+ for (const [key, collection] of Object.entries(collections)) {
18
+ this.collectionIdToKey.set(collection.id, key)
19
+ }
20
+ }
21
+
22
+ serialize(transaction: OfflineTransaction): string {
23
+ const serialized: SerializedOfflineTransaction = {
24
+ ...transaction,
25
+ createdAt: transaction.createdAt,
26
+ mutations: transaction.mutations.map((mutation) =>
27
+ this.serializeMutation(mutation)
28
+ ),
29
+ }
30
+ // Convert the whole object to JSON, handling dates
31
+ return JSON.stringify(serialized, (key, value) => {
32
+ if (value instanceof Date) {
33
+ return value.toISOString()
34
+ }
35
+ return value
36
+ })
37
+ }
38
+
39
+ deserialize(data: string): OfflineTransaction {
40
+ const parsed: SerializedOfflineTransaction = JSON.parse(
41
+ data,
42
+ (key, value) => {
43
+ // Parse ISO date strings back to Date objects
44
+ if (
45
+ typeof value === `string` &&
46
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)
47
+ ) {
48
+ return new Date(value)
49
+ }
50
+ return value
51
+ }
52
+ )
53
+
54
+ return {
55
+ ...parsed,
56
+ mutations: parsed.mutations.map((mutationData) =>
57
+ this.deserializeMutation(mutationData)
58
+ ),
59
+ }
60
+ }
61
+
62
+ private serializeMutation(mutation: PendingMutation): SerializedMutation {
63
+ const registryKey = this.collectionIdToKey.get(mutation.collection.id)
64
+ if (!registryKey) {
65
+ throw new Error(
66
+ `Collection with id ${mutation.collection.id} not found in registry`
67
+ )
68
+ }
69
+
70
+ return {
71
+ globalKey: mutation.globalKey,
72
+ type: mutation.type,
73
+ modified: this.serializeValue(mutation.modified),
74
+ original: this.serializeValue(mutation.original),
75
+ collectionId: registryKey, // Store registry key instead of collection.id
76
+ }
77
+ }
78
+
79
+ private deserializeMutation(data: SerializedMutation): PendingMutation {
80
+ const collection = this.collections[data.collectionId]
81
+ if (!collection) {
82
+ throw new Error(`Collection with id ${data.collectionId} not found`)
83
+ }
84
+
85
+ // Create a partial PendingMutation - we can't fully reconstruct it but
86
+ // we provide what we can. The executor will need to handle the rest.
87
+ return {
88
+ globalKey: data.globalKey,
89
+ type: data.type as any,
90
+ modified: this.deserializeValue(data.modified),
91
+ original: this.deserializeValue(data.original),
92
+ collection,
93
+ // These fields would need to be reconstructed by the executor
94
+ mutationId: ``, // Will be regenerated
95
+ key: null, // Will be extracted from the data
96
+ changes: {}, // Will be recalculated
97
+ metadata: undefined,
98
+ syncMetadata: {},
99
+ optimistic: true,
100
+ createdAt: new Date(),
101
+ updatedAt: new Date(),
102
+ } as PendingMutation
103
+ }
104
+
105
+ private serializeValue(value: any): any {
106
+ if (value === null || value === undefined) {
107
+ return value
108
+ }
109
+
110
+ if (value instanceof Date) {
111
+ return { __type: `Date`, value: value.toISOString() }
112
+ }
113
+
114
+ if (typeof value === `object`) {
115
+ const result: any = Array.isArray(value) ? [] : {}
116
+ for (const key in value) {
117
+ if (value.hasOwnProperty(key)) {
118
+ result[key] = this.serializeValue(value[key])
119
+ }
120
+ }
121
+ return result
122
+ }
123
+
124
+ return value
125
+ }
126
+
127
+ private deserializeValue(value: any): any {
128
+ if (value === null || value === undefined) {
129
+ return value
130
+ }
131
+
132
+ if (typeof value === `object` && value.__type === `Date`) {
133
+ return new Date(value.value)
134
+ }
135
+
136
+ if (typeof value === `object`) {
137
+ const result: any = Array.isArray(value) ? [] : {}
138
+ for (const key in value) {
139
+ if (value.hasOwnProperty(key)) {
140
+ result[key] = this.deserializeValue(value[key])
141
+ }
142
+ }
143
+ return result
144
+ }
145
+
146
+ return value
147
+ }
148
+
149
+ serializeError(error: Error): SerializedError {
150
+ return {
151
+ name: error.name,
152
+ message: error.message,
153
+ stack: error.stack,
154
+ }
155
+ }
156
+
157
+ deserializeError(data: SerializedError): Error {
158
+ const error = new Error(data.message)
159
+ error.name = data.name
160
+ error.stack = data.stack
161
+ return error
162
+ }
163
+ }
@@ -0,0 +1,13 @@
1
+ export class BackoffCalculator {
2
+ private jitter: boolean
3
+
4
+ constructor(jitter = true) {
5
+ this.jitter = jitter
6
+ }
7
+
8
+ calculate(retryCount: number): number {
9
+ const baseDelay = Math.min(1000 * Math.pow(2, retryCount), 60000)
10
+ const jitterMultiplier = this.jitter ? Math.random() * 0.3 : 0
11
+ return Math.floor(baseDelay * (1 + jitterMultiplier))
12
+ }
13
+ }
@@ -0,0 +1 @@
1
+ export { NonRetriableError } from "../types"