@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,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"
|