@tanstack/offline-transactions 1.0.65 → 1.1.0

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.
@@ -165,6 +165,7 @@ interface OfflineConfig {
165
165
  storage?: StorageAdapter // Custom storage (default: auto-detect)
166
166
  maxConcurrency?: number // Parallel execution limit
167
167
  jitter?: boolean // Add jitter to retry delays
168
+ shouldRetry?: (error: Error, retryCount: number) => boolean | undefined // Override retry decision
168
169
  beforeRetry?: (txs) => txs // Transform/filter before retry
169
170
  onUnknownMutationFn?: (name, tx) => void // Handle orphaned transactions
170
171
  onLeadershipChange?: (isLeader) => void // Leadership state callback
@@ -206,7 +207,55 @@ const executor = startOfflineExecutor({
206
207
  })
207
208
  ```
208
209
 
209
- Throwing `NonRetriableError` stops retry and removes the transaction from the outbox. Use for permanent failures (validation errors, conflicts, 4xx responses).
210
+ Throwing `NonRetriableError` stops retry and removes the transaction from the outbox. Use it for failures known to be permanent under the server contract, such as validation errors or a 409 that means an unrecoverable duplicate. A recoverable 401 can instead reach `shouldRetry`.
211
+
212
+ ### Retry decisions
213
+
214
+ Set `shouldRetry` to override the default decision for a named mutation function
215
+ error. The hook receives the named mutation function's `Error` and retry count
216
+ (`0` on the first failure). The same `Error` instance is passed through; a
217
+ non-`Error` rejection is converted to an `Error` and may lose custom fields.
218
+ Return `true` to retry, `false` to stop, or `undefined` to use the
219
+ default decision. For example, return `true` for a recoverable 401 and
220
+ `undefined` otherwise. `NonRetriableError` always stops without calling the
221
+ hook. The default backoff and configured jitter still determine retry timing.
222
+ The hook is synchronous and shared by all named mutation functions. An async
223
+ hook returns a Promise, which fails its outbox row. Put function-specific
224
+ context on the thrown error when retry rules differ between functions.
225
+ If the hook throws or returns another value, the executor records a terminal
226
+ rejection for that row, removes it from the outbox, and rejects its waiting
227
+ promises with the hook failure. Other queued rows continue after the failed
228
+ row's deletion is acknowledged. New commits may join the queue during cleanup,
229
+ but cannot run ahead of that deletion. A fresh executor does not replay the
230
+ failed row. The named mutation function error is logged; the hook failure is
231
+ the caller-facing and stored error. Stored errors retain `name`, `message`, and
232
+ `stack`, but a restart does not restore an arbitrary error subclass or its
233
+ custom fields.
234
+ If marker storage or deletion fails, the caller still receives the hook error
235
+ while the executor stops with the storage error and retains queued work. A
236
+ saved terminal marker skips the named mutation function after restart; an
237
+ unmarked row can replay.
238
+ To decide from an HTTP status, throw an error carrying `response.status` in
239
+ the named mutation function and inspect its numeric status in `shouldRetry`.
240
+
241
+ ```ts
242
+ class HttpError extends Error {
243
+ constructor(readonly status: number) {
244
+ super(`HTTP ${status}`)
245
+ this.name = 'HttpError'
246
+ }
247
+ }
248
+
249
+ function throwOnHttpFailure(response: Response): void {
250
+ if (!response.ok) throw new HttpError(response.status)
251
+ }
252
+
253
+ const retryOnRecoverable401 = (error: Error): boolean | undefined =>
254
+ error instanceof HttpError && error.status === 401 ? true : undefined
255
+ ```
256
+
257
+ Call `throwOnHttpFailure(response)` in the named mutation function and pass
258
+ `shouldRetry: retryOnRecoverable401` in the executor config.
210
259
 
211
260
  ### Idempotency keys
212
261
 
@@ -339,18 +388,37 @@ mutationFns: {
339
388
  Correct:
340
389
 
341
390
  ```ts
391
+ class HttpError extends Error {
392
+ constructor(readonly status: number) {
393
+ super(`HTTP ${status}`)
394
+ this.name = 'HttpError'
395
+ }
396
+ }
397
+
342
398
  mutationFns: {
343
399
  createTodo: async ({ transaction }) => {
344
400
  const res = await fetch('/api/todos', { ... })
345
- if (res.status >= 400 && res.status < 500) {
346
- throw new NonRetriableError(`Client error: ${res.status}`)
401
+ if (res.status === 422) {
402
+ throw new NonRetriableError('Invalid todo')
403
+ }
404
+ if (res.status === 409) {
405
+ throw new NonRetriableError('Duplicate detected')
406
+ }
407
+ // This todo API also treats these responses as permanent.
408
+ if ([404, 405, 410].includes(res.status)) {
409
+ throw new NonRetriableError(`Todo endpoint rejected: ${res.status}`)
347
410
  }
348
- if (!res.ok) throw new Error('Server error')
411
+ if (!res.ok) throw new HttpError(res.status)
349
412
  },
350
413
  }
351
414
  ```
352
415
 
353
- Without distinguishing retriable from permanent errors, 4xx responses (validation, auth, not found) will retry forever until max retries, wasting resources and filling logs.
416
+ Classify failures according to whether the app can recover. The default policy
417
+ stops when an error message contains 400, 401, 403, or 422; it can retry other
418
+ errors indefinitely. This example marks 404, 405, and 410 as permanent for the
419
+ todo API while leaving 408 and 429 available for retry. Use `shouldRetry` when
420
+ a 401 can recover after credentials refresh, and use `NonRetriableError` for
421
+ known permanent failures.
354
422
 
355
423
  See also: db-core/mutations-optimistic/SKILL.md — for the underlying mutation primitives.
356
424
 
@@ -547,26 +547,11 @@ export class OfflineExecutor {
547
547
  return
548
548
  }
549
549
 
550
- // Mark as completed so recomputeOptimisticState removes it from consideration.
551
- // The actual data will come from the sync.
550
+ // Mark as completed: every Collection that tracked it recomputes and
551
+ // releases it. The actual data will come from the sync.
552
552
  restorationTx.setState(`completed`)
553
-
554
- // Remove from each collection's transaction map and recompute
555
- const touchedCollections = new Set<string>()
556
- for (const mutation of restorationTx.mutations) {
557
- // Defensive check for corrupted deserialized data
558
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
559
- if (!mutation.collection) {
560
- continue
561
- }
562
- const collectionId = mutation.collection.id
563
- if (touchedCollections.has(collectionId)) {
564
- continue
565
- }
566
- touchedCollections.add(collectionId)
567
- mutation.collection._state.transactions.delete(restorationTx.id)
568
- mutation.collection._state.recomputeOptimisticState(false)
569
- }
553
+ restorationTx.isPersisted.resolve(restorationTx)
554
+ restorationTx.touchCollection()
570
555
  }
571
556
 
572
557
  async removeFromOutbox(id: string): Promise<void> {
@@ -33,7 +33,9 @@ export class OfflineTransaction {
33
33
  }
34
34
 
35
35
  mutate(callback: () => void): Transaction {
36
- this.transaction = createTransaction({
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({
37
39
  id: this.offlineId,
38
40
  autoCommit: false,
39
41
  mutationFn: async () => {
@@ -1,4 +1,4 @@
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'
@@ -13,6 +13,13 @@ import type {
13
13
 
14
14
  const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)
15
15
 
16
+ function toError(value: unknown): Error {
17
+ return value instanceof Error ||
18
+ Object.prototype.toString.call(value) === `[object Error]`
19
+ ? (value as Error)
20
+ : new Error(String(value))
21
+ }
22
+
16
23
  export class TransactionExecutor {
17
24
  private scheduler: KeyScheduler
18
25
  private outbox: OutboxManager
@@ -107,8 +114,7 @@ export class TransactionExecutor {
107
114
  try {
108
115
  await this.runMutationFn(transaction)
109
116
  } catch (error) {
110
- const err =
111
- error instanceof Error ? error : new Error(String(error))
117
+ const err = toError(error)
112
118
 
113
119
  span.setAttribute(`result`, `error`)
114
120
 
@@ -153,8 +159,9 @@ export class TransactionExecutor {
153
159
  )
154
160
  } catch (error) {
155
161
  if (
156
- error instanceof Error &&
157
- (error as any)[HANDLED_EXECUTION_ERROR] === true
162
+ error !== null &&
163
+ typeof error === `object` &&
164
+ (error as Record<symbol, unknown>)[HANDLED_EXECUTION_ERROR] === true
158
165
  ) {
159
166
  return
160
167
  }
@@ -202,38 +209,77 @@ export class TransactionExecutor {
202
209
  'error.message': error.message,
203
210
  },
204
211
  async (span) => {
205
- const shouldRetry = this.retryPolicy.shouldRetry(
206
- error,
207
- transaction.retryCount,
208
- )
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
+ }
209
238
 
210
239
  span.setAttribute(`shouldRetry`, shouldRetry)
211
240
 
212
241
  if (!shouldRetry) {
242
+ const terminalError = hookFailure ?? error
213
243
  const rejectionPending: OfflineTransaction = {
214
244
  ...transaction,
215
245
  outboxPhase: `rejection-pending`,
216
246
  lastError: {
217
- name: error.name,
218
- message: error.message,
219
- stack: error.stack,
247
+ name: terminalError.name,
248
+ message: terminalError.message,
249
+ stack: terminalError.stack,
220
250
  },
221
251
  }
222
- console.warn(
223
- `Transaction ${transaction.id} failed permanently:`,
224
- error,
225
- )
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
+ }
226
266
  try {
227
267
  await this.removeSettledTransaction(rejectionPending, true)
228
268
  } catch (storageError) {
229
269
  span.recordException(storageError as Error)
230
270
  span.setAttribute(`result`, `outbox_failure`)
231
- this.offlineExecutor.rejectTransaction(transaction.id, error)
271
+ this.offlineExecutor.rejectTransaction(
272
+ transaction.id,
273
+ terminalError,
274
+ )
232
275
  throw storageError
233
276
  }
234
277
 
235
- span.setAttribute(`result`, `permanent_failure`)
236
- this.offlineExecutor.rejectTransaction(transaction.id, error)
278
+ span.setAttribute(
279
+ `result`,
280
+ hookFailure ? `retry_decision_failure` : `permanent_failure`,
281
+ )
282
+ this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
237
283
  return
238
284
  }
239
285
 
@@ -261,6 +307,15 @@ export class TransactionExecutor {
261
307
  await this.outbox.update(transaction.id, updatedTransaction)
262
308
  span.setAttribute(`result`, `scheduled_retry`)
263
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
+ }
264
319
  span.recordException(persistError as Error)
265
320
  span.setAttribute(`result`, `persist_failed`)
266
321
  throw persistError
@@ -287,8 +342,7 @@ export class TransactionExecutor {
287
342
  await this.outbox.remove(transaction.id)
288
343
  this.scheduler.markCompleted(transaction)
289
344
  } catch (error) {
290
- const storageError =
291
- error instanceof Error ? error : new Error(String(error))
345
+ const storageError = toError(error)
292
346
  this.fatalError = storageError
293
347
  this.scheduler.markFailed(transaction)
294
348
  this.clearRetryTimer()
@@ -408,25 +462,19 @@ export class TransactionExecutor {
408
462
 
409
463
  restorationTx.applyMutations(offlineTx.mutations)
410
464
 
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
465
+ // Register with each affected Collection. If one cannot track it, roll
466
+ // it back, so no Collection keeps a restoration nothing will settle.
467
+ try {
468
+ // The serializer resolves every mutation's Collection or throws.
469
+ for (const mutation of offlineTx.mutations) {
470
+ if (mutation.collection._state.trackTransaction(restorationTx))
471
+ mutation.collection._state.recomputeOptimisticState(true)
422
472
  }
423
- touchedCollections.add(collectionId)
424
-
425
- mutation.collection._state.transactions.set(
426
- restorationTx.id,
427
- restorationTx,
428
- )
429
- mutation.collection._state.recomputeOptimisticState(true)
473
+ } catch (error) {
474
+ // A secondary rollback settles only this restoration; it must not
475
+ // roll back the user's live transactions on the same keys.
476
+ restorationTx.rollback({ isSecondaryRollback: true })
477
+ throw error
430
478
  }
431
479
 
432
480
  this.offlineExecutor.registerRestorationTransaction(
@@ -434,6 +482,8 @@ export class TransactionExecutor {
434
482
  restorationTx,
435
483
  )
436
484
  } catch (error) {
485
+ // A live transaction with this id already shows these mutations.
486
+ if (error instanceof DuplicateTransactionIdError) continue
437
487
  console.warn(
438
488
  `Failed to restore optimistic state for transaction ${offlineTx.id}:`,
439
489
  error,
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>