@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 +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 /** 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;"}
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 /**\n * Override retry decisions for errors from all named mutation functions.\n * Return synchronously; undefined uses the default decision. Put any\n * function-specific context needed by this shared hook on the Error.\n * NonRetriableError always stops retry.\n * A throw or invalid return fails only that outbox row and rejects its\n * waiters with the hook failure. Queued work runs after terminal cleanup.\n * The default policy still supplies the delay and configured jitter.\n */\n shouldRetry?: (error: Error, retryCount: number) => boolean | undefined\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":"AAmLO,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.65",
3
+ "version": "1.1.1",
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.12.3"
55
+ "@tanstack/db": "0.14.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@react-native-community/netinfo": ">=11.0.0",
@@ -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
 
@@ -26,7 +26,6 @@ import { createOfflineAction } from './api/OfflineAction'
26
26
  // TanStack DB primitives
27
27
 
28
28
  // Replay
29
- import { withNestedSpan, withSpan } from './telemetry/tracer'
30
29
  import { NonRetriableError } from './types'
31
30
  import type {
32
31
  CreateOfflineActionOptions,
@@ -41,6 +40,9 @@ import type {
41
40
  } from './types'
42
41
  import type { Transaction } from '@tanstack/db'
43
42
 
43
+ /** How many settled ids an executor remembers to reject late waiters. */
44
+ const SETTLED_TRANSACTIONS_REMEMBERED = 1000
45
+
44
46
  export class OfflineExecutor {
45
47
  private config: OfflineConfig
46
48
 
@@ -75,6 +77,12 @@ export class OfflineExecutor {
75
77
  }
76
78
  > = new Map()
77
79
 
80
+ /**
81
+ * Ids this executor settled, oldest first. A waiter for one of them has
82
+ * nothing left to wait for. An unknown id can still arrive from another tab.
83
+ */
84
+ private readonly settledTransactions = new Set<string>()
85
+
78
86
  // Track restoration transactions for cleanup when offline transactions complete
79
87
  private restorationTransactions: Map<string, Transaction> = new Map()
80
88
 
@@ -268,77 +276,66 @@ export class OfflineExecutor {
268
276
  }
269
277
 
270
278
  private async initialize(): Promise<void> {
271
- return withSpan(`executor.initialize`, {}, async (span) => {
272
- try {
273
- // Probe storage and create adapter
274
- const { storage, diagnostic } = await this.createStorage()
275
- if (this.disposed) {
276
- this.initResolve()
277
- return
278
- }
279
-
280
- // Cast to writable to set readonly properties
281
- ;(this as any).storage = storage
282
- ;(this as any).storageDiagnostic = diagnostic
283
- ;(this as any).mode = diagnostic.mode
279
+ try {
280
+ // Probe storage and create adapter
281
+ const { storage, diagnostic } = await this.createStorage()
282
+ if (this.disposed) {
283
+ this.initResolve()
284
+ return
285
+ }
284
286
 
285
- span.setAttribute(`storage.mode`, diagnostic.mode)
286
- span.setAttribute(`storage.code`, diagnostic.code)
287
+ // Cast to writable to set readonly properties
288
+ ;(this as any).storage = storage
289
+ ;(this as any).storageDiagnostic = diagnostic
290
+ ;(this as any).mode = diagnostic.mode
287
291
 
288
- if (!storage) {
289
- // Online-only mode - notify callback and skip offline setup
290
- if (this.config.onStorageFailure) {
291
- this.config.onStorageFailure(diagnostic)
292
- }
293
- span.setAttribute(`result`, `online-only`)
294
- this.initResolve()
295
- return
292
+ if (!storage) {
293
+ // Online-only mode - notify callback and skip offline setup
294
+ if (this.config.onStorageFailure) {
295
+ this.config.onStorageFailure(diagnostic)
296
296
  }
297
+ this.initResolve()
298
+ return
299
+ }
297
300
 
298
- // Storage available - set up offline components
299
- this.outbox = new OutboxManager(storage, this.config.collections)
300
- this.executor = new TransactionExecutor(
301
- this.scheduler,
302
- this.outbox,
303
- this.config,
304
- this,
305
- )
306
- this.leaderElection = this.createLeaderElection()
307
-
308
- // Request leadership first
309
- const isLeader = await this.leaderElection.requestLeadership()
310
- // Disposal may have run while leadership was pending.
311
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
312
- if (this.disposed) {
313
- this.leaderElection.releaseLeadership()
314
- this.initResolve()
315
- return
316
- }
317
- this.isLeaderState = isLeader
318
- span.setAttribute(`isLeader`, isLeader)
301
+ // Storage available - set up offline components
302
+ this.outbox = new OutboxManager(storage, this.config.collections)
303
+ this.executor = new TransactionExecutor(
304
+ this.scheduler,
305
+ this.outbox,
306
+ this.config,
307
+ this,
308
+ )
309
+ this.leaderElection = this.createLeaderElection()
319
310
 
320
- // Set up event listeners after leadership is established
321
- // This prevents the callback from being called multiple times
322
- this.setupEventListeners()
311
+ // Request leadership first
312
+ const isLeader = await this.leaderElection.requestLeadership()
313
+ // Disposal may have run while leadership was pending.
314
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
315
+ if (this.disposed) {
316
+ this.leaderElection.releaseLeadership()
317
+ this.initResolve()
318
+ return
319
+ }
320
+ this.isLeaderState = isLeader
323
321
 
324
- // Notify initial leadership state
325
- if (this.config.onLeadershipChange) {
326
- this.config.onLeadershipChange(isLeader)
327
- }
322
+ // Set up event listeners after leadership is established
323
+ // This prevents the callback from being called multiple times
324
+ this.setupEventListeners()
328
325
 
329
- if (isLeader) {
330
- await this.loadAndReplayTransactions()
331
- }
332
- span.setAttribute(`result`, `offline-enabled`)
333
- this.initResolve()
334
- } catch (error) {
335
- console.warn(`Failed to initialize offline executor:`, error)
336
- span.setAttribute(`result`, `failed`)
337
- this.initReject(
338
- error instanceof Error ? error : new Error(String(error)),
339
- )
326
+ // Notify initial leadership state
327
+ if (this.config.onLeadershipChange) {
328
+ this.config.onLeadershipChange(isLeader)
340
329
  }
341
- })
330
+
331
+ if (isLeader) {
332
+ await this.loadAndReplayTransactions()
333
+ }
334
+ this.initResolve()
335
+ } catch (error) {
336
+ console.warn(`Failed to initialize offline executor:`, error)
337
+ this.initReject(error instanceof Error ? error : new Error(String(error)))
338
+ }
342
339
  }
343
340
 
344
341
  private async loadAndReplayTransactions(): Promise<void> {
@@ -441,37 +438,33 @@ export class OfflineExecutor {
441
438
  // Wait for initialization to complete
442
439
  await this.initPromise
443
440
 
444
- return withNestedSpan(
445
- `executor.persistTransaction`,
446
- {
447
- 'transaction.id': transaction.id,
448
- 'transaction.mutationFnName': transaction.mutationFnName,
449
- },
450
- async (span) => {
451
- if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
452
- span.setAttribute(`result`, `rejected_not_admitted`)
453
- throw new NonRetriableError(`Offline transaction was not admitted`)
454
- }
441
+ if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
442
+ throw new NonRetriableError(`Offline transaction was not admitted`)
443
+ }
455
444
 
456
- try {
457
- this.executor.assertHealthy()
458
- await this.outbox.add(transaction)
459
- // A shared queue error belongs to its execution, not every caller
460
- // that durably admitted a transaction. Per-ID signals settle callers.
461
- this.executor.execute(transaction).catch((error) => {
462
- console.warn(`Failed to execute transactions:`, error)
463
- })
464
- span.setAttribute(`result`, `persisted`)
465
- } catch (error) {
466
- console.error(
467
- `Failed to persist offline transaction ${transaction.id}:`,
468
- error,
469
- )
470
- span.setAttribute(`result`, `failed`)
471
- throw error
472
- }
473
- },
474
- )
445
+ try {
446
+ this.executor.assertHealthy()
447
+ await this.outbox.add(transaction)
448
+ // A shared queue error belongs to its execution, not every caller
449
+ // that durably admitted a transaction. Per-ID signals settle callers.
450
+ this.executor.execute(transaction).catch((error) => {
451
+ console.warn(`Failed to execute transactions:`, error)
452
+ })
453
+ } catch (error) {
454
+ console.error(
455
+ `Failed to persist offline transaction ${transaction.id}:`,
456
+ error,
457
+ )
458
+ throw error
459
+ }
460
+ }
461
+
462
+ private rememberSettled(transactionId: string): void {
463
+ this.settledTransactions.add(transactionId)
464
+ if (this.settledTransactions.size > SETTLED_TRANSACTIONS_REMEMBERED)
465
+ this.settledTransactions.delete(
466
+ this.settledTransactions.values().next().value!,
467
+ )
475
468
  }
476
469
 
477
470
  // Method for OfflineTransaction to wait for completion
@@ -480,6 +473,10 @@ export class OfflineExecutor {
480
473
  if (existing) {
481
474
  return existing.promise
482
475
  }
476
+ if (this.settledTransactions.has(transactionId))
477
+ throw new Error(
478
+ `Offline transaction ${transactionId} already settled; wait for it before it settles`,
479
+ )
483
480
 
484
481
  const deferred: {
485
482
  promise: Promise<any>
@@ -498,6 +495,7 @@ export class OfflineExecutor {
498
495
 
499
496
  // Method for TransactionExecutor to signal completion
500
497
  resolveTransaction(transactionId: string, result: any): void {
498
+ this.rememberSettled(transactionId)
501
499
  const deferred = this.pendingTransactionPromises.get(transactionId)
502
500
  if (deferred) {
503
501
  deferred.resolve(result)
@@ -510,6 +508,7 @@ export class OfflineExecutor {
510
508
 
511
509
  // Method for TransactionExecutor to signal failure
512
510
  rejectTransaction(transactionId: string, error: Error): void {
511
+ this.rememberSettled(transactionId)
513
512
  const deferred = this.pendingTransactionPromises.get(transactionId)
514
513
  if (deferred) {
515
514
  deferred.reject(error)
@@ -547,26 +546,11 @@ export class OfflineExecutor {
547
546
  return
548
547
  }
549
548
 
550
- // Mark as completed so recomputeOptimisticState removes it from consideration.
551
- // The actual data will come from the sync.
549
+ // Mark as completed: every Collection that tracked it recomputes and
550
+ // releases it. The actual data will come from the sync.
552
551
  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
- }
552
+ restorationTx.isPersisted.resolve(restorationTx)
553
+ restorationTx.touchCollection()
570
554
  }
571
555
 
572
556
  async removeFromOutbox(id: string): Promise<void> {
@@ -22,44 +22,26 @@ export function createOfflineAction<T>(
22
22
  executor: any,
23
23
  ): (variables: T) => Transaction {
24
24
  const { mutationFnName, onMutate } = options
25
- console.log(`createOfflineAction 2`, options)
26
25
 
27
26
  return (variables: T): Transaction => {
28
27
  const offlineTransaction = new OfflineTransaction(
29
28
  {
30
29
  mutationFnName,
31
- autoCommit: false,
30
+ // The transaction commits after onMutate and reports a failure
31
+ // through `when('settled')`.
32
+ autoCommit: true,
32
33
  },
33
34
  mutationFn,
34
35
  persistTransaction,
35
36
  executor,
36
37
  )
37
38
 
38
- const transaction = offlineTransaction.mutate(() => {
39
- console.log(`mutate`)
39
+ return offlineTransaction.mutate(() => {
40
40
  const maybePromise = onMutate(variables) as unknown
41
41
 
42
42
  if (isPromiseLike(maybePromise)) {
43
43
  throw new OnMutateMustBeSynchronousError()
44
44
  }
45
45
  })
46
-
47
- // Immediately commit
48
- const commitPromise = (async () => {
49
- try {
50
- await transaction.commit()
51
- console.log(`offlineAction committed - success`)
52
- } catch {
53
- console.log(`offlineAction commit failed - error`)
54
- }
55
- })()
56
-
57
- // Don't await - this is fire-and-forget for optimistic actions
58
- // But catch to prevent unhandled rejection
59
- commitPromise.catch(() => {
60
- // Already handled in try/catch above
61
- })
62
-
63
- return transaction
64
46
  }
65
47
  }
@@ -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,63 +35,81 @@ export class OfflineTransaction {
33
35
  }
34
36
 
35
37
  mutate(callback: () => void): Transaction {
36
- this.transaction = createTransaction({
37
- id: this.offlineId,
38
- autoCommit: false,
39
- mutationFn: async () => {
40
- // This is the blocking mutationFn that waits for the executor
41
- // First persist the transaction to the outbox
42
- const offlineTransaction: OfflineTransactionType = {
43
- id: this.offlineId,
44
- mutationFnName: this.mutationFnName,
45
- mutations: this.transaction!.mutations,
46
- keys: this.extractKeys(this.transaction!.mutations),
47
- idempotencyKey: this.idempotencyKey,
48
- createdAt: new Date(),
49
- retryCount: 0,
50
- nextAttemptAt: Date.now(),
51
- metadata: this.metadata,
52
- spanContext: undefined,
53
- version: 1,
54
- }
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
+ }
55
63
 
56
- const completionPromise = this.executor.waitForTransactionCompletion(
57
- this.offlineId,
58
- )
64
+ const completionPromise = this.executor.waitForTransactionCompletion(
65
+ this.offlineId,
66
+ )
59
67
 
60
- try {
61
- // Persistence also drives the shared queue. This transaction can finish
62
- // before that queue drains; observe both promises from the outset.
63
- await Promise.race([
64
- this.persistTransaction(offlineTransaction),
65
- completionPromise,
66
- ])
67
- // A queue pause (offline or retry) is not transaction completion.
68
- await completionPromise
69
- } catch (error) {
70
- const normalizedError =
71
- error instanceof Error ? error : new Error(String(error))
72
- this.executor.rejectTransaction(this.offlineId, normalizedError)
73
- throw error
74
- }
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
+ }
75
83
 
76
- return
77
- },
78
- metadata: this.metadata,
79
- })
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
+ }
80
108
 
81
109
  this.transaction.mutate(() => {
82
110
  callback()
83
111
  })
84
112
 
85
- if (this.autoCommit) {
86
- // Auto-commit for direct OfflineTransaction usage
87
- this.commit().catch((error) => {
88
- console.error(`Auto-commit failed:`, error)
89
- throw error
90
- })
91
- }
92
-
93
113
  return this.transaction
94
114
  }
95
115
 
@@ -98,18 +118,12 @@ export class OfflineTransaction {
98
118
  throw new Error(`No mutations to commit. Call mutate() first.`)
99
119
  }
100
120
 
101
- try {
102
- // Commit the TanStack DB transaction
103
- // This will trigger the mutationFn which handles persistence and waiting
104
- await this.transaction.commit()
105
- return this.transaction
106
- } catch (error) {
107
- // Only rollback for NonRetriableError - other errors should allow retry
108
- if (error instanceof NonRetriableError) {
109
- this.transaction.rollback()
110
- }
111
- throw error
112
- }
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
113
127
  }
114
128
 
115
129
  rollback(): void {