@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.
Files changed (64) hide show
  1. package/README.md +137 -23
  2. package/dist/cjs/OfflineExecutor.cjs +28 -11
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/OfflineExecutor.d.cts +1 -0
  5. package/dist/cjs/api/OfflineTransaction.cjs +4 -1
  6. package/dist/cjs/api/OfflineTransaction.cjs.map +1 -1
  7. package/dist/cjs/connectivity/OnlineDetector.cjs +1 -1
  8. package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -1
  9. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +1 -7
  10. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
  11. package/dist/cjs/executor/KeyScheduler.cjs +31 -26
  12. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
  13. package/dist/cjs/executor/KeyScheduler.d.cts +5 -3
  14. package/dist/cjs/executor/TransactionExecutor.cjs +72 -35
  15. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  16. package/dist/cjs/executor/TransactionExecutor.d.cts +3 -2
  17. package/dist/cjs/outbox/OutboxManager.cjs +66 -24
  18. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
  19. package/dist/cjs/outbox/OutboxManager.d.cts +6 -0
  20. package/dist/cjs/outbox/TransactionSerializer.cjs +159 -20
  21. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
  22. package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
  23. package/dist/cjs/storage/IndexedDBAdapter.cjs +16 -12
  24. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
  25. package/dist/cjs/storage/IndexedDBAdapter.d.cts +1 -0
  26. package/dist/cjs/types.cjs.map +1 -1
  27. package/dist/cjs/types.d.cts +3 -0
  28. package/dist/esm/OfflineExecutor.d.ts +1 -0
  29. package/dist/esm/OfflineExecutor.js +28 -11
  30. package/dist/esm/OfflineExecutor.js.map +1 -1
  31. package/dist/esm/api/OfflineTransaction.js +4 -1
  32. package/dist/esm/api/OfflineTransaction.js.map +1 -1
  33. package/dist/esm/connectivity/OnlineDetector.js +1 -1
  34. package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
  35. package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
  36. package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
  37. package/dist/esm/executor/KeyScheduler.d.ts +5 -3
  38. package/dist/esm/executor/KeyScheduler.js +31 -26
  39. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  40. package/dist/esm/executor/TransactionExecutor.d.ts +3 -2
  41. package/dist/esm/executor/TransactionExecutor.js +72 -35
  42. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  43. package/dist/esm/outbox/OutboxManager.d.ts +6 -0
  44. package/dist/esm/outbox/OutboxManager.js +67 -25
  45. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  46. package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
  47. package/dist/esm/outbox/TransactionSerializer.js +159 -20
  48. package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
  49. package/dist/esm/storage/IndexedDBAdapter.d.ts +1 -0
  50. package/dist/esm/storage/IndexedDBAdapter.js +16 -12
  51. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
  52. package/dist/esm/types.d.ts +3 -0
  53. package/dist/esm/types.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/OfflineExecutor.ts +34 -15
  56. package/src/api/OfflineTransaction.ts +7 -2
  57. package/src/connectivity/OnlineDetector.ts +6 -1
  58. package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
  59. package/src/executor/KeyScheduler.ts +39 -28
  60. package/src/executor/TransactionExecutor.ts +88 -46
  61. package/src/outbox/OutboxManager.ts +82 -28
  62. package/src/outbox/TransactionSerializer.ts +236 -22
  63. package/src/storage/IndexedDBAdapter.ts +23 -12
  64. 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 isRunning = false
6
+ private activeTransactionId: string | undefined
7
7
 
8
- schedule(transaction: OfflineTransaction): void {
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 (this.isRunning || this.pendingTransactions.length === 0) {
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(_transaction: OfflineTransaction): void {
55
- this.isRunning = true
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.isRunning = false
71
+ this.activeTransactionId = undefined
61
72
  }
62
73
 
63
74
  markFailed(_transaction: OfflineTransaction): void {
64
- this.isRunning = false
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
- const index = this.pendingTransactions.findIndex(
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.isRunning ? 1 : 0
96
+ return this.activeTransactionId === undefined ? 0 : 1
95
97
  }
96
98
 
97
99
  clear(): void {
98
100
  this.pendingTransactions = []
99
- this.isRunning = false
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
- for (const updatedTx of updatedTransactions) {
108
- const index = this.pendingTransactions.findIndex(
109
- (tx) => tx.id === updatedTx.id,
110
- )
111
- if (index >= 0) {
112
- this.pendingTransactions[index] = updatedTx
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.isOnline()) {
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
- const result = await this.runMutationFn(transaction)
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
- this.scheduler.markCompleted(transaction)
180
- await this.outbox.remove(transaction.id)
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
- const transactions = await this.outbox.getAll()
230
- let filteredTransactions = transactions
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
- if (this.config.beforeRetry) {
233
- filteredTransactions = this.config.beforeRetry(transactions)
234
- }
251
+ // The retry hook is user code and may synchronously revoke replay rights.
252
+ if (!this.offlineExecutor.isOfflineEnabled) return
235
253
 
236
- for (const transaction of filteredTransactions) {
237
- this.scheduler.schedule(transaction)
238
- }
254
+ const newlyLoaded = filteredTransactions.filter((transaction) =>
255
+ this.scheduler.schedule(transaction),
256
+ )
239
257
 
240
- // Restore optimistic state for loaded transactions
241
- // This ensures the UI shows the optimistic data while transactions are pending
242
- this.restoreOptimisticState(filteredTransactions)
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
- // Reset retry delays for all loaded transactions so they can run immediately
245
- this.resetRetryDelays()
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
- // Schedule retry timer for loaded transactions
248
- this.scheduleNextRetry()
268
+ // Reset retry delays for all loaded transactions so they can run immediately
269
+ this.resetRetryDelays()
249
270
 
250
- const removedTransactions = transactions.filter(
251
- (tx) => !filteredTransactions.some((filtered) => filtered.id === tx.id),
252
- )
271
+ // Schedule retry timer for loaded transactions
272
+ this.scheduleNextRetry()
273
+ })
253
274
 
254
- if (removedTransactions.length > 0) {
255
- await this.outbox.removeMany(removedTransactions.map((tx) => tx.id))
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.isOnline()) {
377
+ if (!this.canExecute()) {
338
378
  return
339
379
  }
340
380
 
341
- // Find the earliest retry time among pending transactions
342
- const earliestRetryTime = this.getEarliestRetryTime()
381
+ const nextRetryTime = this.getNextRetryTime()
343
382
 
344
- if (earliestRetryTime === null) {
383
+ if (nextRetryTime === null) {
345
384
  return // No transactions pending retry
346
385
  }
347
386
 
348
- const delay = Math.max(0, earliestRetryTime - Date.now())
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 getEarliestRetryTime(): number | null {
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
- return Math.min(...allTransactions.map((tx) => tx.nextAttemptAt))
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 isOnline(): boolean {
375
- return this.offlineExecutor.isOnline()
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 { TransactionSerializer } from './TransactionSerializer'
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 withSpan(`outbox.getAll`, {}, async (span) => {
64
- const keys = await this.storage.keys()
65
- const transactionKeys = keys.filter((key) =>
66
- key.startsWith(this.keyPrefix),
67
- )
68
-
69
- span.setAttribute(`transactionCount`, transactionKeys.length)
70
-
71
- const transactions: Array<OfflineTransaction> = []
72
-
73
- for (const key of transactionKeys) {
74
- const data = await this.storage.get(key)
75
- if (data) {
76
- try {
77
- const transaction = this.serializer.deserialize(data)
78
- transactions.push(transaction)
79
- } catch (error) {
80
- console.warn(
81
- `Failed to deserialize transaction from key ${key}:`,
82
- error,
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
- return transactions.sort(
89
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
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 Promise.all(transactionKeys.map((key) => this.storage.delete(key)))
187
+ await this.removeMany(
188
+ transactionKeys.map((key) => key.slice(this.keyPrefix.length)),
189
+ )
136
190
  }
137
191
 
138
192
  async count(): Promise<number> {