@tanstack/offline-transactions 1.0.56 → 1.0.59

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 (60) hide show
  1. package/README.md +157 -23
  2. package/dist/cjs/OfflineExecutor.cjs +6 -3
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/connectivity/OnlineDetector.cjs +1 -1
  5. package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -1
  6. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +1 -7
  7. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
  8. package/dist/cjs/executor/KeyScheduler.cjs +24 -25
  9. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
  10. package/dist/cjs/executor/KeyScheduler.d.cts +4 -2
  11. package/dist/cjs/executor/TransactionExecutor.cjs +134 -35
  12. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  13. package/dist/cjs/executor/TransactionExecutor.d.cts +4 -0
  14. package/dist/cjs/outbox/OutboxManager.cjs +70 -25
  15. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
  16. package/dist/cjs/outbox/OutboxManager.d.cts +8 -0
  17. package/dist/cjs/outbox/TransactionSerializer.cjs +140 -22
  18. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
  19. package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
  20. package/dist/cjs/storage/IndexedDBAdapter.cjs +1 -0
  21. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
  22. package/dist/cjs/storage/LocalStorageAdapter.cjs +1 -1
  23. package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -1
  24. package/dist/cjs/types.cjs.map +1 -1
  25. package/dist/cjs/types.d.cts +5 -1
  26. package/dist/esm/OfflineExecutor.js +6 -3
  27. package/dist/esm/OfflineExecutor.js.map +1 -1
  28. package/dist/esm/connectivity/OnlineDetector.js +1 -1
  29. package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
  30. package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
  31. package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
  32. package/dist/esm/executor/KeyScheduler.d.ts +4 -2
  33. package/dist/esm/executor/KeyScheduler.js +24 -25
  34. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  35. package/dist/esm/executor/TransactionExecutor.d.ts +4 -0
  36. package/dist/esm/executor/TransactionExecutor.js +134 -35
  37. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  38. package/dist/esm/outbox/OutboxManager.d.ts +8 -0
  39. package/dist/esm/outbox/OutboxManager.js +72 -27
  40. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  41. package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
  42. package/dist/esm/outbox/TransactionSerializer.js +140 -22
  43. package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
  44. package/dist/esm/storage/IndexedDBAdapter.js +1 -0
  45. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
  46. package/dist/esm/storage/LocalStorageAdapter.js +1 -1
  47. package/dist/esm/storage/LocalStorageAdapter.js.map +1 -1
  48. package/dist/esm/types.d.ts +5 -1
  49. package/dist/esm/types.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/OfflineExecutor.ts +6 -4
  52. package/src/connectivity/OnlineDetector.ts +6 -1
  53. package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
  54. package/src/executor/KeyScheduler.ts +29 -26
  55. package/src/executor/TransactionExecutor.ts +164 -50
  56. package/src/outbox/OutboxManager.ts +85 -29
  57. package/src/outbox/TransactionSerializer.ts +209 -26
  58. package/src/storage/IndexedDBAdapter.ts +1 -0
  59. package/src/storage/LocalStorageAdapter.ts +1 -1
  60. package/src/types.ts +5 -1
@@ -3,7 +3,7 @@ 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
8
  schedule(transaction: OfflineTransaction): boolean {
9
9
  return withSyncSpan(
@@ -35,7 +35,10 @@ export class KeyScheduler {
35
35
  `scheduler.getNext`,
36
36
  { pendingCount: this.pendingTransactions.length },
37
37
  (span) => {
38
- if (this.isRunning || this.pendingTransactions.length === 0) {
38
+ if (
39
+ this.activeTransactionId !== undefined ||
40
+ this.pendingTransactions.length === 0
41
+ ) {
39
42
  span.setAttribute(`result`, `empty`)
40
43
  return undefined
41
44
  }
@@ -59,17 +62,17 @@ export class KeyScheduler {
59
62
  return Date.now() >= transaction.nextAttemptAt
60
63
  }
61
64
 
62
- markStarted(_transaction: OfflineTransaction): void {
63
- this.isRunning = true
65
+ markStarted(transaction: OfflineTransaction): void {
66
+ this.activeTransactionId = transaction.id
64
67
  }
65
68
 
66
69
  markCompleted(transaction: OfflineTransaction): void {
67
70
  this.removeTransaction(transaction)
68
- this.isRunning = false
71
+ this.activeTransactionId = undefined
69
72
  }
70
73
 
71
74
  markFailed(_transaction: OfflineTransaction): void {
72
- this.isRunning = false
75
+ this.activeTransactionId = undefined
73
76
  }
74
77
 
75
78
  private removeTransaction(transaction: OfflineTransaction): void {
@@ -82,16 +85,7 @@ export class KeyScheduler {
82
85
  }
83
86
 
84
87
  updateTransaction(transaction: OfflineTransaction): void {
85
- const index = this.pendingTransactions.findIndex(
86
- (tx) => tx.id === transaction.id,
87
- )
88
- if (index >= 0) {
89
- this.pendingTransactions[index] = transaction
90
- // Re-sort to maintain FIFO order after update
91
- this.pendingTransactions.sort(
92
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
93
- )
94
- }
88
+ this.updateTransactions([transaction])
95
89
  }
96
90
 
97
91
  getPendingCount(): number {
@@ -99,12 +93,23 @@ export class KeyScheduler {
99
93
  }
100
94
 
101
95
  getRunningCount(): number {
102
- return this.isRunning ? 1 : 0
96
+ return this.activeTransactionId === undefined ? 0 : 1
103
97
  }
104
98
 
105
99
  clear(): void {
106
100
  this.pendingTransactions = []
107
- 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]
108
113
  }
109
114
 
110
115
  getAllPendingTransactions(): Array<OfflineTransaction> {
@@ -112,14 +117,12 @@ export class KeyScheduler {
112
117
  }
113
118
 
114
119
  updateTransactions(updatedTransactions: Array<OfflineTransaction>): void {
115
- for (const updatedTx of updatedTransactions) {
116
- const index = this.pendingTransactions.findIndex(
117
- (tx) => tx.id === updatedTx.id,
118
- )
119
- if (index >= 0) {
120
- this.pendingTransactions[index] = updatedTx
121
- }
122
- }
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
+ )
123
126
  // Re-sort to maintain FIFO order after updates
124
127
  this.pendingTransactions.sort(
125
128
  (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
@@ -1,4 +1,5 @@
1
1
  import { createTransaction } from '@tanstack/db'
2
+ import { OutboxTransactionNotFoundError } from '../outbox/OutboxManager'
2
3
  import { DefaultRetryPolicy } from '../retry/RetryPolicy'
3
4
  import { NonRetriableError } from '../types'
4
5
  import { withNestedSpan } from '../telemetry/tracer'
@@ -21,6 +22,7 @@ export class TransactionExecutor {
21
22
  private executionPromise: Promise<void> | null = null
22
23
  private offlineExecutor: TransactionSignaler
23
24
  private retryTimer: ReturnType<typeof setTimeout> | null = null
25
+ private fatalError: Error | null = null
24
26
 
25
27
  constructor(
26
28
  scheduler: KeyScheduler,
@@ -39,11 +41,13 @@ export class TransactionExecutor {
39
41
  }
40
42
 
41
43
  async execute(transaction: OfflineTransaction): Promise<void> {
44
+ this.assertHealthy()
42
45
  this.scheduler.schedule(transaction)
43
46
  await this.executeAll()
44
47
  }
45
48
 
46
49
  async executeAll(): Promise<void> {
50
+ this.assertHealthy()
47
51
  if (this.isExecuting) {
48
52
  return this.executionPromise!
49
53
  }
@@ -56,6 +60,7 @@ export class TransactionExecutor {
56
60
  } finally {
57
61
  this.isExecuting = false
58
62
  this.executionPromise = null
63
+ this.scheduleNextRetry()
59
64
  }
60
65
  }
61
66
 
@@ -73,9 +78,6 @@ export class TransactionExecutor {
73
78
 
74
79
  await this.executeTransaction(transaction)
75
80
  }
76
-
77
- // Schedule next retry after execution completes
78
- this.scheduleNextRetry()
79
81
  }
80
82
 
81
83
  private async executeTransaction(
@@ -91,33 +93,61 @@ export class TransactionExecutor {
91
93
  'transaction.keyCount': transaction.keys.length,
92
94
  },
93
95
  async (span) => {
96
+ const terminalError =
97
+ transaction.outboxPhase === `rejection-pending`
98
+ ? this.getTerminalError(transaction)
99
+ : undefined
94
100
  this.scheduler.markStarted(transaction)
95
101
 
96
102
  if (transaction.retryCount > 0) {
97
103
  span.setAttribute(`retry.attempt`, transaction.retryCount)
98
104
  }
99
105
 
100
- try {
101
- const result = await this.runMutationFn(transaction)
102
-
106
+ if (!transaction.outboxPhase) {
103
107
  try {
104
- // Replay can still see this ID until durable deletion settles.
105
- await this.outbox.remove(transaction.id)
106
- } finally {
107
- this.scheduler.markCompleted(transaction)
108
+ await this.runMutationFn(transaction)
109
+ } catch (error) {
110
+ const err =
111
+ error instanceof Error ? error : new Error(String(error))
112
+
113
+ span.setAttribute(`result`, `error`)
114
+
115
+ await this.handleError(transaction, err)
116
+ ;(err as any)[HANDLED_EXECUTION_ERROR] = true
117
+ throw err
108
118
  }
119
+ }
109
120
 
110
- span.setAttribute(`result`, `success`)
111
- this.offlineExecutor.resolveTransaction(transaction.id, result)
121
+ // A successful provider call can outlive this executor. Persist that
122
+ // fact before removal so a restarted executor only retries deletion.
123
+ const deletionPending: OfflineTransaction = transaction.outboxPhase
124
+ ? transaction
125
+ : { ...transaction, outboxPhase: `deletion-pending` }
126
+ try {
127
+ await this.removeSettledTransaction(
128
+ deletionPending,
129
+ !transaction.outboxPhase,
130
+ )
112
131
  } catch (error) {
113
- const err =
114
- error instanceof Error ? error : new Error(String(error))
115
-
116
- span.setAttribute(`result`, `error`)
132
+ const storageError = error as Error
133
+ span.recordException(storageError)
134
+ span.setAttribute(`result`, `outbox_failure`)
135
+ this.offlineExecutor.rejectTransaction(
136
+ transaction.id,
137
+ terminalError ?? storageError,
138
+ )
139
+ throw storageError
140
+ }
117
141
 
118
- await this.handleError(transaction, err)
119
- ;(err as any)[HANDLED_EXECUTION_ERROR] = true
120
- throw err
142
+ if (terminalError) {
143
+ span.setAttribute(`result`, `permanent_failure`)
144
+ this.offlineExecutor.rejectTransaction(
145
+ transaction.id,
146
+ terminalError,
147
+ )
148
+ } else {
149
+ span.setAttribute(`result`, `success`)
150
+ this.offlineExecutor.resolveTransaction(transaction.id, undefined)
121
151
  }
122
152
  },
123
153
  )
@@ -180,15 +210,29 @@ export class TransactionExecutor {
180
210
  span.setAttribute(`shouldRetry`, shouldRetry)
181
211
 
182
212
  if (!shouldRetry) {
183
- this.scheduler.markCompleted(transaction)
184
- await this.outbox.remove(transaction.id)
213
+ const rejectionPending: OfflineTransaction = {
214
+ ...transaction,
215
+ outboxPhase: `rejection-pending`,
216
+ lastError: {
217
+ name: error.name,
218
+ message: error.message,
219
+ stack: error.stack,
220
+ },
221
+ }
185
222
  console.warn(
186
223
  `Transaction ${transaction.id} failed permanently:`,
187
224
  error,
188
225
  )
226
+ try {
227
+ await this.removeSettledTransaction(rejectionPending, true)
228
+ } catch (storageError) {
229
+ span.recordException(storageError as Error)
230
+ span.setAttribute(`result`, `outbox_failure`)
231
+ this.offlineExecutor.rejectTransaction(transaction.id, error)
232
+ throw storageError
233
+ }
189
234
 
190
235
  span.setAttribute(`result`, `permanent_failure`)
191
- // Signal permanent failure to the waiting transaction
192
236
  this.offlineExecutor.rejectTransaction(transaction.id, error)
193
237
  return
194
238
  }
@@ -211,7 +255,6 @@ export class TransactionExecutor {
211
255
  span.setAttribute(`retryDelay`, delay)
212
256
  span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
213
257
 
214
- this.scheduler.markFailed(transaction)
215
258
  this.scheduler.updateTransaction(updatedTransaction)
216
259
 
217
260
  try {
@@ -221,45 +264,116 @@ export class TransactionExecutor {
221
264
  span.recordException(persistError as Error)
222
265
  span.setAttribute(`result`, `persist_failed`)
223
266
  throw persistError
267
+ } finally {
268
+ this.scheduler.markFailed(transaction)
224
269
  }
225
-
226
- // Schedule retry timer
227
- this.scheduleNextRetry()
228
270
  },
229
271
  )
230
272
  }
231
273
 
232
- async loadPendingTransactions(): Promise<void> {
233
- const transactions = await this.outbox.getAll()
234
- let filteredTransactions = transactions
235
-
236
- if (this.config.beforeRetry) {
237
- filteredTransactions = this.config.beforeRetry(transactions)
274
+ private async removeSettledTransaction(
275
+ transaction: OfflineTransaction,
276
+ persistMarker: boolean,
277
+ ): Promise<void> {
278
+ try {
279
+ if (persistMarker) {
280
+ try {
281
+ await this.outbox.update(transaction.id, transaction)
282
+ } catch (error) {
283
+ // A public removal can finish while the provider is still running.
284
+ if (!(error instanceof OutboxTransactionNotFoundError)) throw error
285
+ }
286
+ }
287
+ await this.outbox.remove(transaction.id)
288
+ this.scheduler.markCompleted(transaction)
289
+ } catch (error) {
290
+ const storageError =
291
+ error instanceof Error ? error : new Error(String(error))
292
+ this.fatalError = storageError
293
+ this.scheduler.markFailed(transaction)
294
+ this.clearRetryTimer()
295
+ throw storageError
238
296
  }
297
+ }
239
298
 
240
- // The outbox read or retry hook may outlive this owner's right to replay.
241
- if (!this.offlineExecutor.isOfflineEnabled) return
299
+ assertHealthy(): void {
300
+ if (this.fatalError) throw this.fatalError
301
+ }
242
302
 
243
- const newlyLoaded = filteredTransactions.filter((transaction) =>
244
- this.scheduler.schedule(transaction),
245
- )
303
+ private getTerminalError(transaction: OfflineTransaction): Error {
304
+ if (!transaction.lastError)
305
+ throw new Error(`Terminal transaction ${transaction.id} has no error`)
306
+ const { name, message, stack } = transaction.lastError
307
+ const error =
308
+ name === `NonRetriableError`
309
+ ? new NonRetriableError(message)
310
+ : new Error(message)
311
+ error.name = name
312
+ error.stack = stack
313
+ return error
314
+ }
315
+
316
+ async loadPendingTransactions(): Promise<void> {
317
+ let removedIds: Array<string> = []
318
+ await this.outbox.withAll((transactions) => {
319
+ const { isOfflineEnabled } = this.offlineExecutor
320
+ if (!isOfflineEnabled) return
321
+ const providerPending = transactions.filter(
322
+ (transaction) => !transaction.outboxPhase,
323
+ )
324
+ const selectedProvider = this.config.beforeRetry
325
+ ? this.config.beforeRetry(providerPending)
326
+ : providerPending
327
+ const selectedById = new Map(selectedProvider.map((tx) => [tx.id, tx]))
328
+ const filteredTransactions = transactions.flatMap((transaction) => {
329
+ if (transaction.outboxPhase) return [transaction]
330
+ const selected = selectedById.get(transaction.id)
331
+ return selected ? [selected] : []
332
+ })
246
333
 
247
- // Restore optimistic state for loaded transactions
248
- // This ensures the UI shows the optimistic data while transactions are pending
249
- this.restoreOptimisticState(newlyLoaded)
334
+ // The retry hook is user code and may synchronously revoke replay rights.
335
+ if (!this.offlineExecutor.isOfflineEnabled) return
250
336
 
251
- // Reset retry delays for all loaded transactions so they can run immediately
252
- this.resetRetryDelays()
337
+ const newlyLoaded = filteredTransactions.filter((transaction) =>
338
+ this.scheduler.schedule(transaction),
339
+ )
253
340
 
254
- // Schedule retry timer for loaded transactions
255
- this.scheduleNextRetry()
341
+ removedIds = providerPending
342
+ .filter(({ id }) => !selectedById.has(id))
343
+ .map(({ id }) => id)
344
+ removedIds = this.scheduler.removePendingTransactions(removedIds)
345
+
346
+ // Restore optimistic state for loaded transactions
347
+ // This ensures the UI shows the optimistic data while transactions are pending
348
+ this.restoreOptimisticState(
349
+ newlyLoaded.filter(
350
+ (transaction) => transaction.outboxPhase !== `rejection-pending`,
351
+ ),
352
+ )
256
353
 
257
- const removedTransactions = transactions.filter(
258
- (tx) => !filteredTransactions.some((filtered) => filtered.id === tx.id),
259
- )
354
+ // Reset retry delays for all loaded transactions so they can run immediately
355
+ this.resetRetryDelays()
260
356
 
261
- if (removedTransactions.length > 0) {
262
- await this.outbox.removeMany(removedTransactions.map((tx) => tx.id))
357
+ // Schedule retry timer for loaded transactions
358
+ this.scheduleNextRetry()
359
+ })
360
+
361
+ if (removedIds.length > 0) {
362
+ const error = new NonRetriableError(`Transaction excluded by beforeRetry`)
363
+ await Promise.all(
364
+ removedIds.map(async (id) => {
365
+ try {
366
+ await this.outbox.remove(id)
367
+ this.offlineExecutor.rejectTransaction(id, error)
368
+ } catch (cleanupError) {
369
+ console.warn(
370
+ `Failed to remove transaction excluded by beforeRetry:`,
371
+ id,
372
+ cleanupError,
373
+ )
374
+ }
375
+ }),
376
+ )
263
377
  }
264
378
  }
265
379
 
@@ -346,7 +460,7 @@ export class TransactionExecutor {
346
460
  // Clear existing timer
347
461
  this.clearRetryTimer()
348
462
 
349
- if (!this.canExecute()) {
463
+ if (this.fatalError || !this.canExecute()) {
350
464
  return
351
465
  }
352
466
 
@@ -1,12 +1,20 @@
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
 
9
+ export class OutboxTransactionNotFoundError extends Error {}
10
+
6
11
  export class OutboxManager {
7
12
  private storage: StorageAdapter
8
13
  private serializer: TransactionSerializer
9
14
  private keyPrefix = `tx:`
15
+ private removalRevision = 0
16
+ private activeReads = new Set<{ revision: number }>()
17
+ private removedAtRevision = new Map<string, number>()
10
18
 
11
19
  constructor(
12
20
  storage: StorageAdapter,
@@ -21,6 +29,25 @@ export class OutboxManager {
21
29
  return `${this.keyPrefix}${id}`
22
30
  }
23
31
 
32
+ private recordRemovals(ids: Iterable<string>): void {
33
+ this.removalRevision++
34
+ if (this.activeReads.size === 0) return
35
+ for (const id of ids) this.removedAtRevision.set(id, this.removalRevision)
36
+ this.pruneRemovalHistory()
37
+ }
38
+
39
+ private pruneRemovalHistory(): void {
40
+ if (this.activeReads.size === 0) {
41
+ this.removedAtRevision.clear()
42
+ return
43
+ }
44
+ let oldestRead = Number.POSITIVE_INFINITY
45
+ for (const read of this.activeReads)
46
+ oldestRead = Math.min(oldestRead, read.revision)
47
+ for (const [id, revision] of this.removedAtRevision)
48
+ if (revision <= oldestRead) this.removedAtRevision.delete(id)
49
+ }
50
+
24
51
  async add(transaction: OfflineTransaction): Promise<void> {
25
52
  return withSpan(
26
53
  `outbox.add`,
@@ -52,6 +79,10 @@ export class OutboxManager {
52
79
  span.setAttribute(`result`, `found`)
53
80
  return transaction
54
81
  } catch (error) {
82
+ if (error instanceof MissingTemporalConstructorError) {
83
+ error.message = `transaction ${id}: ${error.message}`
84
+ throw error
85
+ }
55
86
  console.warn(`Failed to deserialize transaction ${id}:`, error)
56
87
  span.setAttribute(`result`, `deserialize_error`)
57
88
  return null
@@ -60,35 +91,57 @@ export class OutboxManager {
60
91
  }
61
92
 
62
93
  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
- )
94
+ return this.withAll((transactions) => transactions)
95
+ }
96
+
97
+ async withAll<T>(
98
+ consume: (transactions: Array<OfflineTransaction>) => T,
99
+ ): Promise<T> {
100
+ const read = { revision: this.removalRevision }
101
+ this.activeReads.add(read)
102
+ try {
103
+ return await withSpan(`outbox.getAll`, {}, async (span) => {
104
+ const keys = await this.storage.keys()
105
+ const transactionKeys = keys.filter((key) =>
106
+ key.startsWith(this.keyPrefix),
107
+ )
108
+
109
+ span.setAttribute(`transactionCount`, transactionKeys.length)
110
+
111
+ const transactions: Array<OfflineTransaction> = []
112
+
113
+ for (const key of transactionKeys) {
114
+ const data = await this.storage.get(key)
115
+ if (data) {
116
+ try {
117
+ const transaction = this.serializer.deserialize(data)
118
+ transactions.push(transaction)
119
+ } catch (error) {
120
+ if (error instanceof MissingTemporalConstructorError) {
121
+ error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`
122
+ throw error
123
+ }
124
+ console.warn(
125
+ `Failed to deserialize transaction from key ${key}:`,
126
+ error,
127
+ )
128
+ }
84
129
  }
85
130
  }
86
- }
87
131
 
88
- return transactions.sort(
89
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
90
- )
91
- })
132
+ const currentTransactions = transactions
133
+ .filter(
134
+ ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,
135
+ )
136
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
137
+ // Keep the read registered until replay admission completes. A durable
138
+ // removal cannot land between filtering and this synchronous consumer.
139
+ return consume(currentTransactions)
140
+ })
141
+ } finally {
142
+ this.activeReads.delete(read)
143
+ this.pruneRemovalHistory()
144
+ }
92
145
  }
93
146
 
94
147
  async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> {
@@ -107,7 +160,7 @@ export class OutboxManager {
107
160
  return withSpan(`outbox.update`, { 'transaction.id': id }, async () => {
108
161
  const existing = await this.get(id)
109
162
  if (!existing) {
110
- throw new Error(`Transaction ${id} not found`)
163
+ throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)
111
164
  }
112
165
 
113
166
  const updated = { ...existing, ...updates }
@@ -119,6 +172,7 @@ export class OutboxManager {
119
172
  return withSpan(`outbox.remove`, { 'transaction.id': id }, async () => {
120
173
  const key = this.getStorageKey(id)
121
174
  await this.storage.delete(key)
175
+ this.recordRemovals([id])
122
176
  })
123
177
  }
124
178
 
@@ -132,7 +186,9 @@ export class OutboxManager {
132
186
  const keys = await this.storage.keys()
133
187
  const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix))
134
188
 
135
- await Promise.all(transactionKeys.map((key) => this.storage.delete(key)))
189
+ await this.removeMany(
190
+ transactionKeys.map((key) => key.slice(this.keyPrefix.length)),
191
+ )
136
192
  }
137
193
 
138
194
  async count(): Promise<number> {