@tanstack/offline-transactions 1.0.58 → 1.0.61

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 (41) hide show
  1. package/README.md +25 -5
  2. package/dist/cjs/OfflineExecutor.cjs +4 -3
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/executor/TransactionExecutor.cjs +103 -34
  5. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  6. package/dist/cjs/executor/TransactionExecutor.d.cts +4 -0
  7. package/dist/cjs/outbox/OutboxManager.cjs +4 -1
  8. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
  9. package/dist/cjs/outbox/OutboxManager.d.cts +2 -0
  10. package/dist/cjs/outbox/TransactionSerializer.cjs +8 -0
  11. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
  12. package/dist/cjs/storage/IndexedDBAdapter.cjs +1 -0
  13. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
  14. package/dist/cjs/storage/LocalStorageAdapter.cjs +1 -1
  15. package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -1
  16. package/dist/cjs/types.cjs.map +1 -1
  17. package/dist/cjs/types.d.cts +4 -0
  18. package/dist/esm/OfflineExecutor.js +4 -3
  19. package/dist/esm/OfflineExecutor.js.map +1 -1
  20. package/dist/esm/executor/TransactionExecutor.d.ts +4 -0
  21. package/dist/esm/executor/TransactionExecutor.js +103 -34
  22. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  23. package/dist/esm/outbox/OutboxManager.d.ts +2 -0
  24. package/dist/esm/outbox/OutboxManager.js +5 -2
  25. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  26. package/dist/esm/outbox/TransactionSerializer.js +8 -0
  27. package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
  28. package/dist/esm/storage/IndexedDBAdapter.js +1 -0
  29. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
  30. package/dist/esm/storage/LocalStorageAdapter.js +1 -1
  31. package/dist/esm/storage/LocalStorageAdapter.js.map +1 -1
  32. package/dist/esm/types.d.ts +4 -0
  33. package/dist/esm/types.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/OfflineExecutor.ts +4 -3
  36. package/src/executor/TransactionExecutor.ts +124 -38
  37. package/src/outbox/OutboxManager.ts +3 -1
  38. package/src/outbox/TransactionSerializer.ts +17 -1
  39. package/src/storage/IndexedDBAdapter.ts +1 -0
  40. package/src/storage/LocalStorageAdapter.ts +1 -1
  41. package/src/types.ts +4 -0
@@ -31,6 +31,8 @@ export interface OfflineTransaction {
31
31
  createdAt: Date;
32
32
  retryCount: number;
33
33
  nextAttemptAt: number;
34
+ /** Provider work has settled; only durable outbox deletion remains. */
35
+ outboxPhase?: `deletion-pending` | `rejection-pending`;
34
36
  lastError?: SerializedError;
35
37
  metadata?: Record<string, any>;
36
38
  spanContext?: SerializedSpanContext;
@@ -47,6 +49,8 @@ export interface SerializedOfflineTransaction {
47
49
  createdAt: string;
48
50
  retryCount: number;
49
51
  nextAttemptAt: number;
52
+ /** Absent in older records and while mutationFn may still need to run. */
53
+ outboxPhase?: `deletion-pending` | `rejection-pending`;
50
54
  lastError?: SerializedError;
51
55
  metadata?: Record<string, any>;
52
56
  spanContext?: SerializedSpanContext;
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n Collection,\n MutationFnParams,\n PendingMutation,\n Transaction,\n} from '@tanstack/db'\n\n// Extended mutation function that includes idempotency key\nexport type OfflineMutationFnParams<\n T extends object = Record<string, unknown>,\n> = MutationFnParams<T> & {\n idempotencyKey: string\n}\n\nexport type OfflineMutationFn<T extends object = Record<string, unknown>> = (\n params: OfflineMutationFnParams<T>,\n) => Promise<any>\n\n// Simplified mutation structure for serialization\nexport interface SerializedMutation {\n globalKey: string\n type: string\n modified: any\n original: any\n changes: any\n collectionId: string\n}\n\nexport interface SerializedError {\n name: string\n message: string\n stack?: string\n}\n\nexport interface SerializedSpanContext {\n traceId: string\n spanId: string\n traceFlags: number\n traceState?: string\n}\n\n// In-memory representation with full PendingMutation objects\nexport interface OfflineTransaction {\n id: string\n mutationFnName: string\n mutations: Array<PendingMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: Date\n retryCount: number\n nextAttemptAt: number\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\n// Serialized representation for storage\nexport interface SerializedOfflineTransaction {\n /** Absent for the original Date-marker format. */\n valueEncoding?: 2 | 3\n id: string\n mutationFnName: string\n mutations: Array<SerializedMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: string\n retryCount: number\n nextAttemptAt: number\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\n// Storage diagnostics and mode\nexport type OfflineMode = `offline` | `online-only`\n\nexport type StorageDiagnosticCode =\n | `STORAGE_AVAILABLE`\n | `INDEXEDDB_UNAVAILABLE`\n | `LOCALSTORAGE_UNAVAILABLE`\n | `STORAGE_BLOCKED`\n | `QUOTA_EXCEEDED`\n | `UNKNOWN_ERROR`\n\nexport interface StorageDiagnostic {\n code: StorageDiagnosticCode\n mode: OfflineMode\n message: string\n error?: Error\n}\n\nexport interface OfflineConfig {\n collections: Record<string, Collection<any, any, any, any, any>>\n mutationFns: Record<string, OfflineMutationFn>\n storage?: StorageAdapter\n maxConcurrency?: number\n jitter?: boolean\n beforeRetry?: (\n transactions: Array<OfflineTransaction>,\n ) => Array<OfflineTransaction>\n onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void\n onLeadershipChange?: (isLeader: boolean) => void\n onStorageFailure?: (diagnostic: StorageDiagnostic) => void\n leaderElection?: LeaderElection\n /**\n * Custom online detector implementation.\n * Defaults to WebOnlineDetector for browser environments.\n * The '@tanstack/offline-transactions/react-native' entry point uses ReactNativeOnlineDetector automatically.\n */\n onlineDetector?: OnlineDetector\n}\n\nexport interface StorageAdapter {\n get: (key: string) => Promise<string | null>\n set: (key: string, value: string) => Promise<void>\n delete: (key: string) => Promise<void>\n keys: () => Promise<Array<string>>\n clear: () => Promise<void>\n}\n\nexport interface RetryPolicy {\n calculateDelay: (retryCount: number) => number\n shouldRetry: (error: Error, retryCount: number) => boolean\n}\n\nexport interface LeaderElection {\n requestLeadership: () => Promise<boolean>\n releaseLeadership: () => void\n isLeader: () => boolean\n onLeadershipChange: (callback: (isLeader: boolean) => void) => () => void\n}\n\nexport interface TransactionSignaler {\n readonly isOfflineEnabled: boolean\n resolveTransaction: (transactionId: string, result: any) => void\n rejectTransaction: (transactionId: string, error: Error) => void\n registerRestorationTransaction: (\n offlineTransactionId: string,\n restorationTransaction: Transaction,\n ) => void\n isOnline: () => boolean\n}\n\nexport interface OnlineDetector {\n subscribe: (callback: () => void) => () => void\n notifyOnline: () => void\n isOnline: () => boolean\n dispose: () => void\n}\n\nexport interface CreateOfflineTransactionOptions {\n id?: string\n mutationFnName: string\n autoCommit?: boolean\n idempotencyKey?: string\n metadata?: Record<string, any>\n}\n\nexport interface CreateOfflineActionOptions<T> {\n mutationFnName: string\n onMutate: (variables: T) => void\n}\n\nexport class NonRetriableError extends Error {\n constructor(message: string) {\n super(message)\n this.name = `NonRetriableError`\n }\n}\n"],"names":[],"mappings":"AAqKO,MAAM,0BAA0B,MAAM;AAAA,EAC3C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;"}
1
+ {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n Collection,\n MutationFnParams,\n PendingMutation,\n Transaction,\n} from '@tanstack/db'\n\n// Extended mutation function that includes idempotency key\nexport type OfflineMutationFnParams<\n T extends object = Record<string, unknown>,\n> = MutationFnParams<T> & {\n idempotencyKey: string\n}\n\nexport type OfflineMutationFn<T extends object = Record<string, unknown>> = (\n params: OfflineMutationFnParams<T>,\n) => Promise<any>\n\n// Simplified mutation structure for serialization\nexport interface SerializedMutation {\n globalKey: string\n type: string\n modified: any\n original: any\n changes: any\n collectionId: string\n}\n\nexport interface SerializedError {\n name: string\n message: string\n stack?: string\n}\n\nexport interface SerializedSpanContext {\n traceId: string\n spanId: string\n traceFlags: number\n traceState?: string\n}\n\n// In-memory representation with full PendingMutation objects\nexport interface OfflineTransaction {\n id: string\n mutationFnName: string\n mutations: Array<PendingMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: Date\n retryCount: number\n nextAttemptAt: number\n /** Provider work has settled; only durable outbox deletion remains. */\n outboxPhase?: `deletion-pending` | `rejection-pending`\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\n// Serialized representation for storage\nexport interface SerializedOfflineTransaction {\n /** Absent for the original Date-marker format. */\n valueEncoding?: 2 | 3\n id: string\n mutationFnName: string\n mutations: Array<SerializedMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: string\n retryCount: number\n nextAttemptAt: number\n /** Absent in older records and while mutationFn may still need to run. */\n outboxPhase?: `deletion-pending` | `rejection-pending`\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\n// Storage diagnostics and mode\nexport type OfflineMode = `offline` | `online-only`\n\nexport type StorageDiagnosticCode =\n | `STORAGE_AVAILABLE`\n | `INDEXEDDB_UNAVAILABLE`\n | `LOCALSTORAGE_UNAVAILABLE`\n | `STORAGE_BLOCKED`\n | `QUOTA_EXCEEDED`\n | `UNKNOWN_ERROR`\n\nexport interface StorageDiagnostic {\n code: StorageDiagnosticCode\n mode: OfflineMode\n message: string\n error?: Error\n}\n\nexport interface OfflineConfig {\n collections: Record<string, Collection<any, any, any, any, any>>\n mutationFns: Record<string, OfflineMutationFn>\n storage?: StorageAdapter\n maxConcurrency?: number\n jitter?: boolean\n beforeRetry?: (\n transactions: Array<OfflineTransaction>,\n ) => Array<OfflineTransaction>\n onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void\n onLeadershipChange?: (isLeader: boolean) => void\n onStorageFailure?: (diagnostic: StorageDiagnostic) => void\n leaderElection?: LeaderElection\n /**\n * Custom online detector implementation.\n * Defaults to WebOnlineDetector for browser environments.\n * The '@tanstack/offline-transactions/react-native' entry point uses ReactNativeOnlineDetector automatically.\n */\n onlineDetector?: OnlineDetector\n}\n\nexport interface StorageAdapter {\n get: (key: string) => Promise<string | null>\n set: (key: string, value: string) => Promise<void>\n delete: (key: string) => Promise<void>\n keys: () => Promise<Array<string>>\n clear: () => Promise<void>\n}\n\nexport interface RetryPolicy {\n calculateDelay: (retryCount: number) => number\n shouldRetry: (error: Error, retryCount: number) => boolean\n}\n\nexport interface LeaderElection {\n requestLeadership: () => Promise<boolean>\n releaseLeadership: () => void\n isLeader: () => boolean\n onLeadershipChange: (callback: (isLeader: boolean) => void) => () => void\n}\n\nexport interface TransactionSignaler {\n readonly isOfflineEnabled: boolean\n resolveTransaction: (transactionId: string, result: any) => void\n rejectTransaction: (transactionId: string, error: Error) => void\n registerRestorationTransaction: (\n offlineTransactionId: string,\n restorationTransaction: Transaction,\n ) => void\n isOnline: () => boolean\n}\n\nexport interface OnlineDetector {\n subscribe: (callback: () => void) => () => void\n notifyOnline: () => void\n isOnline: () => boolean\n dispose: () => void\n}\n\nexport interface CreateOfflineTransactionOptions {\n id?: string\n mutationFnName: string\n autoCommit?: boolean\n idempotencyKey?: string\n metadata?: Record<string, any>\n}\n\nexport interface CreateOfflineActionOptions<T> {\n mutationFnName: string\n onMutate: (variables: T) => void\n}\n\nexport class NonRetriableError extends Error {\n constructor(message: string) {\n super(message)\n this.name = `NonRetriableError`\n }\n}\n"],"names":[],"mappings":"AAyKO,MAAM,0BAA0B,MAAM;AAAA,EAC3C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/offline-transactions",
3
- "version": "1.0.58",
3
+ "version": "1.0.61",
4
4
  "description": "Offline-first transaction capabilities for TanStack DB",
5
5
  "author": "TanStack",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "skills"
53
53
  ],
54
54
  "dependencies": {
55
- "@tanstack/db": "0.11.0"
55
+ "@tanstack/db": "0.11.3"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@react-native-community/netinfo": ">=11.0.0",
@@ -27,6 +27,7 @@ import { createOfflineAction } from './api/OfflineAction'
27
27
 
28
28
  // Replay
29
29
  import { withNestedSpan, withSpan } from './telemetry/tracer'
30
+ import { NonRetriableError } from './types'
30
31
  import type {
31
32
  CreateOfflineActionOptions,
32
33
  CreateOfflineTransactionOptions,
@@ -448,12 +449,12 @@ export class OfflineExecutor {
448
449
  },
449
450
  async (span) => {
450
451
  if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
451
- span.setAttribute(`result`, `skipped_not_leader`)
452
- this.resolveTransaction(transaction.id, undefined)
453
- return
452
+ span.setAttribute(`result`, `rejected_not_admitted`)
453
+ throw new NonRetriableError(`Offline transaction was not admitted`)
454
454
  }
455
455
 
456
456
  try {
457
+ this.executor.assertHealthy()
457
458
  await this.outbox.add(transaction)
458
459
  // A shared queue error belongs to its execution, not every caller
459
460
  // that durably admitted a transaction. Per-ID signals settle callers.
@@ -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
  }
@@ -89,39 +93,62 @@ export class TransactionExecutor {
89
93
  'transaction.keyCount': transaction.keys.length,
90
94
  },
91
95
  async (span) => {
96
+ const terminalError =
97
+ transaction.outboxPhase === `rejection-pending`
98
+ ? this.getTerminalError(transaction)
99
+ : undefined
92
100
  this.scheduler.markStarted(transaction)
93
101
 
94
102
  if (transaction.retryCount > 0) {
95
103
  span.setAttribute(`retry.attempt`, transaction.retryCount)
96
104
  }
97
105
 
98
- let result: void
99
- try {
100
- result = await this.runMutationFn(transaction)
101
- } catch (error) {
102
- const err =
103
- error instanceof Error ? error : new Error(String(error))
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))
104
112
 
105
- span.setAttribute(`result`, `error`)
113
+ span.setAttribute(`result`, `error`)
106
114
 
107
- await this.handleError(transaction, err)
108
- ;(err as any)[HANDLED_EXECUTION_ERROR] = true
109
- throw err
115
+ await this.handleError(transaction, err)
116
+ ;(err as any)[HANDLED_EXECUTION_ERROR] = true
117
+ throw err
118
+ }
110
119
  }
111
120
 
112
- let removalError: unknown
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` }
113
126
  try {
114
- // Replay can still see this ID until durable deletion settles.
115
- await this.outbox.remove(transaction.id)
127
+ await this.removeSettledTransaction(
128
+ deletionPending,
129
+ !transaction.outboxPhase,
130
+ )
116
131
  } catch (error) {
117
- removalError = error
118
- } finally {
119
- this.scheduler.markCompleted(transaction)
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
120
140
  }
121
141
 
122
- span.setAttribute(`result`, `success`)
123
- this.offlineExecutor.resolveTransaction(transaction.id, result)
124
- if (removalError !== undefined) throw removalError
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
+ }
125
152
  },
126
153
  )
127
154
  } catch (error) {
@@ -183,23 +210,30 @@ export class TransactionExecutor {
183
210
  span.setAttribute(`shouldRetry`, shouldRetry)
184
211
 
185
212
  if (!shouldRetry) {
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)
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
+ },
193
221
  }
194
222
  console.warn(
195
223
  `Transaction ${transaction.id} failed permanently:`,
196
224
  error,
197
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
+ }
198
234
 
199
235
  span.setAttribute(`result`, `permanent_failure`)
200
- // Signal permanent failure to the waiting transaction
201
236
  this.offlineExecutor.rejectTransaction(transaction.id, error)
202
- if (removalError !== undefined) throw removalError
203
237
  return
204
238
  }
205
239
 
@@ -237,16 +271,65 @@ export class TransactionExecutor {
237
271
  )
238
272
  }
239
273
 
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
296
+ }
297
+ }
298
+
299
+ assertHealthy(): void {
300
+ if (this.fatalError) throw this.fatalError
301
+ }
302
+
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
+
240
316
  async loadPendingTransactions(): Promise<void> {
241
317
  let removedIds: Array<string> = []
242
318
  await this.outbox.withAll((transactions) => {
243
319
  const { isOfflineEnabled } = this.offlineExecutor
244
320
  if (!isOfflineEnabled) return
245
- let filteredTransactions = transactions
246
-
247
- if (this.config.beforeRetry) {
248
- filteredTransactions = this.config.beforeRetry(transactions)
249
- }
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
+ })
250
333
 
251
334
  // The retry hook is user code and may synchronously revoke replay rights.
252
335
  if (!this.offlineExecutor.isOfflineEnabled) return
@@ -255,15 +338,18 @@ export class TransactionExecutor {
255
338
  this.scheduler.schedule(transaction),
256
339
  )
257
340
 
258
- const retainedIds = new Set(filteredTransactions.map(({ id }) => id))
259
- removedIds = transactions
260
- .filter(({ id }) => !retainedIds.has(id))
341
+ removedIds = providerPending
342
+ .filter(({ id }) => !selectedById.has(id))
261
343
  .map(({ id }) => id)
262
344
  removedIds = this.scheduler.removePendingTransactions(removedIds)
263
345
 
264
346
  // Restore optimistic state for loaded transactions
265
347
  // This ensures the UI shows the optimistic data while transactions are pending
266
- this.restoreOptimisticState(newlyLoaded)
348
+ this.restoreOptimisticState(
349
+ newlyLoaded.filter(
350
+ (transaction) => transaction.outboxPhase !== `rejection-pending`,
351
+ ),
352
+ )
267
353
 
268
354
  // Reset retry delays for all loaded transactions so they can run immediately
269
355
  this.resetRetryDelays()
@@ -374,7 +460,7 @@ export class TransactionExecutor {
374
460
  // Clear existing timer
375
461
  this.clearRetryTimer()
376
462
 
377
- if (!this.canExecute()) {
463
+ if (this.fatalError || !this.canExecute()) {
378
464
  return
379
465
  }
380
466
 
@@ -6,6 +6,8 @@ import {
6
6
  import type { OfflineTransaction, StorageAdapter } from '../types'
7
7
  import type { Collection } from '@tanstack/db'
8
8
 
9
+ export class OutboxTransactionNotFoundError extends Error {}
10
+
9
11
  export class OutboxManager {
10
12
  private storage: StorageAdapter
11
13
  private serializer: TransactionSerializer
@@ -158,7 +160,7 @@ export class OutboxManager {
158
160
  return withSpan(`outbox.update`, { 'transaction.id': id }, async () => {
159
161
  const existing = await this.get(id)
160
162
  if (!existing) {
161
- throw new Error(`Transaction ${id} not found`)
163
+ throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)
162
164
  }
163
165
 
164
166
  const updated = { ...existing, ...updates }
@@ -94,6 +94,17 @@ function setDataProperty(
94
94
  })
95
95
  }
96
96
 
97
+ function assertOutboxPhase(
98
+ phase: unknown,
99
+ ): asserts phase is `deletion-pending` | `rejection-pending` | undefined {
100
+ if (
101
+ phase !== undefined &&
102
+ phase !== `deletion-pending` &&
103
+ phase !== `rejection-pending`
104
+ )
105
+ throw new Error(`Unsupported transaction outbox phase`)
106
+ }
107
+
97
108
  export class TransactionSerializer {
98
109
  private collections: Record<string, Collection<any, any, any, any, any>>
99
110
  private collectionIdToKey: Map<string, string>
@@ -110,6 +121,7 @@ export class TransactionSerializer {
110
121
  }
111
122
 
112
123
  serialize(transaction: OfflineTransaction): string {
124
+ assertOutboxPhase(transaction.outboxPhase)
113
125
  const serialized: SerializedOfflineTransaction = {
114
126
  ...transaction,
115
127
  valueEncoding: 3,
@@ -127,9 +139,11 @@ export class TransactionSerializer {
127
139
  // marker-shaped user objects; the encoding tag is not application metadata.
128
140
  const {
129
141
  valueEncoding,
142
+ outboxPhase,
130
143
  ...parsed
131
- }: Omit<SerializedOfflineTransaction, `valueEncoding`> & {
144
+ }: Omit<SerializedOfflineTransaction, `valueEncoding` | `outboxPhase`> & {
132
145
  valueEncoding?: unknown
146
+ outboxPhase?: unknown
133
147
  } = JSON.parse(data)
134
148
  if (
135
149
  valueEncoding !== undefined &&
@@ -140,6 +154,7 @@ export class TransactionSerializer {
140
154
  `Unsupported transaction value encoding: ${valueEncoding}`,
141
155
  )
142
156
  }
157
+ assertOutboxPhase(outboxPhase)
143
158
 
144
159
  const createdAt = new Date(parsed.createdAt)
145
160
  if (isNaN(createdAt.getTime())) {
@@ -150,6 +165,7 @@ export class TransactionSerializer {
150
165
 
151
166
  return {
152
167
  ...parsed,
168
+ ...(outboxPhase === undefined ? {} : { outboxPhase }),
153
169
  createdAt,
154
170
  metadata:
155
171
  valueEncoding === 3
@@ -147,6 +147,7 @@ export class IndexedDBAdapter extends BaseStorageAdapter {
147
147
  })
148
148
  } catch (error) {
149
149
  console.warn(`IndexedDB delete failed:`, error)
150
+ throw error
150
151
  }
151
152
  }
152
153
 
@@ -84,7 +84,7 @@ export class LocalStorageAdapter extends BaseStorageAdapter {
84
84
  return Promise.resolve()
85
85
  } catch (error) {
86
86
  console.warn(`localStorage delete failed:`, error)
87
- return Promise.resolve()
87
+ return Promise.reject(error)
88
88
  }
89
89
  }
90
90
 
package/src/types.ts CHANGED
@@ -49,6 +49,8 @@ export interface OfflineTransaction {
49
49
  createdAt: Date
50
50
  retryCount: number
51
51
  nextAttemptAt: number
52
+ /** Provider work has settled; only durable outbox deletion remains. */
53
+ outboxPhase?: `deletion-pending` | `rejection-pending`
52
54
  lastError?: SerializedError
53
55
  metadata?: Record<string, any>
54
56
  spanContext?: SerializedSpanContext
@@ -67,6 +69,8 @@ export interface SerializedOfflineTransaction {
67
69
  createdAt: string
68
70
  retryCount: number
69
71
  nextAttemptAt: number
72
+ /** Absent in older records and while mutationFn may still need to run. */
73
+ outboxPhase?: `deletion-pending` | `rejection-pending`
70
74
  lastError?: SerializedError
71
75
  metadata?: Record<string, any>
72
76
  spanContext?: SerializedSpanContext