@tanstack/offline-transactions 1.0.65 → 1.1.1

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 (52) hide show
  1. package/README.md +81 -2
  2. package/dist/cjs/OfflineExecutor.cjs +74 -93
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/OfflineExecutor.d.cts +6 -0
  5. package/dist/cjs/api/OfflineAction.cjs +4 -15
  6. package/dist/cjs/api/OfflineAction.cjs.map +1 -1
  7. package/dist/cjs/api/OfflineTransaction.cjs +54 -51
  8. package/dist/cjs/api/OfflineTransaction.cjs.map +1 -1
  9. package/dist/cjs/api/OfflineTransaction.d.cts +2 -0
  10. package/dist/cjs/executor/KeyScheduler.cjs +12 -38
  11. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
  12. package/dist/cjs/executor/TransactionExecutor.cjs +119 -134
  13. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  14. package/dist/cjs/outbox/OutboxManager.cjs +48 -77
  15. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
  16. package/dist/cjs/types.cjs.map +1 -1
  17. package/dist/cjs/types.d.cts +10 -0
  18. package/dist/esm/OfflineExecutor.d.ts +6 -0
  19. package/dist/esm/OfflineExecutor.js +74 -93
  20. package/dist/esm/OfflineExecutor.js.map +1 -1
  21. package/dist/esm/api/OfflineAction.js +4 -15
  22. package/dist/esm/api/OfflineAction.js.map +1 -1
  23. package/dist/esm/api/OfflineTransaction.d.ts +2 -0
  24. package/dist/esm/api/OfflineTransaction.js +54 -51
  25. package/dist/esm/api/OfflineTransaction.js.map +1 -1
  26. package/dist/esm/executor/KeyScheduler.js +12 -38
  27. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  28. package/dist/esm/executor/TransactionExecutor.js +120 -135
  29. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  30. package/dist/esm/outbox/OutboxManager.js +48 -77
  31. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  32. package/dist/esm/types.d.ts +10 -0
  33. package/dist/esm/types.js.map +1 -1
  34. package/package.json +2 -2
  35. package/skills/offline/SKILL.md +73 -5
  36. package/src/OfflineExecutor.ts +97 -113
  37. package/src/api/OfflineAction.ts +4 -22
  38. package/src/api/OfflineTransaction.ts +78 -64
  39. package/src/executor/KeyScheduler.ts +18 -47
  40. package/src/executor/TransactionExecutor.ts +148 -162
  41. package/src/outbox/OutboxManager.ts +54 -86
  42. package/src/types.ts +10 -0
  43. package/dist/cjs/retry/NonRetriableError.d.cts +0 -1
  44. package/dist/cjs/telemetry/tracer.cjs +0 -27
  45. package/dist/cjs/telemetry/tracer.cjs.map +0 -1
  46. package/dist/cjs/telemetry/tracer.d.cts +0 -30
  47. package/dist/esm/retry/NonRetriableError.d.ts +0 -1
  48. package/dist/esm/telemetry/tracer.d.ts +0 -30
  49. package/dist/esm/telemetry/tracer.js +0 -27
  50. package/dist/esm/telemetry/tracer.js.map +0 -1
  51. package/src/retry/NonRetriableError.ts +0 -1
  52. package/src/telemetry/tracer.ts +0 -66
@@ -1,4 +1,3 @@
1
- import { withSyncSpan } from '../telemetry/tracer'
2
1
  import type { OfflineTransaction } from '../types'
3
2
 
4
3
  export class KeyScheduler {
@@ -6,56 +5,28 @@ export class KeyScheduler {
6
5
  private activeTransactionId: string | undefined
7
6
 
8
7
  schedule(transaction: OfflineTransaction): boolean {
9
- return withSyncSpan(
10
- `scheduler.schedule`,
11
- {
12
- 'transaction.id': transaction.id,
13
- queueLength: this.pendingTransactions.length,
14
- },
15
- () => {
16
- if (
17
- this.pendingTransactions.some(
18
- (pending) => pending.id === transaction.id,
19
- )
20
- ) {
21
- return false
22
- }
23
- this.pendingTransactions.push(transaction)
24
- // Sort by creation time to maintain FIFO order
25
- this.pendingTransactions.sort(
26
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
27
- )
28
- return true
29
- },
8
+ if (
9
+ this.pendingTransactions.some((pending) => pending.id === transaction.id)
10
+ ) {
11
+ return false
12
+ }
13
+ this.pendingTransactions.push(transaction)
14
+ // Sort by creation time to maintain FIFO order
15
+ this.pendingTransactions.sort(
16
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
30
17
  )
18
+ return true
31
19
  }
32
20
 
33
21
  getNext(): OfflineTransaction | undefined {
34
- return withSyncSpan(
35
- `scheduler.getNext`,
36
- { pendingCount: this.pendingTransactions.length },
37
- (span) => {
38
- if (
39
- this.activeTransactionId !== undefined ||
40
- this.pendingTransactions.length === 0
41
- ) {
42
- span.setAttribute(`result`, `empty`)
43
- return undefined
44
- }
45
-
46
- const firstTransaction = this.pendingTransactions[0]!
47
-
48
- if (!this.isReadyToRun(firstTransaction)) {
49
- span.setAttribute(`result`, `waiting_for_first`)
50
- span.setAttribute(`transaction.id`, firstTransaction.id)
51
- return undefined
52
- }
53
-
54
- span.setAttribute(`result`, `found`)
55
- span.setAttribute(`transaction.id`, firstTransaction.id)
56
- return firstTransaction
57
- },
58
- )
22
+ if (
23
+ this.activeTransactionId !== undefined ||
24
+ this.pendingTransactions.length === 0
25
+ ) {
26
+ return undefined
27
+ }
28
+ const firstTransaction = this.pendingTransactions[0]!
29
+ return this.isReadyToRun(firstTransaction) ? firstTransaction : undefined
59
30
  }
60
31
 
61
32
  private isReadyToRun(transaction: OfflineTransaction): boolean {
@@ -1,8 +1,7 @@
1
- import { createTransaction } from '@tanstack/db'
1
+ import { DuplicateTransactionIdError, createTransaction } from '@tanstack/db'
2
2
  import { OutboxTransactionNotFoundError } from '../outbox/OutboxManager'
3
3
  import { DefaultRetryPolicy } from '../retry/RetryPolicy'
4
4
  import { NonRetriableError } from '../types'
5
- import { withNestedSpan } from '../telemetry/tracer'
6
5
  import type { KeyScheduler } from './KeyScheduler'
7
6
  import type { OutboxManager } from '../outbox/OutboxManager'
8
7
  import type {
@@ -13,6 +12,13 @@ import type {
13
12
 
14
13
  const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)
15
14
 
15
+ function toError(value: unknown): Error {
16
+ return value instanceof Error ||
17
+ Object.prototype.toString.call(value) === `[object Error]`
18
+ ? (value as Error)
19
+ : new Error(String(value))
20
+ }
21
+
16
22
  export class TransactionExecutor {
17
23
  private scheduler: KeyScheduler
18
24
  private outbox: OutboxManager
@@ -84,77 +90,53 @@ export class TransactionExecutor {
84
90
  transaction: OfflineTransaction,
85
91
  ): Promise<void> {
86
92
  try {
87
- await withNestedSpan(
88
- `transaction.execute`,
89
- {
90
- 'transaction.id': transaction.id,
91
- 'transaction.mutationFnName': transaction.mutationFnName,
92
- 'transaction.retryCount': transaction.retryCount,
93
- 'transaction.keyCount': transaction.keys.length,
94
- },
95
- async (span) => {
96
- const terminalError =
97
- transaction.outboxPhase === `rejection-pending`
98
- ? this.getTerminalError(transaction)
99
- : undefined
100
- this.scheduler.markStarted(transaction)
101
-
102
- if (transaction.retryCount > 0) {
103
- span.setAttribute(`retry.attempt`, transaction.retryCount)
104
- }
105
-
106
- if (!transaction.outboxPhase) {
107
- try {
108
- await this.runMutationFn(transaction)
109
- } catch (error) {
110
- const err =
111
- error instanceof Error ? error : new Error(String(error))
93
+ const terminalError =
94
+ transaction.outboxPhase === `rejection-pending`
95
+ ? this.getTerminalError(transaction)
96
+ : undefined
97
+ this.scheduler.markStarted(transaction)
112
98
 
113
- span.setAttribute(`result`, `error`)
99
+ if (!transaction.outboxPhase) {
100
+ try {
101
+ await this.runMutationFn(transaction)
102
+ } catch (error) {
103
+ const err = toError(error)
114
104
 
115
- await this.handleError(transaction, err)
116
- ;(err as any)[HANDLED_EXECUTION_ERROR] = true
117
- throw err
118
- }
119
- }
105
+ await this.handleError(transaction, err)
106
+ ;(err as any)[HANDLED_EXECUTION_ERROR] = true
107
+ throw err
108
+ }
109
+ }
120
110
 
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
- )
131
- } catch (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
- }
111
+ // A successful provider call can outlive this executor. Persist that
112
+ // fact before removal so a restarted executor only retries deletion.
113
+ const deletionPending: OfflineTransaction = transaction.outboxPhase
114
+ ? transaction
115
+ : { ...transaction, outboxPhase: `deletion-pending` }
116
+ try {
117
+ await this.removeSettledTransaction(
118
+ deletionPending,
119
+ !transaction.outboxPhase,
120
+ )
121
+ } catch (error) {
122
+ const storageError = error as Error
123
+ this.offlineExecutor.rejectTransaction(
124
+ transaction.id,
125
+ terminalError ?? storageError,
126
+ )
127
+ throw storageError
128
+ }
141
129
 
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)
151
- }
152
- },
153
- )
130
+ if (terminalError) {
131
+ this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
132
+ } else {
133
+ this.offlineExecutor.resolveTransaction(transaction.id, undefined)
134
+ }
154
135
  } catch (error) {
155
136
  if (
156
- error instanceof Error &&
157
- (error as any)[HANDLED_EXECUTION_ERROR] === true
137
+ error !== null &&
138
+ typeof error === `object` &&
139
+ (error as Record<symbol, unknown>)[HANDLED_EXECUTION_ERROR] === true
158
140
  ) {
159
141
  return
160
142
  }
@@ -194,81 +176,97 @@ export class TransactionExecutor {
194
176
  transaction: OfflineTransaction,
195
177
  error: Error,
196
178
  ): Promise<void> {
197
- return withNestedSpan(
198
- `transaction.handleError`,
199
- {
200
- 'transaction.id': transaction.id,
201
- 'error.name': error.name,
202
- 'error.message': error.message,
203
- },
204
- async (span) => {
205
- const shouldRetry = this.retryPolicy.shouldRetry(
206
- error,
207
- transaction.retryCount,
208
- )
209
-
210
- span.setAttribute(`shouldRetry`, shouldRetry)
211
-
212
- if (!shouldRetry) {
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
- }
222
- console.warn(
223
- `Transaction ${transaction.id} failed permanently:`,
224
- error,
179
+ let shouldRetry: boolean
180
+ let hookFailure: Error | undefined
181
+ if (error instanceof NonRetriableError) {
182
+ shouldRetry = false
183
+ } else {
184
+ let decision: boolean | undefined
185
+ try {
186
+ decision = this.config.shouldRetry?.(error, transaction.retryCount)
187
+ if (decision !== undefined && typeof decision !== `boolean`) {
188
+ // An async hook is invalid, but its rejection must stay row-local.
189
+ void Promise.resolve(decision).catch(() => {})
190
+ throw new TypeError(
191
+ `OfflineConfig.shouldRetry must return true, false, or undefined`,
225
192
  )
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
- }
234
-
235
- span.setAttribute(`result`, `permanent_failure`)
236
- this.offlineExecutor.rejectTransaction(transaction.id, error)
237
- return
238
193
  }
194
+ } catch (hookError) {
195
+ hookFailure = toError(hookError)
196
+ }
197
+ if (hookFailure) {
198
+ shouldRetry = false
199
+ } else {
200
+ shouldRetry =
201
+ decision ??
202
+ this.retryPolicy.shouldRetry(error, transaction.retryCount)
203
+ }
204
+ }
239
205
 
240
- const delay = Math.max(
241
- 0,
242
- this.retryPolicy.calculateDelay(transaction.retryCount),
206
+ if (!shouldRetry) {
207
+ const terminalError = hookFailure ?? error
208
+ const rejectionPending: OfflineTransaction = {
209
+ ...transaction,
210
+ outboxPhase: `rejection-pending`,
211
+ lastError: {
212
+ name: terminalError.name,
213
+ message: terminalError.message,
214
+ stack: terminalError.stack,
215
+ },
216
+ }
217
+ if (hookFailure) {
218
+ console.warn(
219
+ `Retry decision failed for transaction ${transaction.id}:`,
220
+ hookFailure,
221
+ `Named mutation function error:`,
222
+ error,
243
223
  )
244
- const updatedTransaction: OfflineTransaction = {
245
- ...transaction,
246
- retryCount: transaction.retryCount + 1,
247
- nextAttemptAt: Date.now() + delay,
248
- lastError: {
249
- name: error.name,
250
- message: error.message,
251
- stack: error.stack,
252
- },
253
- }
254
-
255
- span.setAttribute(`retryDelay`, delay)
256
- span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
224
+ } else {
225
+ console.warn(`Transaction ${transaction.id} failed permanently:`, error)
226
+ }
227
+ try {
228
+ await this.removeSettledTransaction(rejectionPending, true)
229
+ } catch (storageError) {
230
+ this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
231
+ throw storageError
232
+ }
257
233
 
258
- this.scheduler.updateTransaction(updatedTransaction)
234
+ this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
235
+ return
236
+ }
259
237
 
260
- try {
261
- await this.outbox.update(transaction.id, updatedTransaction)
262
- span.setAttribute(`result`, `scheduled_retry`)
263
- } catch (persistError) {
264
- span.recordException(persistError as Error)
265
- span.setAttribute(`result`, `persist_failed`)
266
- throw persistError
267
- } finally {
268
- this.scheduler.markFailed(transaction)
269
- }
270
- },
238
+ const delay = Math.max(
239
+ 0,
240
+ this.retryPolicy.calculateDelay(transaction.retryCount),
271
241
  )
242
+ const updatedTransaction: OfflineTransaction = {
243
+ ...transaction,
244
+ retryCount: transaction.retryCount + 1,
245
+ nextAttemptAt: Date.now() + delay,
246
+ lastError: {
247
+ name: error.name,
248
+ message: error.message,
249
+ stack: error.stack,
250
+ },
251
+ }
252
+
253
+ this.scheduler.updateTransaction(updatedTransaction)
254
+
255
+ try {
256
+ await this.outbox.update(transaction.id, updatedTransaction)
257
+ } catch (persistError) {
258
+ // Public removal can finish while the named mutation function runs.
259
+ // The removed row cannot be retried, but its caller still owns the
260
+ // provider failure and later queued work must be able to run.
261
+ if (persistError instanceof OutboxTransactionNotFoundError) {
262
+ this.scheduler.markCompleted(transaction)
263
+ this.offlineExecutor.rejectTransaction(transaction.id, error)
264
+ return
265
+ }
266
+ throw persistError
267
+ } finally {
268
+ this.scheduler.markFailed(transaction)
269
+ }
272
270
  }
273
271
 
274
272
  private async removeSettledTransaction(
@@ -287,8 +285,7 @@ export class TransactionExecutor {
287
285
  await this.outbox.remove(transaction.id)
288
286
  this.scheduler.markCompleted(transaction)
289
287
  } catch (error) {
290
- const storageError =
291
- error instanceof Error ? error : new Error(String(error))
288
+ const storageError = toError(error)
292
289
  this.fatalError = storageError
293
290
  this.scheduler.markFailed(transaction)
294
291
  this.clearRetryTimer()
@@ -399,34 +396,21 @@ export class TransactionExecutor {
399
396
  mutationFn: async () => {},
400
397
  })
401
398
 
402
- // Prevent unhandled promise rejection when cleanup calls rollback()
403
- // We don't care about this promise - it's just for holding mutations
404
- restorationTx.isPersisted.promise.catch(() => {
405
- // Intentionally ignored - restoration transactions are cleaned up
406
- // via cleanupRestorationTransaction, not through normal commit flow
407
- })
408
-
409
399
  restorationTx.applyMutations(offlineTx.mutations)
410
400
 
411
- // Register with each affected collection's state manager
412
- const touchedCollections = new Set<string>()
413
- for (const mutation of offlineTx.mutations) {
414
- // Defensive check for corrupted deserialized data
415
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
416
- if (!mutation.collection) {
417
- continue
418
- }
419
- const collectionId = mutation.collection.id
420
- if (touchedCollections.has(collectionId)) {
421
- continue
401
+ // Register with each affected Collection. If one cannot track it, roll
402
+ // it back, so no Collection keeps a restoration nothing will settle.
403
+ try {
404
+ // The serializer resolves every mutation's Collection or throws.
405
+ for (const mutation of offlineTx.mutations) {
406
+ if (mutation.collection._state.trackTransaction(restorationTx))
407
+ mutation.collection._state.recomputeOptimisticState(true)
422
408
  }
423
- touchedCollections.add(collectionId)
424
-
425
- mutation.collection._state.transactions.set(
426
- restorationTx.id,
427
- restorationTx,
428
- )
429
- mutation.collection._state.recomputeOptimisticState(true)
409
+ } catch (error) {
410
+ // A secondary rollback settles only this restoration; it must not
411
+ // roll back the user's live transactions on the same keys.
412
+ restorationTx.rollback({ isSecondaryRollback: true })
413
+ throw error
430
414
  }
431
415
 
432
416
  this.offlineExecutor.registerRestorationTransaction(
@@ -434,6 +418,8 @@ export class TransactionExecutor {
434
418
  restorationTx,
435
419
  )
436
420
  } catch (error) {
421
+ // A live transaction with this id already shows these mutations.
422
+ if (error instanceof DuplicateTransactionIdError) continue
437
423
  console.warn(
438
424
  `Failed to restore optimistic state for transaction ${offlineTx.id}:`,
439
425
  error,
@@ -1,4 +1,3 @@
1
- import { withSpan } from '../telemetry/tracer'
2
1
  import {
3
2
  MissingTemporalConstructorError,
4
3
  TransactionSerializer,
@@ -49,45 +48,26 @@ export class OutboxManager {
49
48
  }
50
49
 
51
50
  async add(transaction: OfflineTransaction): Promise<void> {
52
- return withSpan(
53
- `outbox.add`,
54
- {
55
- 'transaction.id': transaction.id,
56
- 'transaction.mutationFnName': transaction.mutationFnName,
57
- 'transaction.keyCount': transaction.keys.length,
58
- },
59
- async () => {
60
- const key = this.getStorageKey(transaction.id)
61
- const serialized = this.serializer.serialize(transaction)
62
- await this.storage.set(key, serialized)
63
- },
64
- )
51
+ const key = this.getStorageKey(transaction.id)
52
+ const serialized = this.serializer.serialize(transaction)
53
+ await this.storage.set(key, serialized)
65
54
  }
66
55
 
67
56
  async get(id: string): Promise<OfflineTransaction | null> {
68
- return withSpan(`outbox.get`, { 'transaction.id': id }, async (span) => {
69
- const key = this.getStorageKey(id)
70
- const data = await this.storage.get(key)
71
-
72
- if (!data) {
73
- span.setAttribute(`result`, `not_found`)
74
- return null
75
- }
57
+ const key = this.getStorageKey(id)
58
+ const data = await this.storage.get(key)
59
+ if (!data) return null
76
60
 
77
- try {
78
- const transaction = this.serializer.deserialize(data)
79
- span.setAttribute(`result`, `found`)
80
- return transaction
81
- } catch (error) {
82
- if (error instanceof MissingTemporalConstructorError) {
83
- error.message = `transaction ${id}: ${error.message}`
84
- throw error
85
- }
86
- console.warn(`Failed to deserialize transaction ${id}:`, error)
87
- span.setAttribute(`result`, `deserialize_error`)
88
- return null
61
+ try {
62
+ return this.serializer.deserialize(data)
63
+ } catch (error) {
64
+ if (error instanceof MissingTemporalConstructorError) {
65
+ error.message = `transaction ${id}: ${error.message}`
66
+ throw error
89
67
  }
90
- })
68
+ console.warn(`Failed to deserialize transaction ${id}:`, error)
69
+ return null
70
+ }
91
71
  }
92
72
 
93
73
  async getAll(): Promise<Array<OfflineTransaction>> {
@@ -100,44 +80,40 @@ export class OutboxManager {
100
80
  const read = { revision: this.removalRevision }
101
81
  this.activeReads.add(read)
102
82
  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
- )
83
+ const keys = await this.storage.keys()
84
+ const transactionKeys = keys.filter((key) =>
85
+ key.startsWith(this.keyPrefix),
86
+ )
87
+
88
+ const transactions: Array<OfflineTransaction> = []
89
+
90
+ for (const key of transactionKeys) {
91
+ const data = await this.storage.get(key)
92
+ if (data) {
93
+ try {
94
+ const transaction = this.serializer.deserialize(data)
95
+ transactions.push(transaction)
96
+ } catch (error) {
97
+ if (error instanceof MissingTemporalConstructorError) {
98
+ error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`
99
+ throw error
128
100
  }
101
+ console.warn(
102
+ `Failed to deserialize transaction from key ${key}:`,
103
+ error,
104
+ )
129
105
  }
130
106
  }
107
+ }
131
108
 
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
- })
109
+ const currentTransactions = transactions
110
+ .filter(
111
+ ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,
112
+ )
113
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
114
+ // Keep the read registered until replay admission completes. A durable
115
+ // removal cannot land between filtering and this synchronous consumer.
116
+ return await consume(currentTransactions)
141
117
  } finally {
142
118
  this.activeReads.delete(read)
143
119
  this.pruneRemovalHistory()
@@ -157,29 +133,21 @@ export class OutboxManager {
157
133
  id: string,
158
134
  updates: Partial<OfflineTransaction>,
159
135
  ): Promise<void> {
160
- return withSpan(`outbox.update`, { 'transaction.id': id }, async () => {
161
- const existing = await this.get(id)
162
- if (!existing) {
163
- throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)
164
- }
165
-
166
- const updated = { ...existing, ...updates }
167
- await this.add(updated)
168
- })
136
+ const existing = await this.get(id)
137
+ if (!existing) {
138
+ throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)
139
+ }
140
+ await this.add({ ...existing, ...updates })
169
141
  }
170
142
 
171
143
  async remove(id: string): Promise<void> {
172
- return withSpan(`outbox.remove`, { 'transaction.id': id }, async () => {
173
- const key = this.getStorageKey(id)
174
- await this.storage.delete(key)
175
- this.recordRemovals([id])
176
- })
144
+ const key = this.getStorageKey(id)
145
+ await this.storage.delete(key)
146
+ this.recordRemovals([id])
177
147
  }
178
148
 
179
149
  async removeMany(ids: Array<string>): Promise<void> {
180
- return withSpan(`outbox.removeMany`, { count: ids.length }, async () => {
181
- await Promise.all(ids.map((id) => this.remove(id)))
182
- })
150
+ await Promise.all(ids.map((id) => this.remove(id)))
183
151
  }
184
152
 
185
153
  async clear(): Promise<void> {
package/src/types.ts CHANGED
@@ -101,6 +101,16 @@ export interface OfflineConfig {
101
101
  storage?: StorageAdapter
102
102
  maxConcurrency?: number
103
103
  jitter?: boolean
104
+ /**
105
+ * Override retry decisions for errors from all named mutation functions.
106
+ * Return synchronously; undefined uses the default decision. Put any
107
+ * function-specific context needed by this shared hook on the Error.
108
+ * NonRetriableError always stops retry.
109
+ * A throw or invalid return fails only that outbox row and rejects its
110
+ * waiters with the hook failure. Queued work runs after terminal cleanup.
111
+ * The default policy still supplies the delay and configured jitter.
112
+ */
113
+ shouldRetry?: (error: Error, retryCount: number) => boolean | undefined
104
114
  beforeRetry?: (
105
115
  transactions: Array<OfflineTransaction>,
106
116
  ) => Array<OfflineTransaction>
@@ -1 +0,0 @@
1
- export { NonRetriableError } from '../types.cjs';