@tanstack/offline-transactions 1.0.54 → 1.0.58
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 +137 -23
- package/dist/cjs/OfflineExecutor.cjs +28 -11
- package/dist/cjs/OfflineExecutor.cjs.map +1 -1
- package/dist/cjs/OfflineExecutor.d.cts +1 -0
- package/dist/cjs/api/OfflineTransaction.cjs +4 -1
- package/dist/cjs/api/OfflineTransaction.cjs.map +1 -1
- package/dist/cjs/connectivity/OnlineDetector.cjs +1 -1
- package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -1
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +1 -7
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
- package/dist/cjs/executor/KeyScheduler.cjs +31 -26
- package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
- package/dist/cjs/executor/KeyScheduler.d.cts +5 -3
- package/dist/cjs/executor/TransactionExecutor.cjs +72 -35
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
- package/dist/cjs/executor/TransactionExecutor.d.cts +3 -2
- package/dist/cjs/outbox/OutboxManager.cjs +66 -24
- package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
- package/dist/cjs/outbox/OutboxManager.d.cts +6 -0
- package/dist/cjs/outbox/TransactionSerializer.cjs +159 -20
- package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
- package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
- package/dist/cjs/storage/IndexedDBAdapter.cjs +16 -12
- package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
- package/dist/cjs/storage/IndexedDBAdapter.d.cts +1 -0
- package/dist/cjs/types.cjs.map +1 -1
- package/dist/cjs/types.d.cts +3 -0
- package/dist/esm/OfflineExecutor.d.ts +1 -0
- package/dist/esm/OfflineExecutor.js +28 -11
- package/dist/esm/OfflineExecutor.js.map +1 -1
- package/dist/esm/api/OfflineTransaction.js +4 -1
- package/dist/esm/api/OfflineTransaction.js.map +1 -1
- package/dist/esm/connectivity/OnlineDetector.js +1 -1
- package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
- package/dist/esm/executor/KeyScheduler.d.ts +5 -3
- package/dist/esm/executor/KeyScheduler.js +31 -26
- package/dist/esm/executor/KeyScheduler.js.map +1 -1
- package/dist/esm/executor/TransactionExecutor.d.ts +3 -2
- package/dist/esm/executor/TransactionExecutor.js +72 -35
- package/dist/esm/executor/TransactionExecutor.js.map +1 -1
- package/dist/esm/outbox/OutboxManager.d.ts +6 -0
- package/dist/esm/outbox/OutboxManager.js +67 -25
- package/dist/esm/outbox/OutboxManager.js.map +1 -1
- package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
- package/dist/esm/outbox/TransactionSerializer.js +159 -20
- package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
- package/dist/esm/storage/IndexedDBAdapter.d.ts +1 -0
- package/dist/esm/storage/IndexedDBAdapter.js +16 -12
- package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
- package/dist/esm/types.d.ts +3 -0
- package/dist/esm/types.js.map +1 -1
- package/package.json +2 -2
- package/src/OfflineExecutor.ts +34 -15
- package/src/api/OfflineTransaction.ts +7 -2
- package/src/connectivity/OnlineDetector.ts +6 -1
- package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
- package/src/executor/KeyScheduler.ts +39 -28
- package/src/executor/TransactionExecutor.ts +88 -46
- package/src/outbox/OutboxManager.ts +82 -28
- package/src/outbox/TransactionSerializer.ts +236 -22
- package/src/storage/IndexedDBAdapter.ts +23 -12
- package/src/types.ts +3 -0
|
@@ -3,21 +3,29 @@ import type { OfflineTransaction } from '../types'
|
|
|
3
3
|
|
|
4
4
|
export class KeyScheduler {
|
|
5
5
|
private pendingTransactions: Array<OfflineTransaction> = []
|
|
6
|
-
private
|
|
6
|
+
private activeTransactionId: string | undefined
|
|
7
7
|
|
|
8
|
-
schedule(transaction: OfflineTransaction):
|
|
9
|
-
withSyncSpan(
|
|
8
|
+
schedule(transaction: OfflineTransaction): boolean {
|
|
9
|
+
return withSyncSpan(
|
|
10
10
|
`scheduler.schedule`,
|
|
11
11
|
{
|
|
12
12
|
'transaction.id': transaction.id,
|
|
13
13
|
queueLength: this.pendingTransactions.length,
|
|
14
14
|
},
|
|
15
15
|
() => {
|
|
16
|
+
if (
|
|
17
|
+
this.pendingTransactions.some(
|
|
18
|
+
(pending) => pending.id === transaction.id,
|
|
19
|
+
)
|
|
20
|
+
) {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
16
23
|
this.pendingTransactions.push(transaction)
|
|
17
24
|
// Sort by creation time to maintain FIFO order
|
|
18
25
|
this.pendingTransactions.sort(
|
|
19
26
|
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
20
27
|
)
|
|
28
|
+
return true
|
|
21
29
|
},
|
|
22
30
|
)
|
|
23
31
|
}
|
|
@@ -27,7 +35,10 @@ export class KeyScheduler {
|
|
|
27
35
|
`scheduler.getNext`,
|
|
28
36
|
{ pendingCount: this.pendingTransactions.length },
|
|
29
37
|
(span) => {
|
|
30
|
-
if (
|
|
38
|
+
if (
|
|
39
|
+
this.activeTransactionId !== undefined ||
|
|
40
|
+
this.pendingTransactions.length === 0
|
|
41
|
+
) {
|
|
31
42
|
span.setAttribute(`result`, `empty`)
|
|
32
43
|
return undefined
|
|
33
44
|
}
|
|
@@ -51,17 +62,17 @@ export class KeyScheduler {
|
|
|
51
62
|
return Date.now() >= transaction.nextAttemptAt
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
markStarted(
|
|
55
|
-
this.
|
|
65
|
+
markStarted(transaction: OfflineTransaction): void {
|
|
66
|
+
this.activeTransactionId = transaction.id
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
markCompleted(transaction: OfflineTransaction): void {
|
|
59
70
|
this.removeTransaction(transaction)
|
|
60
|
-
this.
|
|
71
|
+
this.activeTransactionId = undefined
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
markFailed(_transaction: OfflineTransaction): void {
|
|
64
|
-
this.
|
|
75
|
+
this.activeTransactionId = undefined
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
private removeTransaction(transaction: OfflineTransaction): void {
|
|
@@ -74,16 +85,7 @@ export class KeyScheduler {
|
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
updateTransaction(transaction: OfflineTransaction): void {
|
|
77
|
-
|
|
78
|
-
(tx) => tx.id === transaction.id,
|
|
79
|
-
)
|
|
80
|
-
if (index >= 0) {
|
|
81
|
-
this.pendingTransactions[index] = transaction
|
|
82
|
-
// Re-sort to maintain FIFO order after update
|
|
83
|
-
this.pendingTransactions.sort(
|
|
84
|
-
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
85
|
-
)
|
|
86
|
-
}
|
|
88
|
+
this.updateTransactions([transaction])
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
getPendingCount(): number {
|
|
@@ -91,12 +93,23 @@ export class KeyScheduler {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
getRunningCount(): number {
|
|
94
|
-
return this.
|
|
96
|
+
return this.activeTransactionId === undefined ? 0 : 1
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
clear(): void {
|
|
98
100
|
this.pendingTransactions = []
|
|
99
|
-
this.
|
|
101
|
+
this.activeTransactionId = undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @internal Reconcile one replay snapshot without canceling issued work. */
|
|
105
|
+
removePendingTransactions(transactionIds: Iterable<string>): Array<string> {
|
|
106
|
+
const ids = new Set(transactionIds)
|
|
107
|
+
if (this.activeTransactionId !== undefined)
|
|
108
|
+
ids.delete(this.activeTransactionId)
|
|
109
|
+
this.pendingTransactions = this.pendingTransactions.filter(
|
|
110
|
+
({ id }) => !ids.has(id),
|
|
111
|
+
)
|
|
112
|
+
return [...ids]
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
getAllPendingTransactions(): Array<OfflineTransaction> {
|
|
@@ -104,14 +117,12 @@ export class KeyScheduler {
|
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
updateTransactions(updatedTransactions: Array<OfflineTransaction>): void {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
}
|
|
120
|
+
const updatedById = new Map(
|
|
121
|
+
updatedTransactions.map((transaction) => [transaction.id, transaction]),
|
|
122
|
+
)
|
|
123
|
+
this.pendingTransactions = this.pendingTransactions.map(
|
|
124
|
+
(transaction) => updatedById.get(transaction.id) ?? transaction,
|
|
125
|
+
)
|
|
115
126
|
// Re-sort to maintain FIFO order after updates
|
|
116
127
|
this.pendingTransactions.sort(
|
|
117
128
|
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
@@ -56,12 +56,13 @@ export class TransactionExecutor {
|
|
|
56
56
|
} finally {
|
|
57
57
|
this.isExecuting = false
|
|
58
58
|
this.executionPromise = null
|
|
59
|
+
this.scheduleNextRetry()
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
private async runExecution(): Promise<void> {
|
|
63
64
|
while (this.scheduler.getPendingCount() > 0) {
|
|
64
|
-
if (!this.
|
|
65
|
+
if (!this.canExecute()) {
|
|
65
66
|
break
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -73,9 +74,6 @@ export class TransactionExecutor {
|
|
|
73
74
|
|
|
74
75
|
await this.executeTransaction(transaction)
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
-
// Schedule next retry after execution completes
|
|
78
|
-
this.scheduleNextRetry()
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
private async executeTransaction(
|
|
@@ -97,14 +95,9 @@ export class TransactionExecutor {
|
|
|
97
95
|
span.setAttribute(`retry.attempt`, transaction.retryCount)
|
|
98
96
|
}
|
|
99
97
|
|
|
98
|
+
let result: void
|
|
100
99
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.scheduler.markCompleted(transaction)
|
|
104
|
-
await this.outbox.remove(transaction.id)
|
|
105
|
-
|
|
106
|
-
span.setAttribute(`result`, `success`)
|
|
107
|
-
this.offlineExecutor.resolveTransaction(transaction.id, result)
|
|
100
|
+
result = await this.runMutationFn(transaction)
|
|
108
101
|
} catch (error) {
|
|
109
102
|
const err =
|
|
110
103
|
error instanceof Error ? error : new Error(String(error))
|
|
@@ -115,6 +108,20 @@ export class TransactionExecutor {
|
|
|
115
108
|
;(err as any)[HANDLED_EXECUTION_ERROR] = true
|
|
116
109
|
throw err
|
|
117
110
|
}
|
|
111
|
+
|
|
112
|
+
let removalError: unknown
|
|
113
|
+
try {
|
|
114
|
+
// Replay can still see this ID until durable deletion settles.
|
|
115
|
+
await this.outbox.remove(transaction.id)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
removalError = error
|
|
118
|
+
} finally {
|
|
119
|
+
this.scheduler.markCompleted(transaction)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
span.setAttribute(`result`, `success`)
|
|
123
|
+
this.offlineExecutor.resolveTransaction(transaction.id, result)
|
|
124
|
+
if (removalError !== undefined) throw removalError
|
|
118
125
|
},
|
|
119
126
|
)
|
|
120
127
|
} catch (error) {
|
|
@@ -176,8 +183,14 @@ export class TransactionExecutor {
|
|
|
176
183
|
span.setAttribute(`shouldRetry`, shouldRetry)
|
|
177
184
|
|
|
178
185
|
if (!shouldRetry) {
|
|
179
|
-
|
|
180
|
-
|
|
186
|
+
let removalError: unknown
|
|
187
|
+
try {
|
|
188
|
+
await this.outbox.remove(transaction.id)
|
|
189
|
+
} catch (cleanupError) {
|
|
190
|
+
removalError = cleanupError
|
|
191
|
+
} finally {
|
|
192
|
+
this.scheduler.markCompleted(transaction)
|
|
193
|
+
}
|
|
181
194
|
console.warn(
|
|
182
195
|
`Transaction ${transaction.id} failed permanently:`,
|
|
183
196
|
error,
|
|
@@ -186,6 +199,7 @@ export class TransactionExecutor {
|
|
|
186
199
|
span.setAttribute(`result`, `permanent_failure`)
|
|
187
200
|
// Signal permanent failure to the waiting transaction
|
|
188
201
|
this.offlineExecutor.rejectTransaction(transaction.id, error)
|
|
202
|
+
if (removalError !== undefined) throw removalError
|
|
189
203
|
return
|
|
190
204
|
}
|
|
191
205
|
|
|
@@ -207,7 +221,6 @@ export class TransactionExecutor {
|
|
|
207
221
|
span.setAttribute(`retryDelay`, delay)
|
|
208
222
|
span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
|
|
209
223
|
|
|
210
|
-
this.scheduler.markFailed(transaction)
|
|
211
224
|
this.scheduler.updateTransaction(updatedTransaction)
|
|
212
225
|
|
|
213
226
|
try {
|
|
@@ -217,42 +230,64 @@ export class TransactionExecutor {
|
|
|
217
230
|
span.recordException(persistError as Error)
|
|
218
231
|
span.setAttribute(`result`, `persist_failed`)
|
|
219
232
|
throw persistError
|
|
233
|
+
} finally {
|
|
234
|
+
this.scheduler.markFailed(transaction)
|
|
220
235
|
}
|
|
221
|
-
|
|
222
|
-
// Schedule retry timer
|
|
223
|
-
this.scheduleNextRetry()
|
|
224
236
|
},
|
|
225
237
|
)
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
async loadPendingTransactions(): Promise<void> {
|
|
229
|
-
|
|
230
|
-
|
|
241
|
+
let removedIds: Array<string> = []
|
|
242
|
+
await this.outbox.withAll((transactions) => {
|
|
243
|
+
const { isOfflineEnabled } = this.offlineExecutor
|
|
244
|
+
if (!isOfflineEnabled) return
|
|
245
|
+
let filteredTransactions = transactions
|
|
246
|
+
|
|
247
|
+
if (this.config.beforeRetry) {
|
|
248
|
+
filteredTransactions = this.config.beforeRetry(transactions)
|
|
249
|
+
}
|
|
231
250
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
251
|
+
// The retry hook is user code and may synchronously revoke replay rights.
|
|
252
|
+
if (!this.offlineExecutor.isOfflineEnabled) return
|
|
235
253
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
254
|
+
const newlyLoaded = filteredTransactions.filter((transaction) =>
|
|
255
|
+
this.scheduler.schedule(transaction),
|
|
256
|
+
)
|
|
239
257
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
258
|
+
const retainedIds = new Set(filteredTransactions.map(({ id }) => id))
|
|
259
|
+
removedIds = transactions
|
|
260
|
+
.filter(({ id }) => !retainedIds.has(id))
|
|
261
|
+
.map(({ id }) => id)
|
|
262
|
+
removedIds = this.scheduler.removePendingTransactions(removedIds)
|
|
243
263
|
|
|
244
|
-
|
|
245
|
-
|
|
264
|
+
// Restore optimistic state for loaded transactions
|
|
265
|
+
// This ensures the UI shows the optimistic data while transactions are pending
|
|
266
|
+
this.restoreOptimisticState(newlyLoaded)
|
|
246
267
|
|
|
247
|
-
|
|
248
|
-
|
|
268
|
+
// Reset retry delays for all loaded transactions so they can run immediately
|
|
269
|
+
this.resetRetryDelays()
|
|
249
270
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
271
|
+
// Schedule retry timer for loaded transactions
|
|
272
|
+
this.scheduleNextRetry()
|
|
273
|
+
})
|
|
253
274
|
|
|
254
|
-
if (
|
|
255
|
-
|
|
275
|
+
if (removedIds.length > 0) {
|
|
276
|
+
const error = new NonRetriableError(`Transaction excluded by beforeRetry`)
|
|
277
|
+
await Promise.all(
|
|
278
|
+
removedIds.map(async (id) => {
|
|
279
|
+
try {
|
|
280
|
+
await this.outbox.remove(id)
|
|
281
|
+
this.offlineExecutor.rejectTransaction(id, error)
|
|
282
|
+
} catch (cleanupError) {
|
|
283
|
+
console.warn(
|
|
284
|
+
`Failed to remove transaction excluded by beforeRetry:`,
|
|
285
|
+
id,
|
|
286
|
+
cleanupError,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
256
291
|
}
|
|
257
292
|
}
|
|
258
293
|
|
|
@@ -326,6 +361,11 @@ export class TransactionExecutor {
|
|
|
326
361
|
this.clearRetryTimer()
|
|
327
362
|
}
|
|
328
363
|
|
|
364
|
+
pause(): void {
|
|
365
|
+
// Retain queued work and let the issued call finish its acknowledgment.
|
|
366
|
+
this.clearRetryTimer()
|
|
367
|
+
}
|
|
368
|
+
|
|
329
369
|
getPendingCount(): number {
|
|
330
370
|
return this.scheduler.getPendingCount()
|
|
331
371
|
}
|
|
@@ -334,18 +374,17 @@ export class TransactionExecutor {
|
|
|
334
374
|
// Clear existing timer
|
|
335
375
|
this.clearRetryTimer()
|
|
336
376
|
|
|
337
|
-
if (!this.
|
|
377
|
+
if (!this.canExecute()) {
|
|
338
378
|
return
|
|
339
379
|
}
|
|
340
380
|
|
|
341
|
-
|
|
342
|
-
const earliestRetryTime = this.getEarliestRetryTime()
|
|
381
|
+
const nextRetryTime = this.getNextRetryTime()
|
|
343
382
|
|
|
344
|
-
if (
|
|
383
|
+
if (nextRetryTime === null) {
|
|
345
384
|
return // No transactions pending retry
|
|
346
385
|
}
|
|
347
386
|
|
|
348
|
-
const delay = Math.max(0,
|
|
387
|
+
const delay = Math.max(0, nextRetryTime - Date.now())
|
|
349
388
|
|
|
350
389
|
this.retryTimer = setTimeout(() => {
|
|
351
390
|
this.executeAll().catch((error) => {
|
|
@@ -354,14 +393,15 @@ export class TransactionExecutor {
|
|
|
354
393
|
}, delay)
|
|
355
394
|
}
|
|
356
395
|
|
|
357
|
-
private
|
|
396
|
+
private getNextRetryTime(): number | null {
|
|
358
397
|
const allTransactions = this.scheduler.getAllPendingTransactions()
|
|
359
398
|
|
|
360
399
|
if (allTransactions.length === 0) {
|
|
361
400
|
return null
|
|
362
401
|
}
|
|
363
402
|
|
|
364
|
-
|
|
403
|
+
// Later transactions cannot overtake the FIFO head, even if they are ready.
|
|
404
|
+
return allTransactions[0]!.nextAttemptAt
|
|
365
405
|
}
|
|
366
406
|
|
|
367
407
|
private clearRetryTimer(): void {
|
|
@@ -371,8 +411,10 @@ export class TransactionExecutor {
|
|
|
371
411
|
}
|
|
372
412
|
}
|
|
373
413
|
|
|
374
|
-
private
|
|
375
|
-
return
|
|
414
|
+
private canExecute(): boolean {
|
|
415
|
+
return (
|
|
416
|
+
this.offlineExecutor.isOfflineEnabled && this.offlineExecutor.isOnline()
|
|
417
|
+
)
|
|
376
418
|
}
|
|
377
419
|
|
|
378
420
|
getRunningCount(): number {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { withSpan } from '../telemetry/tracer'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MissingTemporalConstructorError,
|
|
4
|
+
TransactionSerializer,
|
|
5
|
+
} from './TransactionSerializer'
|
|
3
6
|
import type { OfflineTransaction, StorageAdapter } from '../types'
|
|
4
7
|
import type { Collection } from '@tanstack/db'
|
|
5
8
|
|
|
@@ -7,6 +10,9 @@ export class OutboxManager {
|
|
|
7
10
|
private storage: StorageAdapter
|
|
8
11
|
private serializer: TransactionSerializer
|
|
9
12
|
private keyPrefix = `tx:`
|
|
13
|
+
private removalRevision = 0
|
|
14
|
+
private activeReads = new Set<{ revision: number }>()
|
|
15
|
+
private removedAtRevision = new Map<string, number>()
|
|
10
16
|
|
|
11
17
|
constructor(
|
|
12
18
|
storage: StorageAdapter,
|
|
@@ -21,6 +27,25 @@ export class OutboxManager {
|
|
|
21
27
|
return `${this.keyPrefix}${id}`
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
private recordRemovals(ids: Iterable<string>): void {
|
|
31
|
+
this.removalRevision++
|
|
32
|
+
if (this.activeReads.size === 0) return
|
|
33
|
+
for (const id of ids) this.removedAtRevision.set(id, this.removalRevision)
|
|
34
|
+
this.pruneRemovalHistory()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private pruneRemovalHistory(): void {
|
|
38
|
+
if (this.activeReads.size === 0) {
|
|
39
|
+
this.removedAtRevision.clear()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
let oldestRead = Number.POSITIVE_INFINITY
|
|
43
|
+
for (const read of this.activeReads)
|
|
44
|
+
oldestRead = Math.min(oldestRead, read.revision)
|
|
45
|
+
for (const [id, revision] of this.removedAtRevision)
|
|
46
|
+
if (revision <= oldestRead) this.removedAtRevision.delete(id)
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
async add(transaction: OfflineTransaction): Promise<void> {
|
|
25
50
|
return withSpan(
|
|
26
51
|
`outbox.add`,
|
|
@@ -52,6 +77,10 @@ export class OutboxManager {
|
|
|
52
77
|
span.setAttribute(`result`, `found`)
|
|
53
78
|
return transaction
|
|
54
79
|
} catch (error) {
|
|
80
|
+
if (error instanceof MissingTemporalConstructorError) {
|
|
81
|
+
error.message = `transaction ${id}: ${error.message}`
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
55
84
|
console.warn(`Failed to deserialize transaction ${id}:`, error)
|
|
56
85
|
span.setAttribute(`result`, `deserialize_error`)
|
|
57
86
|
return null
|
|
@@ -60,35 +89,57 @@ export class OutboxManager {
|
|
|
60
89
|
}
|
|
61
90
|
|
|
62
91
|
async getAll(): Promise<Array<OfflineTransaction>> {
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
return this.withAll((transactions) => transactions)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async withAll<T>(
|
|
96
|
+
consume: (transactions: Array<OfflineTransaction>) => T,
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
const read = { revision: this.removalRevision }
|
|
99
|
+
this.activeReads.add(read)
|
|
100
|
+
try {
|
|
101
|
+
return await withSpan(`outbox.getAll`, {}, async (span) => {
|
|
102
|
+
const keys = await this.storage.keys()
|
|
103
|
+
const transactionKeys = keys.filter((key) =>
|
|
104
|
+
key.startsWith(this.keyPrefix),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
span.setAttribute(`transactionCount`, transactionKeys.length)
|
|
108
|
+
|
|
109
|
+
const transactions: Array<OfflineTransaction> = []
|
|
110
|
+
|
|
111
|
+
for (const key of transactionKeys) {
|
|
112
|
+
const data = await this.storage.get(key)
|
|
113
|
+
if (data) {
|
|
114
|
+
try {
|
|
115
|
+
const transaction = this.serializer.deserialize(data)
|
|
116
|
+
transactions.push(transaction)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof MissingTemporalConstructorError) {
|
|
119
|
+
error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
console.warn(
|
|
123
|
+
`Failed to deserialize transaction from key ${key}:`,
|
|
124
|
+
error,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
84
127
|
}
|
|
85
128
|
}
|
|
86
|
-
}
|
|
87
129
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
130
|
+
const currentTransactions = transactions
|
|
131
|
+
.filter(
|
|
132
|
+
({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,
|
|
133
|
+
)
|
|
134
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
|
135
|
+
// Keep the read registered until replay admission completes. A durable
|
|
136
|
+
// removal cannot land between filtering and this synchronous consumer.
|
|
137
|
+
return consume(currentTransactions)
|
|
138
|
+
})
|
|
139
|
+
} finally {
|
|
140
|
+
this.activeReads.delete(read)
|
|
141
|
+
this.pruneRemovalHistory()
|
|
142
|
+
}
|
|
92
143
|
}
|
|
93
144
|
|
|
94
145
|
async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> {
|
|
@@ -119,6 +170,7 @@ export class OutboxManager {
|
|
|
119
170
|
return withSpan(`outbox.remove`, { 'transaction.id': id }, async () => {
|
|
120
171
|
const key = this.getStorageKey(id)
|
|
121
172
|
await this.storage.delete(key)
|
|
173
|
+
this.recordRemovals([id])
|
|
122
174
|
})
|
|
123
175
|
}
|
|
124
176
|
|
|
@@ -132,7 +184,9 @@ export class OutboxManager {
|
|
|
132
184
|
const keys = await this.storage.keys()
|
|
133
185
|
const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix))
|
|
134
186
|
|
|
135
|
-
await
|
|
187
|
+
await this.removeMany(
|
|
188
|
+
transactionKeys.map((key) => key.slice(this.keyPrefix.length)),
|
|
189
|
+
)
|
|
136
190
|
}
|
|
137
191
|
|
|
138
192
|
async count(): Promise<number> {
|