@tanstack/offline-transactions 1.1.0 → 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 (46) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/OfflineExecutor.cjs +72 -80
  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 +106 -161
  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/esm/OfflineExecutor.d.ts +6 -0
  17. package/dist/esm/OfflineExecutor.js +72 -80
  18. package/dist/esm/OfflineExecutor.js.map +1 -1
  19. package/dist/esm/api/OfflineAction.js +4 -15
  20. package/dist/esm/api/OfflineAction.js.map +1 -1
  21. package/dist/esm/api/OfflineTransaction.d.ts +2 -0
  22. package/dist/esm/api/OfflineTransaction.js +54 -51
  23. package/dist/esm/api/OfflineTransaction.js.map +1 -1
  24. package/dist/esm/executor/KeyScheduler.js +12 -38
  25. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  26. package/dist/esm/executor/TransactionExecutor.js +106 -161
  27. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  28. package/dist/esm/outbox/OutboxManager.js +48 -77
  29. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/OfflineExecutor.ts +93 -94
  32. package/src/api/OfflineAction.ts +4 -22
  33. package/src/api/OfflineTransaction.ts +78 -66
  34. package/src/executor/KeyScheduler.ts +18 -47
  35. package/src/executor/TransactionExecutor.ts +122 -186
  36. package/src/outbox/OutboxManager.ts +54 -86
  37. package/dist/cjs/retry/NonRetriableError.d.cts +0 -1
  38. package/dist/cjs/telemetry/tracer.cjs +0 -27
  39. package/dist/cjs/telemetry/tracer.cjs.map +0 -1
  40. package/dist/cjs/telemetry/tracer.d.cts +0 -30
  41. package/dist/esm/retry/NonRetriableError.d.ts +0 -1
  42. package/dist/esm/telemetry/tracer.d.ts +0 -30
  43. package/dist/esm/telemetry/tracer.js +0 -27
  44. package/dist/esm/telemetry/tracer.js.map +0 -1
  45. package/src/retry/NonRetriableError.ts +0 -1
  46. package/src/telemetry/tracer.ts +0 -66
@@ -1,5 +1,4 @@
1
1
  import { createTransaction, safeRandomUUID } from '@tanstack/db'
2
- import { NonRetriableError } from '../types'
3
2
  import type { PendingMutation, Transaction } from '@tanstack/db'
4
3
  import type {
5
4
  CreateOfflineTransactionOptions,
@@ -10,10 +9,12 @@ import type {
10
9
  export class OfflineTransaction {
11
10
  private offlineId: string
12
11
  private mutationFnName: string
13
- private autoCommit: boolean
12
+ private autoCommit: boolean | undefined
14
13
  private idempotencyKey: string
15
14
  private metadata: Record<string, any>
16
15
  private transaction: Transaction | null = null
16
+ /** The mutation function ran, so the executor settles the waiter. */
17
+ private persisting = false
17
18
  private persistTransaction: (tx: OfflineTransactionType) => Promise<void>
18
19
  private executor: any // Will be typed properly - reference to OfflineExecutor
19
20
 
@@ -25,7 +26,8 @@ export class OfflineTransaction {
25
26
  ) {
26
27
  this.offlineId = safeRandomUUID()
27
28
  this.mutationFnName = options.mutationFnName
28
- this.autoCommit = options.autoCommit ?? true
29
+ // The TanStack DB transaction applies the default.
30
+ this.autoCommit = options.autoCommit
29
31
  this.idempotencyKey = options.idempotencyKey ?? safeRandomUUID()
30
32
  this.metadata = options.metadata ?? {}
31
33
  this.persistTransaction = persistTransaction
@@ -33,65 +35,81 @@ export class OfflineTransaction {
33
35
  }
34
36
 
35
37
  mutate(callback: () => void): Transaction {
36
- // Repeated calls add to the same transaction: one offline transaction is
37
- // one transaction, and its id is unique among live transactions.
38
- this.transaction ??= createTransaction({
39
- id: this.offlineId,
40
- autoCommit: false,
41
- mutationFn: async () => {
42
- // This is the blocking mutationFn that waits for the executor
43
- // First persist the transaction to the outbox
44
- const offlineTransaction: OfflineTransactionType = {
45
- id: this.offlineId,
46
- mutationFnName: this.mutationFnName,
47
- mutations: this.transaction!.mutations,
48
- keys: this.extractKeys(this.transaction!.mutations),
49
- idempotencyKey: this.idempotencyKey,
50
- createdAt: new Date(),
51
- retryCount: 0,
52
- nextAttemptAt: Date.now(),
53
- metadata: this.metadata,
54
- spanContext: undefined,
55
- version: 1,
56
- }
38
+ // One offline transaction is one transaction, and its id is unique among
39
+ // live transactions. Repeated calls add to it while it is pending. With
40
+ // autoCommit, it commits after this callback and reports a failure
41
+ // through `when('settled')`, so a later call throws: it is no longer pending.
42
+ if (!this.transaction) {
43
+ this.transaction = createTransaction({
44
+ id: this.offlineId,
45
+ autoCommit: this.autoCommit,
46
+ mutationFn: async () => {
47
+ this.persisting = true
48
+ // This is the blocking mutationFn that waits for the executor
49
+ // First persist the transaction to the outbox
50
+ const offlineTransaction: OfflineTransactionType = {
51
+ id: this.offlineId,
52
+ mutationFnName: this.mutationFnName,
53
+ mutations: this.transaction!.mutations,
54
+ keys: this.extractKeys(this.transaction!.mutations),
55
+ idempotencyKey: this.idempotencyKey,
56
+ createdAt: new Date(),
57
+ retryCount: 0,
58
+ nextAttemptAt: Date.now(),
59
+ metadata: this.metadata,
60
+ spanContext: undefined,
61
+ version: 1,
62
+ }
57
63
 
58
- const completionPromise = this.executor.waitForTransactionCompletion(
59
- this.offlineId,
60
- )
64
+ const completionPromise = this.executor.waitForTransactionCompletion(
65
+ this.offlineId,
66
+ )
61
67
 
62
- try {
63
- // Persistence also drives the shared queue. This transaction can finish
64
- // before that queue drains; observe both promises from the outset.
65
- await Promise.race([
66
- this.persistTransaction(offlineTransaction),
67
- completionPromise,
68
- ])
69
- // A queue pause (offline or retry) is not transaction completion.
70
- await completionPromise
71
- } catch (error) {
72
- const normalizedError =
73
- error instanceof Error ? error : new Error(String(error))
74
- this.executor.rejectTransaction(this.offlineId, normalizedError)
75
- throw error
76
- }
68
+ try {
69
+ // Persistence also drives the shared queue. This transaction can finish
70
+ // before that queue drains; observe both promises from the outset.
71
+ await Promise.race([
72
+ this.persistTransaction(offlineTransaction),
73
+ completionPromise,
74
+ ])
75
+ // A queue pause (offline or retry) is not transaction completion.
76
+ await completionPromise
77
+ } catch (error) {
78
+ const normalizedError =
79
+ error instanceof Error ? error : new Error(String(error))
80
+ this.executor.rejectTransaction(this.offlineId, normalizedError)
81
+ throw error
82
+ }
77
83
 
78
- return
79
- },
80
- metadata: this.metadata,
81
- })
84
+ return
85
+ },
86
+ metadata: this.metadata,
87
+ })
88
+ // A transaction without mutations completes, and one rolled back before
89
+ // it commits fails, without its mutation function, so the executor never
90
+ // settles its waiter. Settle that waiter here with the same outcome. Once
91
+ // the mutation function runs, only the executor settles it.
92
+ void this.transaction.when(`settled`).then(
93
+ () => {
94
+ if (!this.persisting)
95
+ this.executor.resolveTransaction(this.offlineId, undefined)
96
+ },
97
+ (error: unknown) => {
98
+ if (!this.persisting)
99
+ this.executor.rejectTransaction(
100
+ this.offlineId,
101
+ error instanceof Error
102
+ ? error
103
+ : new Error(`Offline transaction was rolled back`),
104
+ )
105
+ },
106
+ )
107
+ }
82
108
 
83
109
  this.transaction.mutate(() => {
84
110
  callback()
85
111
  })
86
112
 
87
- if (this.autoCommit) {
88
- // Auto-commit for direct OfflineTransaction usage
89
- this.commit().catch((error) => {
90
- console.error(`Auto-commit failed:`, error)
91
- throw error
92
- })
93
- }
94
-
95
113
  return this.transaction
96
114
  }
97
115
 
@@ -100,18 +118,12 @@ export class OfflineTransaction {
100
118
  throw new Error(`No mutations to commit. Call mutate() first.`)
101
119
  }
102
120
 
103
- try {
104
- // Commit the TanStack DB transaction
105
- // This will trigger the mutationFn which handles persistence and waiting
106
- await this.transaction.commit()
107
- return this.transaction
108
- } catch (error) {
109
- // Only rollback for NonRetriableError - other errors should allow retry
110
- if (error instanceof NonRetriableError) {
111
- this.transaction.rollback()
112
- }
113
- throw error
114
- }
121
+ // The mutationFn persists the transaction and waits for the executor. A
122
+ // commit that rejects has already rolled the transaction back. A sibling's
123
+ // failure can also roll it back while it waits, and then commit() resolves,
124
+ // so `when('settled')` is the receipt.
125
+ await this.transaction.commit()
126
+ return this.transaction
115
127
  }
116
128
 
117
129
  rollback(): void {
@@ -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 {
@@ -2,7 +2,6 @@ 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 {
@@ -91,72 +90,48 @@ export class TransactionExecutor {
91
90
  transaction: OfflineTransaction,
92
91
  ): Promise<void> {
93
92
  try {
94
- await withNestedSpan(
95
- `transaction.execute`,
96
- {
97
- 'transaction.id': transaction.id,
98
- 'transaction.mutationFnName': transaction.mutationFnName,
99
- 'transaction.retryCount': transaction.retryCount,
100
- 'transaction.keyCount': transaction.keys.length,
101
- },
102
- async (span) => {
103
- const terminalError =
104
- transaction.outboxPhase === `rejection-pending`
105
- ? this.getTerminalError(transaction)
106
- : undefined
107
- this.scheduler.markStarted(transaction)
108
-
109
- if (transaction.retryCount > 0) {
110
- span.setAttribute(`retry.attempt`, transaction.retryCount)
111
- }
112
-
113
- if (!transaction.outboxPhase) {
114
- try {
115
- await this.runMutationFn(transaction)
116
- } catch (error) {
117
- const err = toError(error)
93
+ const terminalError =
94
+ transaction.outboxPhase === `rejection-pending`
95
+ ? this.getTerminalError(transaction)
96
+ : undefined
97
+ this.scheduler.markStarted(transaction)
118
98
 
119
- span.setAttribute(`result`, `error`)
99
+ if (!transaction.outboxPhase) {
100
+ try {
101
+ await this.runMutationFn(transaction)
102
+ } catch (error) {
103
+ const err = toError(error)
120
104
 
121
- await this.handleError(transaction, err)
122
- ;(err as any)[HANDLED_EXECUTION_ERROR] = true
123
- throw err
124
- }
125
- }
105
+ await this.handleError(transaction, err)
106
+ ;(err as any)[HANDLED_EXECUTION_ERROR] = true
107
+ throw err
108
+ }
109
+ }
126
110
 
127
- // A successful provider call can outlive this executor. Persist that
128
- // fact before removal so a restarted executor only retries deletion.
129
- const deletionPending: OfflineTransaction = transaction.outboxPhase
130
- ? transaction
131
- : { ...transaction, outboxPhase: `deletion-pending` }
132
- try {
133
- await this.removeSettledTransaction(
134
- deletionPending,
135
- !transaction.outboxPhase,
136
- )
137
- } catch (error) {
138
- const storageError = error as Error
139
- span.recordException(storageError)
140
- span.setAttribute(`result`, `outbox_failure`)
141
- this.offlineExecutor.rejectTransaction(
142
- transaction.id,
143
- terminalError ?? storageError,
144
- )
145
- throw storageError
146
- }
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
+ }
147
129
 
148
- if (terminalError) {
149
- span.setAttribute(`result`, `permanent_failure`)
150
- this.offlineExecutor.rejectTransaction(
151
- transaction.id,
152
- terminalError,
153
- )
154
- } else {
155
- span.setAttribute(`result`, `success`)
156
- this.offlineExecutor.resolveTransaction(transaction.id, undefined)
157
- }
158
- },
159
- )
130
+ if (terminalError) {
131
+ this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
132
+ } else {
133
+ this.offlineExecutor.resolveTransaction(transaction.id, undefined)
134
+ }
160
135
  } catch (error) {
161
136
  if (
162
137
  error !== null &&
@@ -201,129 +176,97 @@ export class TransactionExecutor {
201
176
  transaction: OfflineTransaction,
202
177
  error: Error,
203
178
  ): Promise<void> {
204
- return withNestedSpan(
205
- `transaction.handleError`,
206
- {
207
- 'transaction.id': transaction.id,
208
- 'error.name': error.name,
209
- 'error.message': error.message,
210
- },
211
- async (span) => {
212
- let shouldRetry: boolean
213
- let hookFailure: Error | undefined
214
- if (error instanceof NonRetriableError) {
215
- shouldRetry = false
216
- } else {
217
- let decision: boolean | undefined
218
- try {
219
- decision = this.config.shouldRetry?.(error, transaction.retryCount)
220
- if (decision !== undefined && typeof decision !== `boolean`) {
221
- // An async hook is invalid, but its rejection must stay row-local.
222
- void Promise.resolve(decision).catch(() => {})
223
- throw new TypeError(
224
- `OfflineConfig.shouldRetry must return true, false, or undefined`,
225
- )
226
- }
227
- } catch (hookError) {
228
- hookFailure = toError(hookError)
229
- }
230
- if (hookFailure) {
231
- shouldRetry = false
232
- } else {
233
- shouldRetry =
234
- decision ??
235
- this.retryPolicy.shouldRetry(error, transaction.retryCount)
236
- }
237
- }
238
-
239
- span.setAttribute(`shouldRetry`, shouldRetry)
240
-
241
- if (!shouldRetry) {
242
- const terminalError = hookFailure ?? error
243
- const rejectionPending: OfflineTransaction = {
244
- ...transaction,
245
- outboxPhase: `rejection-pending`,
246
- lastError: {
247
- name: terminalError.name,
248
- message: terminalError.message,
249
- stack: terminalError.stack,
250
- },
251
- }
252
- if (hookFailure) {
253
- span.recordException(hookFailure)
254
- console.warn(
255
- `Retry decision failed for transaction ${transaction.id}:`,
256
- hookFailure,
257
- `Named mutation function error:`,
258
- error,
259
- )
260
- } else {
261
- console.warn(
262
- `Transaction ${transaction.id} failed permanently:`,
263
- error,
264
- )
265
- }
266
- try {
267
- await this.removeSettledTransaction(rejectionPending, true)
268
- } catch (storageError) {
269
- span.recordException(storageError as Error)
270
- span.setAttribute(`result`, `outbox_failure`)
271
- this.offlineExecutor.rejectTransaction(
272
- transaction.id,
273
- terminalError,
274
- )
275
- throw storageError
276
- }
277
-
278
- span.setAttribute(
279
- `result`,
280
- hookFailure ? `retry_decision_failure` : `permanent_failure`,
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`,
281
192
  )
282
- this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
283
- return
284
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
+ }
285
205
 
286
- const delay = Math.max(
287
- 0,
288
- 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,
289
223
  )
290
- const updatedTransaction: OfflineTransaction = {
291
- ...transaction,
292
- retryCount: transaction.retryCount + 1,
293
- nextAttemptAt: Date.now() + delay,
294
- lastError: {
295
- name: error.name,
296
- message: error.message,
297
- stack: error.stack,
298
- },
299
- }
300
-
301
- span.setAttribute(`retryDelay`, delay)
302
- 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
+ }
303
233
 
304
- this.scheduler.updateTransaction(updatedTransaction)
234
+ this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
235
+ return
236
+ }
305
237
 
306
- try {
307
- await this.outbox.update(transaction.id, updatedTransaction)
308
- span.setAttribute(`result`, `scheduled_retry`)
309
- } catch (persistError) {
310
- // Public removal can finish while the named mutation function runs.
311
- // The removed row cannot be retried, but its caller still owns the
312
- // provider failure and later queued work must be able to run.
313
- if (persistError instanceof OutboxTransactionNotFoundError) {
314
- this.scheduler.markCompleted(transaction)
315
- span.setAttribute(`result`, `outbox_removed`)
316
- this.offlineExecutor.rejectTransaction(transaction.id, error)
317
- return
318
- }
319
- span.recordException(persistError as Error)
320
- span.setAttribute(`result`, `persist_failed`)
321
- throw persistError
322
- } finally {
323
- this.scheduler.markFailed(transaction)
324
- }
325
- },
238
+ const delay = Math.max(
239
+ 0,
240
+ this.retryPolicy.calculateDelay(transaction.retryCount),
326
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
+ }
327
270
  }
328
271
 
329
272
  private async removeSettledTransaction(
@@ -453,13 +396,6 @@ export class TransactionExecutor {
453
396
  mutationFn: async () => {},
454
397
  })
455
398
 
456
- // Prevent unhandled promise rejection when cleanup calls rollback()
457
- // We don't care about this promise - it's just for holding mutations
458
- restorationTx.isPersisted.promise.catch(() => {
459
- // Intentionally ignored - restoration transactions are cleaned up
460
- // via cleanupRestorationTransaction, not through normal commit flow
461
- })
462
-
463
399
  restorationTx.applyMutations(offlineTx.mutations)
464
400
 
465
401
  // Register with each affected Collection. If one cannot track it, roll