@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 +1 @@
1
- {"version":3,"file":"TransactionExecutor.js","sources":["../../../src/executor/TransactionExecutor.ts"],"sourcesContent":["import { DuplicateTransactionIdError, createTransaction } from '@tanstack/db'\nimport { OutboxTransactionNotFoundError } from '../outbox/OutboxManager'\nimport { DefaultRetryPolicy } from '../retry/RetryPolicy'\nimport { NonRetriableError } from '../types'\nimport { withNestedSpan } from '../telemetry/tracer'\nimport type { KeyScheduler } from './KeyScheduler'\nimport type { OutboxManager } from '../outbox/OutboxManager'\nimport type {\n OfflineConfig,\n OfflineTransaction,\n TransactionSignaler,\n} from '../types'\n\nconst HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)\n\nfunction toError(value: unknown): Error {\n return value instanceof Error ||\n Object.prototype.toString.call(value) === `[object Error]`\n ? (value as Error)\n : new Error(String(value))\n}\n\nexport class TransactionExecutor {\n private scheduler: KeyScheduler\n private outbox: OutboxManager\n private config: OfflineConfig\n private retryPolicy: DefaultRetryPolicy\n private isExecuting = false\n private executionPromise: Promise<void> | null = null\n private offlineExecutor: TransactionSignaler\n private retryTimer: ReturnType<typeof setTimeout> | null = null\n private fatalError: Error | null = null\n\n constructor(\n scheduler: KeyScheduler,\n outbox: OutboxManager,\n config: OfflineConfig,\n offlineExecutor: TransactionSignaler,\n ) {\n this.scheduler = scheduler\n this.outbox = outbox\n this.config = config\n this.retryPolicy = new DefaultRetryPolicy(\n Number.POSITIVE_INFINITY,\n config.jitter ?? true,\n )\n this.offlineExecutor = offlineExecutor\n }\n\n async execute(transaction: OfflineTransaction): Promise<void> {\n this.assertHealthy()\n this.scheduler.schedule(transaction)\n await this.executeAll()\n }\n\n async executeAll(): Promise<void> {\n this.assertHealthy()\n if (this.isExecuting) {\n return this.executionPromise!\n }\n\n this.isExecuting = true\n this.executionPromise = this.runExecution()\n\n try {\n await this.executionPromise\n } finally {\n this.isExecuting = false\n this.executionPromise = null\n this.scheduleNextRetry()\n }\n }\n\n private async runExecution(): Promise<void> {\n while (this.scheduler.getPendingCount() > 0) {\n if (!this.canExecute()) {\n break\n }\n\n const transaction = this.scheduler.getNext()\n\n if (!transaction) {\n break\n }\n\n await this.executeTransaction(transaction)\n }\n }\n\n private async executeTransaction(\n transaction: OfflineTransaction,\n ): Promise<void> {\n try {\n await withNestedSpan(\n `transaction.execute`,\n {\n 'transaction.id': transaction.id,\n 'transaction.mutationFnName': transaction.mutationFnName,\n 'transaction.retryCount': transaction.retryCount,\n 'transaction.keyCount': transaction.keys.length,\n },\n async (span) => {\n const terminalError =\n transaction.outboxPhase === `rejection-pending`\n ? this.getTerminalError(transaction)\n : undefined\n this.scheduler.markStarted(transaction)\n\n if (transaction.retryCount > 0) {\n span.setAttribute(`retry.attempt`, transaction.retryCount)\n }\n\n if (!transaction.outboxPhase) {\n try {\n await this.runMutationFn(transaction)\n } catch (error) {\n const err = toError(error)\n\n span.setAttribute(`result`, `error`)\n\n await this.handleError(transaction, err)\n ;(err as any)[HANDLED_EXECUTION_ERROR] = true\n throw err\n }\n }\n\n // A successful provider call can outlive this executor. Persist that\n // fact before removal so a restarted executor only retries deletion.\n const deletionPending: OfflineTransaction = transaction.outboxPhase\n ? transaction\n : { ...transaction, outboxPhase: `deletion-pending` }\n try {\n await this.removeSettledTransaction(\n deletionPending,\n !transaction.outboxPhase,\n )\n } catch (error) {\n const storageError = error as Error\n span.recordException(storageError)\n span.setAttribute(`result`, `outbox_failure`)\n this.offlineExecutor.rejectTransaction(\n transaction.id,\n terminalError ?? storageError,\n )\n throw storageError\n }\n\n if (terminalError) {\n span.setAttribute(`result`, `permanent_failure`)\n this.offlineExecutor.rejectTransaction(\n transaction.id,\n terminalError,\n )\n } else {\n span.setAttribute(`result`, `success`)\n this.offlineExecutor.resolveTransaction(transaction.id, undefined)\n }\n },\n )\n } catch (error) {\n if (\n error !== null &&\n typeof error === `object` &&\n (error as Record<symbol, unknown>)[HANDLED_EXECUTION_ERROR] === true\n ) {\n return\n }\n\n throw error\n }\n }\n\n private async runMutationFn(transaction: OfflineTransaction): Promise<void> {\n const mutationFn = this.config.mutationFns[transaction.mutationFnName]\n\n if (!mutationFn) {\n const errorMessage = `Unknown mutation function: ${transaction.mutationFnName}`\n\n if (this.config.onUnknownMutationFn) {\n this.config.onUnknownMutationFn(transaction.mutationFnName, transaction)\n }\n\n throw new NonRetriableError(errorMessage)\n }\n\n // Mutations are already PendingMutation objects with collections attached\n // from the deserializer, so we can use them directly\n const transactionWithMutations = {\n id: transaction.id,\n mutations: transaction.mutations,\n metadata: transaction.metadata ?? {},\n }\n\n await mutationFn({\n transaction: transactionWithMutations as any,\n idempotencyKey: transaction.idempotencyKey,\n })\n }\n\n private async handleError(\n transaction: OfflineTransaction,\n error: Error,\n ): Promise<void> {\n return withNestedSpan(\n `transaction.handleError`,\n {\n 'transaction.id': transaction.id,\n 'error.name': error.name,\n 'error.message': error.message,\n },\n async (span) => {\n let shouldRetry: boolean\n let hookFailure: Error | undefined\n if (error instanceof NonRetriableError) {\n shouldRetry = false\n } else {\n let decision: boolean | undefined\n try {\n decision = this.config.shouldRetry?.(error, transaction.retryCount)\n if (decision !== undefined && typeof decision !== `boolean`) {\n // An async hook is invalid, but its rejection must stay row-local.\n void Promise.resolve(decision).catch(() => {})\n throw new TypeError(\n `OfflineConfig.shouldRetry must return true, false, or undefined`,\n )\n }\n } catch (hookError) {\n hookFailure = toError(hookError)\n }\n if (hookFailure) {\n shouldRetry = false\n } else {\n shouldRetry =\n decision ??\n this.retryPolicy.shouldRetry(error, transaction.retryCount)\n }\n }\n\n span.setAttribute(`shouldRetry`, shouldRetry)\n\n if (!shouldRetry) {\n const terminalError = hookFailure ?? error\n const rejectionPending: OfflineTransaction = {\n ...transaction,\n outboxPhase: `rejection-pending`,\n lastError: {\n name: terminalError.name,\n message: terminalError.message,\n stack: terminalError.stack,\n },\n }\n if (hookFailure) {\n span.recordException(hookFailure)\n console.warn(\n `Retry decision failed for transaction ${transaction.id}:`,\n hookFailure,\n `Named mutation function error:`,\n error,\n )\n } else {\n console.warn(\n `Transaction ${transaction.id} failed permanently:`,\n error,\n )\n }\n try {\n await this.removeSettledTransaction(rejectionPending, true)\n } catch (storageError) {\n span.recordException(storageError as Error)\n span.setAttribute(`result`, `outbox_failure`)\n this.offlineExecutor.rejectTransaction(\n transaction.id,\n terminalError,\n )\n throw storageError\n }\n\n span.setAttribute(\n `result`,\n hookFailure ? `retry_decision_failure` : `permanent_failure`,\n )\n this.offlineExecutor.rejectTransaction(transaction.id, terminalError)\n return\n }\n\n const delay = Math.max(\n 0,\n this.retryPolicy.calculateDelay(transaction.retryCount),\n )\n const updatedTransaction: OfflineTransaction = {\n ...transaction,\n retryCount: transaction.retryCount + 1,\n nextAttemptAt: Date.now() + delay,\n lastError: {\n name: error.name,\n message: error.message,\n stack: error.stack,\n },\n }\n\n span.setAttribute(`retryDelay`, delay)\n span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)\n\n this.scheduler.updateTransaction(updatedTransaction)\n\n try {\n await this.outbox.update(transaction.id, updatedTransaction)\n span.setAttribute(`result`, `scheduled_retry`)\n } catch (persistError) {\n // Public removal can finish while the named mutation function runs.\n // The removed row cannot be retried, but its caller still owns the\n // provider failure and later queued work must be able to run.\n if (persistError instanceof OutboxTransactionNotFoundError) {\n this.scheduler.markCompleted(transaction)\n span.setAttribute(`result`, `outbox_removed`)\n this.offlineExecutor.rejectTransaction(transaction.id, error)\n return\n }\n span.recordException(persistError as Error)\n span.setAttribute(`result`, `persist_failed`)\n throw persistError\n } finally {\n this.scheduler.markFailed(transaction)\n }\n },\n )\n }\n\n private async removeSettledTransaction(\n transaction: OfflineTransaction,\n persistMarker: boolean,\n ): Promise<void> {\n try {\n if (persistMarker) {\n try {\n await this.outbox.update(transaction.id, transaction)\n } catch (error) {\n // A public removal can finish while the provider is still running.\n if (!(error instanceof OutboxTransactionNotFoundError)) throw error\n }\n }\n await this.outbox.remove(transaction.id)\n this.scheduler.markCompleted(transaction)\n } catch (error) {\n const storageError = toError(error)\n this.fatalError = storageError\n this.scheduler.markFailed(transaction)\n this.clearRetryTimer()\n throw storageError\n }\n }\n\n assertHealthy(): void {\n if (this.fatalError) throw this.fatalError\n }\n\n private getTerminalError(transaction: OfflineTransaction): Error {\n if (!transaction.lastError)\n throw new Error(`Terminal transaction ${transaction.id} has no error`)\n const { name, message, stack } = transaction.lastError\n const error =\n name === `NonRetriableError`\n ? new NonRetriableError(message)\n : new Error(message)\n error.name = name\n error.stack = stack\n return error\n }\n\n async loadPendingTransactions(): Promise<void> {\n let removedIds: Array<string> = []\n await this.outbox.withAll((transactions) => {\n const { isOfflineEnabled } = this.offlineExecutor\n if (!isOfflineEnabled) return\n const providerPending = transactions.filter(\n (transaction) => !transaction.outboxPhase,\n )\n const selectedProvider = this.config.beforeRetry\n ? this.config.beforeRetry(providerPending)\n : providerPending\n const selectedById = new Map(selectedProvider.map((tx) => [tx.id, tx]))\n const filteredTransactions = transactions.flatMap((transaction) => {\n if (transaction.outboxPhase) return [transaction]\n const selected = selectedById.get(transaction.id)\n return selected ? [selected] : []\n })\n\n // The retry hook is user code and may synchronously revoke replay rights.\n if (!this.offlineExecutor.isOfflineEnabled) return\n\n const newlyLoaded = filteredTransactions.filter((transaction) =>\n this.scheduler.schedule(transaction),\n )\n\n removedIds = providerPending\n .filter(({ id }) => !selectedById.has(id))\n .map(({ id }) => id)\n removedIds = this.scheduler.removePendingTransactions(removedIds)\n\n // Restore optimistic state for loaded transactions\n // This ensures the UI shows the optimistic data while transactions are pending\n this.restoreOptimisticState(\n newlyLoaded.filter(\n (transaction) => transaction.outboxPhase !== `rejection-pending`,\n ),\n )\n\n // Reset retry delays for all loaded transactions so they can run immediately\n this.resetRetryDelays()\n\n // Schedule retry timer for loaded transactions\n this.scheduleNextRetry()\n })\n\n if (removedIds.length > 0) {\n const error = new NonRetriableError(`Transaction excluded by beforeRetry`)\n await Promise.all(\n removedIds.map(async (id) => {\n try {\n await this.outbox.remove(id)\n this.offlineExecutor.rejectTransaction(id, error)\n } catch (cleanupError) {\n console.warn(\n `Failed to remove transaction excluded by beforeRetry:`,\n id,\n cleanupError,\n )\n }\n }),\n )\n }\n }\n\n /**\n * Restore optimistic state from loaded transactions.\n * Creates internal transactions to hold the mutations so the collection's\n * state manager can show optimistic data while waiting for sync.\n */\n private restoreOptimisticState(\n transactions: Array<OfflineTransaction>,\n ): void {\n for (const offlineTx of transactions) {\n if (offlineTx.mutations.length === 0) {\n continue\n }\n\n try {\n // Create a restoration transaction that holds mutations for optimistic state display.\n // It will never commit - the real mutation is handled by the offline executor.\n const restorationTx = createTransaction({\n id: offlineTx.id,\n autoCommit: false,\n mutationFn: async () => {},\n })\n\n // Prevent unhandled promise rejection when cleanup calls rollback()\n // We don't care about this promise - it's just for holding mutations\n restorationTx.isPersisted.promise.catch(() => {\n // Intentionally ignored - restoration transactions are cleaned up\n // via cleanupRestorationTransaction, not through normal commit flow\n })\n\n restorationTx.applyMutations(offlineTx.mutations)\n\n // Register with each affected Collection. If one cannot track it, roll\n // it back, so no Collection keeps a restoration nothing will settle.\n try {\n // The serializer resolves every mutation's Collection or throws.\n for (const mutation of offlineTx.mutations) {\n if (mutation.collection._state.trackTransaction(restorationTx))\n mutation.collection._state.recomputeOptimisticState(true)\n }\n } catch (error) {\n // A secondary rollback settles only this restoration; it must not\n // roll back the user's live transactions on the same keys.\n restorationTx.rollback({ isSecondaryRollback: true })\n throw error\n }\n\n this.offlineExecutor.registerRestorationTransaction(\n offlineTx.id,\n restorationTx,\n )\n } catch (error) {\n // A live transaction with this id already shows these mutations.\n if (error instanceof DuplicateTransactionIdError) continue\n console.warn(\n `Failed to restore optimistic state for transaction ${offlineTx.id}:`,\n error,\n )\n }\n }\n }\n\n clear(): void {\n this.scheduler.clear()\n this.clearRetryTimer()\n }\n\n pause(): void {\n // Retain queued work and let the issued call finish its acknowledgment.\n this.clearRetryTimer()\n }\n\n getPendingCount(): number {\n return this.scheduler.getPendingCount()\n }\n\n private scheduleNextRetry(): void {\n // Clear existing timer\n this.clearRetryTimer()\n\n if (this.fatalError || !this.canExecute()) {\n return\n }\n\n const nextRetryTime = this.getNextRetryTime()\n\n if (nextRetryTime === null) {\n return // No transactions pending retry\n }\n\n const delay = Math.max(0, nextRetryTime - Date.now())\n\n this.retryTimer = setTimeout(() => {\n this.executeAll().catch((error) => {\n console.warn(`Failed to execute retry batch:`, error)\n })\n }, delay)\n }\n\n private getNextRetryTime(): number | null {\n const allTransactions = this.scheduler.getAllPendingTransactions()\n\n if (allTransactions.length === 0) {\n return null\n }\n\n // Later transactions cannot overtake the FIFO head, even if they are ready.\n return allTransactions[0]!.nextAttemptAt\n }\n\n private clearRetryTimer(): void {\n if (this.retryTimer) {\n clearTimeout(this.retryTimer)\n this.retryTimer = null\n }\n }\n\n private canExecute(): boolean {\n return (\n this.offlineExecutor.isOfflineEnabled && this.offlineExecutor.isOnline()\n )\n }\n\n getRunningCount(): number {\n return this.scheduler.getRunningCount()\n }\n\n resetRetryDelays(): void {\n const allTransactions = this.scheduler.getAllPendingTransactions()\n const updatedTransactions = allTransactions.map((transaction) => ({\n ...transaction,\n nextAttemptAt: Date.now(),\n }))\n\n this.scheduler.updateTransactions(updatedTransactions)\n }\n}\n"],"names":[],"mappings":";;;;;AAaA,MAAM,iDAAiC,uBAAuB;AAE9D,SAAS,QAAQ,OAAuB;AACtC,SAAO,iBAAiB,SACtB,OAAO,UAAU,SAAS,KAAK,KAAK,MAAM,mBACvC,QACD,IAAI,MAAM,OAAO,KAAK,CAAC;AAC7B;AAEO,MAAM,oBAAoB;AAAA,EAW/B,YACE,WACA,QACA,QACA,iBACA;AAXF,SAAQ,cAAc;AACtB,SAAQ,mBAAyC;AAEjD,SAAQ,aAAmD;AAC3D,SAAQ,aAA2B;AAQjC,SAAK,YAAY;AACjB,SAAK,SAAS;AACd,SAAK,SAAS;AACd,SAAK,cAAc,IAAI;AAAA,MACrB,OAAO;AAAA,MACP,OAAO,UAAU;AAAA,IAAA;AAEnB,SAAK,kBAAkB;AAAA,EACzB;AAAA,EAEA,MAAM,QAAQ,aAAgD;AAC5D,SAAK,cAAA;AACL,SAAK,UAAU,SAAS,WAAW;AACnC,UAAM,KAAK,WAAA;AAAA,EACb;AAAA,EAEA,MAAM,aAA4B;AAChC,SAAK,cAAA;AACL,QAAI,KAAK,aAAa;AACpB,aAAO,KAAK;AAAA,IACd;AAEA,SAAK,cAAc;AACnB,SAAK,mBAAmB,KAAK,aAAA;AAE7B,QAAI;AACF,YAAM,KAAK;AAAA,IACb,UAAA;AACE,WAAK,cAAc;AACnB,WAAK,mBAAmB;AACxB,WAAK,kBAAA;AAAA,IACP;AAAA,EACF;AAAA,EAEA,MAAc,eAA8B;AAC1C,WAAO,KAAK,UAAU,gBAAA,IAAoB,GAAG;AAC3C,UAAI,CAAC,KAAK,cAAc;AACtB;AAAA,MACF;AAEA,YAAM,cAAc,KAAK,UAAU,QAAA;AAEnC,UAAI,CAAC,aAAa;AAChB;AAAA,MACF;AAEA,YAAM,KAAK,mBAAmB,WAAW;AAAA,IAC3C;AAAA,EACF;AAAA,EAEA,MAAc,mBACZ,aACe;AACf,QAAI;AACF,YAAM;AAAA,QACJ;AAAA,QACA;AAAA,UACE,kBAAkB,YAAY;AAAA,UAC9B,8BAA8B,YAAY;AAAA,UAC1C,0BAA0B,YAAY;AAAA,UACtC,wBAAwB,YAAY,KAAK;AAAA,QAAA;AAAA,QAE3C,OAAO,SAAS;AACd,gBAAM,gBACJ,YAAY,gBAAgB,sBACxB,KAAK,iBAAiB,WAAW,IACjC;AACN,eAAK,UAAU,YAAY,WAAW;AAEtC,cAAI,YAAY,aAAa,GAAG;AAC9B,iBAAK,aAAa,iBAAiB,YAAY,UAAU;AAAA,UAC3D;AAEA,cAAI,CAAC,YAAY,aAAa;AAC5B,gBAAI;AACF,oBAAM,KAAK,cAAc,WAAW;AAAA,YACtC,SAAS,OAAO;AACd,oBAAM,MAAM,QAAQ,KAAK;AAEzB,mBAAK,aAAa,UAAU,OAAO;AAEnC,oBAAM,KAAK,YAAY,aAAa,GAAG;AACrC,kBAAY,uBAAuB,IAAI;AACzC,oBAAM;AAAA,YACR;AAAA,UACF;AAIA,gBAAM,kBAAsC,YAAY,cACpD,cACA,EAAE,GAAG,aAAa,aAAa,mBAAA;AACnC,cAAI;AACF,kBAAM,KAAK;AAAA,cACT;AAAA,cACA,CAAC,YAAY;AAAA,YAAA;AAAA,UAEjB,SAAS,OAAO;AACd,kBAAM,eAAe;AACrB,iBAAK,gBAAgB,YAAY;AACjC,iBAAK,aAAa,UAAU,gBAAgB;AAC5C,iBAAK,gBAAgB;AAAA,cACnB,YAAY;AAAA,cACZ,iBAAiB;AAAA,YAAA;AAEnB,kBAAM;AAAA,UACR;AAEA,cAAI,eAAe;AACjB,iBAAK,aAAa,UAAU,mBAAmB;AAC/C,iBAAK,gBAAgB;AAAA,cACnB,YAAY;AAAA,cACZ;AAAA,YAAA;AAAA,UAEJ,OAAO;AACL,iBAAK,aAAa,UAAU,SAAS;AACrC,iBAAK,gBAAgB,mBAAmB,YAAY,IAAI,MAAS;AAAA,UACnE;AAAA,QACF;AAAA,MAAA;AAAA,IAEJ,SAAS,OAAO;AACd,UACE,UAAU,QACV,OAAO,UAAU,YAChB,MAAkC,uBAAuB,MAAM,MAChE;AACA;AAAA,MACF;AAEA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,MAAc,cAAc,aAAgD;AAC1E,UAAM,aAAa,KAAK,OAAO,YAAY,YAAY,cAAc;AAErE,QAAI,CAAC,YAAY;AACf,YAAM,eAAe,8BAA8B,YAAY,cAAc;AAE7E,UAAI,KAAK,OAAO,qBAAqB;AACnC,aAAK,OAAO,oBAAoB,YAAY,gBAAgB,WAAW;AAAA,MACzE;AAEA,YAAM,IAAI,kBAAkB,YAAY;AAAA,IAC1C;AAIA,UAAM,2BAA2B;AAAA,MAC/B,IAAI,YAAY;AAAA,MAChB,WAAW,YAAY;AAAA,MACvB,UAAU,YAAY,YAAY,CAAA;AAAA,IAAC;AAGrC,UAAM,WAAW;AAAA,MACf,aAAa;AAAA,MACb,gBAAgB,YAAY;AAAA,IAAA,CAC7B;AAAA,EACH;AAAA,EAEA,MAAc,YACZ,aACA,OACe;AACf,WAAO;AAAA,MACL;AAAA,MACA;AAAA,QACE,kBAAkB,YAAY;AAAA,QAC9B,cAAc,MAAM;AAAA,QACpB,iBAAiB,MAAM;AAAA,MAAA;AAAA,MAEzB,OAAO,SAAS;AACd,YAAI;AACJ,YAAI;AACJ,YAAI,iBAAiB,mBAAmB;AACtC,wBAAc;AAAA,QAChB,OAAO;AACL,cAAI;AACJ,cAAI;AACF,uBAAW,KAAK,OAAO,cAAc,OAAO,YAAY,UAAU;AAClE,gBAAI,aAAa,UAAa,OAAO,aAAa,WAAW;AAE3D,mBAAK,QAAQ,QAAQ,QAAQ,EAAE,MAAM,MAAM;AAAA,cAAC,CAAC;AAC7C,oBAAM,IAAI;AAAA,gBACR;AAAA,cAAA;AAAA,YAEJ;AAAA,UACF,SAAS,WAAW;AAClB,0BAAc,QAAQ,SAAS;AAAA,UACjC;AACA,cAAI,aAAa;AACf,0BAAc;AAAA,UAChB,OAAO;AACL,0BACE,YACA,KAAK,YAAY,YAAY,OAAO,YAAY,UAAU;AAAA,UAC9D;AAAA,QACF;AAEA,aAAK,aAAa,eAAe,WAAW;AAE5C,YAAI,CAAC,aAAa;AAChB,gBAAM,gBAAgB,eAAe;AACrC,gBAAM,mBAAuC;AAAA,YAC3C,GAAG;AAAA,YACH,aAAa;AAAA,YACb,WAAW;AAAA,cACT,MAAM,cAAc;AAAA,cACpB,SAAS,cAAc;AAAA,cACvB,OAAO,cAAc;AAAA,YAAA;AAAA,UACvB;AAEF,cAAI,aAAa;AACf,iBAAK,gBAAgB,WAAW;AAChC,oBAAQ;AAAA,cACN,yCAAyC,YAAY,EAAE;AAAA,cACvD;AAAA,cACA;AAAA,cACA;AAAA,YAAA;AAAA,UAEJ,OAAO;AACL,oBAAQ;AAAA,cACN,eAAe,YAAY,EAAE;AAAA,cAC7B;AAAA,YAAA;AAAA,UAEJ;AACA,cAAI;AACF,kBAAM,KAAK,yBAAyB,kBAAkB,IAAI;AAAA,UAC5D,SAAS,cAAc;AACrB,iBAAK,gBAAgB,YAAqB;AAC1C,iBAAK,aAAa,UAAU,gBAAgB;AAC5C,iBAAK,gBAAgB;AAAA,cACnB,YAAY;AAAA,cACZ;AAAA,YAAA;AAEF,kBAAM;AAAA,UACR;AAEA,eAAK;AAAA,YACH;AAAA,YACA,cAAc,2BAA2B;AAAA,UAAA;AAE3C,eAAK,gBAAgB,kBAAkB,YAAY,IAAI,aAAa;AACpE;AAAA,QACF;AAEA,cAAM,QAAQ,KAAK;AAAA,UACjB;AAAA,UACA,KAAK,YAAY,eAAe,YAAY,UAAU;AAAA,QAAA;AAExD,cAAM,qBAAyC;AAAA,UAC7C,GAAG;AAAA,UACH,YAAY,YAAY,aAAa;AAAA,UACrC,eAAe,KAAK,IAAA,IAAQ;AAAA,UAC5B,WAAW;AAAA,YACT,MAAM,MAAM;AAAA,YACZ,SAAS,MAAM;AAAA,YACf,OAAO,MAAM;AAAA,UAAA;AAAA,QACf;AAGF,aAAK,aAAa,cAAc,KAAK;AACrC,aAAK,aAAa,kBAAkB,mBAAmB,UAAU;AAEjE,aAAK,UAAU,kBAAkB,kBAAkB;AAEnD,YAAI;AACF,gBAAM,KAAK,OAAO,OAAO,YAAY,IAAI,kBAAkB;AAC3D,eAAK,aAAa,UAAU,iBAAiB;AAAA,QAC/C,SAAS,cAAc;AAIrB,cAAI,wBAAwB,gCAAgC;AAC1D,iBAAK,UAAU,cAAc,WAAW;AACxC,iBAAK,aAAa,UAAU,gBAAgB;AAC5C,iBAAK,gBAAgB,kBAAkB,YAAY,IAAI,KAAK;AAC5D;AAAA,UACF;AACA,eAAK,gBAAgB,YAAqB;AAC1C,eAAK,aAAa,UAAU,gBAAgB;AAC5C,gBAAM;AAAA,QACR,UAAA;AACE,eAAK,UAAU,WAAW,WAAW;AAAA,QACvC;AAAA,MACF;AAAA,IAAA;AAAA,EAEJ;AAAA,EAEA,MAAc,yBACZ,aACA,eACe;AACf,QAAI;AACF,UAAI,eAAe;AACjB,YAAI;AACF,gBAAM,KAAK,OAAO,OAAO,YAAY,IAAI,WAAW;AAAA,QACtD,SAAS,OAAO;AAEd,cAAI,EAAE,iBAAiB,gCAAiC,OAAM;AAAA,QAChE;AAAA,MACF;AACA,YAAM,KAAK,OAAO,OAAO,YAAY,EAAE;AACvC,WAAK,UAAU,cAAc,WAAW;AAAA,IAC1C,SAAS,OAAO;AACd,YAAM,eAAe,QAAQ,KAAK;AAClC,WAAK,aAAa;AAClB,WAAK,UAAU,WAAW,WAAW;AACrC,WAAK,gBAAA;AACL,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,gBAAsB;AACpB,QAAI,KAAK,WAAY,OAAM,KAAK;AAAA,EAClC;AAAA,EAEQ,iBAAiB,aAAwC;AAC/D,QAAI,CAAC,YAAY;AACf,YAAM,IAAI,MAAM,wBAAwB,YAAY,EAAE,eAAe;AACvE,UAAM,EAAE,MAAM,SAAS,MAAA,IAAU,YAAY;AAC7C,UAAM,QACJ,SAAS,sBACL,IAAI,kBAAkB,OAAO,IAC7B,IAAI,MAAM,OAAO;AACvB,UAAM,OAAO;AACb,UAAM,QAAQ;AACd,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,0BAAyC;AAC7C,QAAI,aAA4B,CAAA;AAChC,UAAM,KAAK,OAAO,QAAQ,CAAC,iBAAiB;AAC1C,YAAM,EAAE,qBAAqB,KAAK;AAClC,UAAI,CAAC,iBAAkB;AACvB,YAAM,kBAAkB,aAAa;AAAA,QACnC,CAAC,gBAAgB,CAAC,YAAY;AAAA,MAAA;AAEhC,YAAM,mBAAmB,KAAK,OAAO,cACjC,KAAK,OAAO,YAAY,eAAe,IACvC;AACJ,YAAM,eAAe,IAAI,IAAI,iBAAiB,IAAI,CAAC,OAAO,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC;AACtE,YAAM,uBAAuB,aAAa,QAAQ,CAAC,gBAAgB;AACjE,YAAI,YAAY,YAAa,QAAO,CAAC,WAAW;AAChD,cAAM,WAAW,aAAa,IAAI,YAAY,EAAE;AAChD,eAAO,WAAW,CAAC,QAAQ,IAAI,CAAA;AAAA,MACjC,CAAC;AAGD,UAAI,CAAC,KAAK,gBAAgB,iBAAkB;AAE5C,YAAM,cAAc,qBAAqB;AAAA,QAAO,CAAC,gBAC/C,KAAK,UAAU,SAAS,WAAW;AAAA,MAAA;AAGrC,mBAAa,gBACV,OAAO,CAAC,EAAE,GAAA,MAAS,CAAC,aAAa,IAAI,EAAE,CAAC,EACxC,IAAI,CAAC,EAAE,GAAA,MAAS,EAAE;AACrB,mBAAa,KAAK,UAAU,0BAA0B,UAAU;AAIhE,WAAK;AAAA,QACH,YAAY;AAAA,UACV,CAAC,gBAAgB,YAAY,gBAAgB;AAAA,QAAA;AAAA,MAC/C;AAIF,WAAK,iBAAA;AAGL,WAAK,kBAAA;AAAA,IACP,CAAC;AAED,QAAI,WAAW,SAAS,GAAG;AACzB,YAAM,QAAQ,IAAI,kBAAkB,qCAAqC;AACzE,YAAM,QAAQ;AAAA,QACZ,WAAW,IAAI,OAAO,OAAO;AAC3B,cAAI;AACF,kBAAM,KAAK,OAAO,OAAO,EAAE;AAC3B,iBAAK,gBAAgB,kBAAkB,IAAI,KAAK;AAAA,UAClD,SAAS,cAAc;AACrB,oBAAQ;AAAA,cACN;AAAA,cACA;AAAA,cACA;AAAA,YAAA;AAAA,UAEJ;AAAA,QACF,CAAC;AAAA,MAAA;AAAA,IAEL;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOQ,uBACN,cACM;AACN,eAAW,aAAa,cAAc;AACpC,UAAI,UAAU,UAAU,WAAW,GAAG;AACpC;AAAA,MACF;AAEA,UAAI;AAGF,cAAM,gBAAgB,kBAAkB;AAAA,UACtC,IAAI,UAAU;AAAA,UACd,YAAY;AAAA,UACZ,YAAY,YAAY;AAAA,UAAC;AAAA,QAAA,CAC1B;AAID,sBAAc,YAAY,QAAQ,MAAM,MAAM;AAAA,QAG9C,CAAC;AAED,sBAAc,eAAe,UAAU,SAAS;AAIhD,YAAI;AAEF,qBAAW,YAAY,UAAU,WAAW;AAC1C,gBAAI,SAAS,WAAW,OAAO,iBAAiB,aAAa;AAC3D,uBAAS,WAAW,OAAO,yBAAyB,IAAI;AAAA,UAC5D;AAAA,QACF,SAAS,OAAO;AAGd,wBAAc,SAAS,EAAE,qBAAqB,KAAA,CAAM;AACpD,gBAAM;AAAA,QACR;AAEA,aAAK,gBAAgB;AAAA,UACnB,UAAU;AAAA,UACV;AAAA,QAAA;AAAA,MAEJ,SAAS,OAAO;AAEd,YAAI,iBAAiB,4BAA6B;AAClD,gBAAQ;AAAA,UACN,sDAAsD,UAAU,EAAE;AAAA,UAClE;AAAA,QAAA;AAAA,MAEJ;AAAA,IACF;AAAA,EACF;AAAA,EAEA,QAAc;AACZ,SAAK,UAAU,MAAA;AACf,SAAK,gBAAA;AAAA,EACP;AAAA,EAEA,QAAc;AAEZ,SAAK,gBAAA;AAAA,EACP;AAAA,EAEA,kBAA0B;AACxB,WAAO,KAAK,UAAU,gBAAA;AAAA,EACxB;AAAA,EAEQ,oBAA0B;AAEhC,SAAK,gBAAA;AAEL,QAAI,KAAK,cAAc,CAAC,KAAK,cAAc;AACzC;AAAA,IACF;AAEA,UAAM,gBAAgB,KAAK,iBAAA;AAE3B,QAAI,kBAAkB,MAAM;AAC1B;AAAA,IACF;AAEA,UAAM,QAAQ,KAAK,IAAI,GAAG,gBAAgB,KAAK,KAAK;AAEpD,SAAK,aAAa,WAAW,MAAM;AACjC,WAAK,WAAA,EAAa,MAAM,CAAC,UAAU;AACjC,gBAAQ,KAAK,kCAAkC,KAAK;AAAA,MACtD,CAAC;AAAA,IACH,GAAG,KAAK;AAAA,EACV;AAAA,EAEQ,mBAAkC;AACxC,UAAM,kBAAkB,KAAK,UAAU,0BAAA;AAEvC,QAAI,gBAAgB,WAAW,GAAG;AAChC,aAAO;AAAA,IACT;AAGA,WAAO,gBAAgB,CAAC,EAAG;AAAA,EAC7B;AAAA,EAEQ,kBAAwB;AAC9B,QAAI,KAAK,YAAY;AACnB,mBAAa,KAAK,UAAU;AAC5B,WAAK,aAAa;AAAA,IACpB;AAAA,EACF;AAAA,EAEQ,aAAsB;AAC5B,WACE,KAAK,gBAAgB,oBAAoB,KAAK,gBAAgB,SAAA;AAAA,EAElE;AAAA,EAEA,kBAA0B;AACxB,WAAO,KAAK,UAAU,gBAAA;AAAA,EACxB;AAAA,EAEA,mBAAyB;AACvB,UAAM,kBAAkB,KAAK,UAAU,0BAAA;AACvC,UAAM,sBAAsB,gBAAgB,IAAI,CAAC,iBAAiB;AAAA,MAChE,GAAG;AAAA,MACH,eAAe,KAAK,IAAA;AAAA,IAAI,EACxB;AAEF,SAAK,UAAU,mBAAmB,mBAAmB;AAAA,EACvD;AACF;"}
1
+ {"version":3,"file":"TransactionExecutor.js","sources":["../../../src/executor/TransactionExecutor.ts"],"sourcesContent":["import { DuplicateTransactionIdError, createTransaction } from '@tanstack/db'\nimport { OutboxTransactionNotFoundError } from '../outbox/OutboxManager'\nimport { DefaultRetryPolicy } from '../retry/RetryPolicy'\nimport { NonRetriableError } from '../types'\nimport type { KeyScheduler } from './KeyScheduler'\nimport type { OutboxManager } from '../outbox/OutboxManager'\nimport type {\n OfflineConfig,\n OfflineTransaction,\n TransactionSignaler,\n} from '../types'\n\nconst HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)\n\nfunction toError(value: unknown): Error {\n return value instanceof Error ||\n Object.prototype.toString.call(value) === `[object Error]`\n ? (value as Error)\n : new Error(String(value))\n}\n\nexport class TransactionExecutor {\n private scheduler: KeyScheduler\n private outbox: OutboxManager\n private config: OfflineConfig\n private retryPolicy: DefaultRetryPolicy\n private isExecuting = false\n private executionPromise: Promise<void> | null = null\n private offlineExecutor: TransactionSignaler\n private retryTimer: ReturnType<typeof setTimeout> | null = null\n private fatalError: Error | null = null\n\n constructor(\n scheduler: KeyScheduler,\n outbox: OutboxManager,\n config: OfflineConfig,\n offlineExecutor: TransactionSignaler,\n ) {\n this.scheduler = scheduler\n this.outbox = outbox\n this.config = config\n this.retryPolicy = new DefaultRetryPolicy(\n Number.POSITIVE_INFINITY,\n config.jitter ?? true,\n )\n this.offlineExecutor = offlineExecutor\n }\n\n async execute(transaction: OfflineTransaction): Promise<void> {\n this.assertHealthy()\n this.scheduler.schedule(transaction)\n await this.executeAll()\n }\n\n async executeAll(): Promise<void> {\n this.assertHealthy()\n if (this.isExecuting) {\n return this.executionPromise!\n }\n\n this.isExecuting = true\n this.executionPromise = this.runExecution()\n\n try {\n await this.executionPromise\n } finally {\n this.isExecuting = false\n this.executionPromise = null\n this.scheduleNextRetry()\n }\n }\n\n private async runExecution(): Promise<void> {\n while (this.scheduler.getPendingCount() > 0) {\n if (!this.canExecute()) {\n break\n }\n\n const transaction = this.scheduler.getNext()\n\n if (!transaction) {\n break\n }\n\n await this.executeTransaction(transaction)\n }\n }\n\n private async executeTransaction(\n transaction: OfflineTransaction,\n ): Promise<void> {\n try {\n const terminalError =\n transaction.outboxPhase === `rejection-pending`\n ? this.getTerminalError(transaction)\n : undefined\n this.scheduler.markStarted(transaction)\n\n if (!transaction.outboxPhase) {\n try {\n await this.runMutationFn(transaction)\n } catch (error) {\n const err = toError(error)\n\n await this.handleError(transaction, err)\n ;(err as any)[HANDLED_EXECUTION_ERROR] = true\n throw err\n }\n }\n\n // A successful provider call can outlive this executor. Persist that\n // fact before removal so a restarted executor only retries deletion.\n const deletionPending: OfflineTransaction = transaction.outboxPhase\n ? transaction\n : { ...transaction, outboxPhase: `deletion-pending` }\n try {\n await this.removeSettledTransaction(\n deletionPending,\n !transaction.outboxPhase,\n )\n } catch (error) {\n const storageError = error as Error\n this.offlineExecutor.rejectTransaction(\n transaction.id,\n terminalError ?? storageError,\n )\n throw storageError\n }\n\n if (terminalError) {\n this.offlineExecutor.rejectTransaction(transaction.id, terminalError)\n } else {\n this.offlineExecutor.resolveTransaction(transaction.id, undefined)\n }\n } catch (error) {\n if (\n error !== null &&\n typeof error === `object` &&\n (error as Record<symbol, unknown>)[HANDLED_EXECUTION_ERROR] === true\n ) {\n return\n }\n\n throw error\n }\n }\n\n private async runMutationFn(transaction: OfflineTransaction): Promise<void> {\n const mutationFn = this.config.mutationFns[transaction.mutationFnName]\n\n if (!mutationFn) {\n const errorMessage = `Unknown mutation function: ${transaction.mutationFnName}`\n\n if (this.config.onUnknownMutationFn) {\n this.config.onUnknownMutationFn(transaction.mutationFnName, transaction)\n }\n\n throw new NonRetriableError(errorMessage)\n }\n\n // Mutations are already PendingMutation objects with collections attached\n // from the deserializer, so we can use them directly\n const transactionWithMutations = {\n id: transaction.id,\n mutations: transaction.mutations,\n metadata: transaction.metadata ?? {},\n }\n\n await mutationFn({\n transaction: transactionWithMutations as any,\n idempotencyKey: transaction.idempotencyKey,\n })\n }\n\n private async handleError(\n transaction: OfflineTransaction,\n error: Error,\n ): Promise<void> {\n let shouldRetry: boolean\n let hookFailure: Error | undefined\n if (error instanceof NonRetriableError) {\n shouldRetry = false\n } else {\n let decision: boolean | undefined\n try {\n decision = this.config.shouldRetry?.(error, transaction.retryCount)\n if (decision !== undefined && typeof decision !== `boolean`) {\n // An async hook is invalid, but its rejection must stay row-local.\n void Promise.resolve(decision).catch(() => {})\n throw new TypeError(\n `OfflineConfig.shouldRetry must return true, false, or undefined`,\n )\n }\n } catch (hookError) {\n hookFailure = toError(hookError)\n }\n if (hookFailure) {\n shouldRetry = false\n } else {\n shouldRetry =\n decision ??\n this.retryPolicy.shouldRetry(error, transaction.retryCount)\n }\n }\n\n if (!shouldRetry) {\n const terminalError = hookFailure ?? error\n const rejectionPending: OfflineTransaction = {\n ...transaction,\n outboxPhase: `rejection-pending`,\n lastError: {\n name: terminalError.name,\n message: terminalError.message,\n stack: terminalError.stack,\n },\n }\n if (hookFailure) {\n console.warn(\n `Retry decision failed for transaction ${transaction.id}:`,\n hookFailure,\n `Named mutation function error:`,\n error,\n )\n } else {\n console.warn(`Transaction ${transaction.id} failed permanently:`, error)\n }\n try {\n await this.removeSettledTransaction(rejectionPending, true)\n } catch (storageError) {\n this.offlineExecutor.rejectTransaction(transaction.id, terminalError)\n throw storageError\n }\n\n this.offlineExecutor.rejectTransaction(transaction.id, terminalError)\n return\n }\n\n const delay = Math.max(\n 0,\n this.retryPolicy.calculateDelay(transaction.retryCount),\n )\n const updatedTransaction: OfflineTransaction = {\n ...transaction,\n retryCount: transaction.retryCount + 1,\n nextAttemptAt: Date.now() + delay,\n lastError: {\n name: error.name,\n message: error.message,\n stack: error.stack,\n },\n }\n\n this.scheduler.updateTransaction(updatedTransaction)\n\n try {\n await this.outbox.update(transaction.id, updatedTransaction)\n } catch (persistError) {\n // Public removal can finish while the named mutation function runs.\n // The removed row cannot be retried, but its caller still owns the\n // provider failure and later queued work must be able to run.\n if (persistError instanceof OutboxTransactionNotFoundError) {\n this.scheduler.markCompleted(transaction)\n this.offlineExecutor.rejectTransaction(transaction.id, error)\n return\n }\n throw persistError\n } finally {\n this.scheduler.markFailed(transaction)\n }\n }\n\n private async removeSettledTransaction(\n transaction: OfflineTransaction,\n persistMarker: boolean,\n ): Promise<void> {\n try {\n if (persistMarker) {\n try {\n await this.outbox.update(transaction.id, transaction)\n } catch (error) {\n // A public removal can finish while the provider is still running.\n if (!(error instanceof OutboxTransactionNotFoundError)) throw error\n }\n }\n await this.outbox.remove(transaction.id)\n this.scheduler.markCompleted(transaction)\n } catch (error) {\n const storageError = toError(error)\n this.fatalError = storageError\n this.scheduler.markFailed(transaction)\n this.clearRetryTimer()\n throw storageError\n }\n }\n\n assertHealthy(): void {\n if (this.fatalError) throw this.fatalError\n }\n\n private getTerminalError(transaction: OfflineTransaction): Error {\n if (!transaction.lastError)\n throw new Error(`Terminal transaction ${transaction.id} has no error`)\n const { name, message, stack } = transaction.lastError\n const error =\n name === `NonRetriableError`\n ? new NonRetriableError(message)\n : new Error(message)\n error.name = name\n error.stack = stack\n return error\n }\n\n async loadPendingTransactions(): Promise<void> {\n let removedIds: Array<string> = []\n await this.outbox.withAll((transactions) => {\n const { isOfflineEnabled } = this.offlineExecutor\n if (!isOfflineEnabled) return\n const providerPending = transactions.filter(\n (transaction) => !transaction.outboxPhase,\n )\n const selectedProvider = this.config.beforeRetry\n ? this.config.beforeRetry(providerPending)\n : providerPending\n const selectedById = new Map(selectedProvider.map((tx) => [tx.id, tx]))\n const filteredTransactions = transactions.flatMap((transaction) => {\n if (transaction.outboxPhase) return [transaction]\n const selected = selectedById.get(transaction.id)\n return selected ? [selected] : []\n })\n\n // The retry hook is user code and may synchronously revoke replay rights.\n if (!this.offlineExecutor.isOfflineEnabled) return\n\n const newlyLoaded = filteredTransactions.filter((transaction) =>\n this.scheduler.schedule(transaction),\n )\n\n removedIds = providerPending\n .filter(({ id }) => !selectedById.has(id))\n .map(({ id }) => id)\n removedIds = this.scheduler.removePendingTransactions(removedIds)\n\n // Restore optimistic state for loaded transactions\n // This ensures the UI shows the optimistic data while transactions are pending\n this.restoreOptimisticState(\n newlyLoaded.filter(\n (transaction) => transaction.outboxPhase !== `rejection-pending`,\n ),\n )\n\n // Reset retry delays for all loaded transactions so they can run immediately\n this.resetRetryDelays()\n\n // Schedule retry timer for loaded transactions\n this.scheduleNextRetry()\n })\n\n if (removedIds.length > 0) {\n const error = new NonRetriableError(`Transaction excluded by beforeRetry`)\n await Promise.all(\n removedIds.map(async (id) => {\n try {\n await this.outbox.remove(id)\n this.offlineExecutor.rejectTransaction(id, error)\n } catch (cleanupError) {\n console.warn(\n `Failed to remove transaction excluded by beforeRetry:`,\n id,\n cleanupError,\n )\n }\n }),\n )\n }\n }\n\n /**\n * Restore optimistic state from loaded transactions.\n * Creates internal transactions to hold the mutations so the collection's\n * state manager can show optimistic data while waiting for sync.\n */\n private restoreOptimisticState(\n transactions: Array<OfflineTransaction>,\n ): void {\n for (const offlineTx of transactions) {\n if (offlineTx.mutations.length === 0) {\n continue\n }\n\n try {\n // Create a restoration transaction that holds mutations for optimistic state display.\n // It will never commit - the real mutation is handled by the offline executor.\n const restorationTx = createTransaction({\n id: offlineTx.id,\n autoCommit: false,\n mutationFn: async () => {},\n })\n\n restorationTx.applyMutations(offlineTx.mutations)\n\n // Register with each affected Collection. If one cannot track it, roll\n // it back, so no Collection keeps a restoration nothing will settle.\n try {\n // The serializer resolves every mutation's Collection or throws.\n for (const mutation of offlineTx.mutations) {\n if (mutation.collection._state.trackTransaction(restorationTx))\n mutation.collection._state.recomputeOptimisticState(true)\n }\n } catch (error) {\n // A secondary rollback settles only this restoration; it must not\n // roll back the user's live transactions on the same keys.\n restorationTx.rollback({ isSecondaryRollback: true })\n throw error\n }\n\n this.offlineExecutor.registerRestorationTransaction(\n offlineTx.id,\n restorationTx,\n )\n } catch (error) {\n // A live transaction with this id already shows these mutations.\n if (error instanceof DuplicateTransactionIdError) continue\n console.warn(\n `Failed to restore optimistic state for transaction ${offlineTx.id}:`,\n error,\n )\n }\n }\n }\n\n clear(): void {\n this.scheduler.clear()\n this.clearRetryTimer()\n }\n\n pause(): void {\n // Retain queued work and let the issued call finish its acknowledgment.\n this.clearRetryTimer()\n }\n\n getPendingCount(): number {\n return this.scheduler.getPendingCount()\n }\n\n private scheduleNextRetry(): void {\n // Clear existing timer\n this.clearRetryTimer()\n\n if (this.fatalError || !this.canExecute()) {\n return\n }\n\n const nextRetryTime = this.getNextRetryTime()\n\n if (nextRetryTime === null) {\n return // No transactions pending retry\n }\n\n const delay = Math.max(0, nextRetryTime - Date.now())\n\n this.retryTimer = setTimeout(() => {\n this.executeAll().catch((error) => {\n console.warn(`Failed to execute retry batch:`, error)\n })\n }, delay)\n }\n\n private getNextRetryTime(): number | null {\n const allTransactions = this.scheduler.getAllPendingTransactions()\n\n if (allTransactions.length === 0) {\n return null\n }\n\n // Later transactions cannot overtake the FIFO head, even if they are ready.\n return allTransactions[0]!.nextAttemptAt\n }\n\n private clearRetryTimer(): void {\n if (this.retryTimer) {\n clearTimeout(this.retryTimer)\n this.retryTimer = null\n }\n }\n\n private canExecute(): boolean {\n return (\n this.offlineExecutor.isOfflineEnabled && this.offlineExecutor.isOnline()\n )\n }\n\n getRunningCount(): number {\n return this.scheduler.getRunningCount()\n }\n\n resetRetryDelays(): void {\n const allTransactions = this.scheduler.getAllPendingTransactions()\n const updatedTransactions = allTransactions.map((transaction) => ({\n ...transaction,\n nextAttemptAt: Date.now(),\n }))\n\n this.scheduler.updateTransactions(updatedTransactions)\n }\n}\n"],"names":[],"mappings":"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"}
@@ -1,4 +1,3 @@
1
- import { withSpan } from "../telemetry/tracer.js";
2
1
  import { TransactionSerializer, MissingTemporalConstructorError } from "./TransactionSerializer.js";
3
2
  class OutboxTransactionNotFoundError extends Error {
4
3
  }
@@ -32,42 +31,24 @@ class OutboxManager {
32
31
  if (revision <= oldestRead) this.removedAtRevision.delete(id);
33
32
  }
34
33
  async add(transaction) {
35
- return withSpan(
36
- `outbox.add`,
37
- {
38
- "transaction.id": transaction.id,
39
- "transaction.mutationFnName": transaction.mutationFnName,
40
- "transaction.keyCount": transaction.keys.length
41
- },
42
- async () => {
43
- const key = this.getStorageKey(transaction.id);
44
- const serialized = this.serializer.serialize(transaction);
45
- await this.storage.set(key, serialized);
46
- }
47
- );
34
+ const key = this.getStorageKey(transaction.id);
35
+ const serialized = this.serializer.serialize(transaction);
36
+ await this.storage.set(key, serialized);
48
37
  }
49
38
  async get(id) {
50
- return withSpan(`outbox.get`, {}, async (span) => {
51
- const key = this.getStorageKey(id);
52
- const data = await this.storage.get(key);
53
- if (!data) {
54
- span.setAttribute(`result`, `not_found`);
55
- return null;
56
- }
57
- try {
58
- const transaction = this.serializer.deserialize(data);
59
- span.setAttribute(`result`, `found`);
60
- return transaction;
61
- } catch (error) {
62
- if (error instanceof MissingTemporalConstructorError) {
63
- error.message = `transaction ${id}: ${error.message}`;
64
- throw error;
65
- }
66
- console.warn(`Failed to deserialize transaction ${id}:`, error);
67
- span.setAttribute(`result`, `deserialize_error`);
68
- return null;
39
+ const key = this.getStorageKey(id);
40
+ const data = await this.storage.get(key);
41
+ if (!data) return null;
42
+ try {
43
+ return this.serializer.deserialize(data);
44
+ } catch (error) {
45
+ if (error instanceof MissingTemporalConstructorError) {
46
+ error.message = `transaction ${id}: ${error.message}`;
47
+ throw error;
69
48
  }
70
- });
49
+ console.warn(`Failed to deserialize transaction ${id}:`, error);
50
+ return null;
51
+ }
71
52
  }
72
53
  async getAll() {
73
54
  return this.withAll((transactions) => transactions);
@@ -76,36 +57,33 @@ class OutboxManager {
76
57
  const read = { revision: this.removalRevision };
77
58
  this.activeReads.add(read);
78
59
  try {
79
- return await withSpan(`outbox.getAll`, {}, async (span) => {
80
- const keys = await this.storage.keys();
81
- const transactionKeys = keys.filter(
82
- (key) => key.startsWith(this.keyPrefix)
83
- );
84
- span.setAttribute(`transactionCount`, transactionKeys.length);
85
- const transactions = [];
86
- for (const key of transactionKeys) {
87
- const data = await this.storage.get(key);
88
- if (data) {
89
- try {
90
- const transaction = this.serializer.deserialize(data);
91
- transactions.push(transaction);
92
- } catch (error) {
93
- if (error instanceof MissingTemporalConstructorError) {
94
- error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`;
95
- throw error;
96
- }
97
- console.warn(
98
- `Failed to deserialize transaction from key ${key}:`,
99
- error
100
- );
60
+ const keys = await this.storage.keys();
61
+ const transactionKeys = keys.filter(
62
+ (key) => key.startsWith(this.keyPrefix)
63
+ );
64
+ const transactions = [];
65
+ for (const key of transactionKeys) {
66
+ const data = await this.storage.get(key);
67
+ if (data) {
68
+ try {
69
+ const transaction = this.serializer.deserialize(data);
70
+ transactions.push(transaction);
71
+ } catch (error) {
72
+ if (error instanceof MissingTemporalConstructorError) {
73
+ error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`;
74
+ throw error;
101
75
  }
76
+ console.warn(
77
+ `Failed to deserialize transaction from key ${key}:`,
78
+ error
79
+ );
102
80
  }
103
81
  }
104
- const currentTransactions = transactions.filter(
105
- ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision
106
- ).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
107
- return consume(currentTransactions);
108
- });
82
+ }
83
+ const currentTransactions = transactions.filter(
84
+ ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision
85
+ ).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
86
+ return await consume(currentTransactions);
109
87
  } finally {
110
88
  this.activeReads.delete(read);
111
89
  this.pruneRemovalHistory();
@@ -119,26 +97,19 @@ class OutboxManager {
119
97
  );
120
98
  }
121
99
  async update(id, updates) {
122
- return withSpan(`outbox.update`, {}, async () => {
123
- const existing = await this.get(id);
124
- if (!existing) {
125
- throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`);
126
- }
127
- const updated = { ...existing, ...updates };
128
- await this.add(updated);
129
- });
100
+ const existing = await this.get(id);
101
+ if (!existing) {
102
+ throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`);
103
+ }
104
+ await this.add({ ...existing, ...updates });
130
105
  }
131
106
  async remove(id) {
132
- return withSpan(`outbox.remove`, {}, async () => {
133
- const key = this.getStorageKey(id);
134
- await this.storage.delete(key);
135
- this.recordRemovals([id]);
136
- });
107
+ const key = this.getStorageKey(id);
108
+ await this.storage.delete(key);
109
+ this.recordRemovals([id]);
137
110
  }
138
111
  async removeMany(ids) {
139
- return withSpan(`outbox.removeMany`, { count: ids.length }, async () => {
140
- await Promise.all(ids.map((id) => this.remove(id)));
141
- });
112
+ await Promise.all(ids.map((id) => this.remove(id)));
142
113
  }
143
114
  async clear() {
144
115
  const keys = await this.storage.keys();
@@ -1 +1 @@
1
- {"version":3,"file":"OutboxManager.js","sources":["../../../src/outbox/OutboxManager.ts"],"sourcesContent":["import { withSpan } from '../telemetry/tracer'\nimport {\n MissingTemporalConstructorError,\n TransactionSerializer,\n} from './TransactionSerializer'\nimport type { OfflineTransaction, StorageAdapter } from '../types'\nimport type { Collection } from '@tanstack/db'\n\nexport class OutboxTransactionNotFoundError extends Error {}\n\nexport class OutboxManager {\n private storage: StorageAdapter\n private serializer: TransactionSerializer\n private keyPrefix = `tx:`\n private removalRevision = 0\n private activeReads = new Set<{ revision: number }>()\n private removedAtRevision = new Map<string, number>()\n\n constructor(\n storage: StorageAdapter,\n\n collections: Record<string, Collection<any, any, any, any, any>>,\n ) {\n this.storage = storage\n this.serializer = new TransactionSerializer(collections)\n }\n\n private getStorageKey(id: string): string {\n return `${this.keyPrefix}${id}`\n }\n\n private recordRemovals(ids: Iterable<string>): void {\n this.removalRevision++\n if (this.activeReads.size === 0) return\n for (const id of ids) this.removedAtRevision.set(id, this.removalRevision)\n this.pruneRemovalHistory()\n }\n\n private pruneRemovalHistory(): void {\n if (this.activeReads.size === 0) {\n this.removedAtRevision.clear()\n return\n }\n let oldestRead = Number.POSITIVE_INFINITY\n for (const read of this.activeReads)\n oldestRead = Math.min(oldestRead, read.revision)\n for (const [id, revision] of this.removedAtRevision)\n if (revision <= oldestRead) this.removedAtRevision.delete(id)\n }\n\n async add(transaction: OfflineTransaction): Promise<void> {\n return withSpan(\n `outbox.add`,\n {\n 'transaction.id': transaction.id,\n 'transaction.mutationFnName': transaction.mutationFnName,\n 'transaction.keyCount': transaction.keys.length,\n },\n async () => {\n const key = this.getStorageKey(transaction.id)\n const serialized = this.serializer.serialize(transaction)\n await this.storage.set(key, serialized)\n },\n )\n }\n\n async get(id: string): Promise<OfflineTransaction | null> {\n return withSpan(`outbox.get`, { 'transaction.id': id }, async (span) => {\n const key = this.getStorageKey(id)\n const data = await this.storage.get(key)\n\n if (!data) {\n span.setAttribute(`result`, `not_found`)\n return null\n }\n\n try {\n const transaction = this.serializer.deserialize(data)\n span.setAttribute(`result`, `found`)\n return transaction\n } catch (error) {\n if (error instanceof MissingTemporalConstructorError) {\n error.message = `transaction ${id}: ${error.message}`\n throw error\n }\n console.warn(`Failed to deserialize transaction ${id}:`, error)\n span.setAttribute(`result`, `deserialize_error`)\n return null\n }\n })\n }\n\n async getAll(): Promise<Array<OfflineTransaction>> {\n return this.withAll((transactions) => transactions)\n }\n\n async withAll<T>(\n consume: (transactions: Array<OfflineTransaction>) => T,\n ): Promise<T> {\n const read = { revision: this.removalRevision }\n this.activeReads.add(read)\n try {\n return await withSpan(`outbox.getAll`, {}, async (span) => {\n const keys = await this.storage.keys()\n const transactionKeys = keys.filter((key) =>\n key.startsWith(this.keyPrefix),\n )\n\n span.setAttribute(`transactionCount`, transactionKeys.length)\n\n const transactions: Array<OfflineTransaction> = []\n\n for (const key of transactionKeys) {\n const data = await this.storage.get(key)\n if (data) {\n try {\n const transaction = this.serializer.deserialize(data)\n transactions.push(transaction)\n } catch (error) {\n if (error instanceof MissingTemporalConstructorError) {\n error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`\n throw error\n }\n console.warn(\n `Failed to deserialize transaction from key ${key}:`,\n error,\n )\n }\n }\n }\n\n const currentTransactions = transactions\n .filter(\n ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,\n )\n .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())\n // Keep the read registered until replay admission completes. A durable\n // removal cannot land between filtering and this synchronous consumer.\n return consume(currentTransactions)\n })\n } finally {\n this.activeReads.delete(read)\n this.pruneRemovalHistory()\n }\n }\n\n async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> {\n const allTransactions = await this.getAll()\n const keySet = new Set(keys)\n\n return allTransactions.filter((transaction) =>\n transaction.keys.some((key) => keySet.has(key)),\n )\n }\n\n async update(\n id: string,\n updates: Partial<OfflineTransaction>,\n ): Promise<void> {\n return withSpan(`outbox.update`, { 'transaction.id': id }, async () => {\n const existing = await this.get(id)\n if (!existing) {\n throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)\n }\n\n const updated = { ...existing, ...updates }\n await this.add(updated)\n })\n }\n\n async remove(id: string): Promise<void> {\n return withSpan(`outbox.remove`, { 'transaction.id': id }, async () => {\n const key = this.getStorageKey(id)\n await this.storage.delete(key)\n this.recordRemovals([id])\n })\n }\n\n async removeMany(ids: Array<string>): Promise<void> {\n return withSpan(`outbox.removeMany`, { count: ids.length }, async () => {\n await Promise.all(ids.map((id) => this.remove(id)))\n })\n }\n\n async clear(): Promise<void> {\n const keys = await this.storage.keys()\n const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix))\n\n await this.removeMany(\n transactionKeys.map((key) => key.slice(this.keyPrefix.length)),\n )\n }\n\n async count(): Promise<number> {\n const keys = await this.storage.keys()\n return keys.filter((key) => key.startsWith(this.keyPrefix)).length\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"OutboxManager.js","sources":["../../../src/outbox/OutboxManager.ts"],"sourcesContent":["import {\n MissingTemporalConstructorError,\n TransactionSerializer,\n} from './TransactionSerializer'\nimport type { OfflineTransaction, StorageAdapter } from '../types'\nimport type { Collection } from '@tanstack/db'\n\nexport class OutboxTransactionNotFoundError extends Error {}\n\nexport class OutboxManager {\n private storage: StorageAdapter\n private serializer: TransactionSerializer\n private keyPrefix = `tx:`\n private removalRevision = 0\n private activeReads = new Set<{ revision: number }>()\n private removedAtRevision = new Map<string, number>()\n\n constructor(\n storage: StorageAdapter,\n\n collections: Record<string, Collection<any, any, any, any, any>>,\n ) {\n this.storage = storage\n this.serializer = new TransactionSerializer(collections)\n }\n\n private getStorageKey(id: string): string {\n return `${this.keyPrefix}${id}`\n }\n\n private recordRemovals(ids: Iterable<string>): void {\n this.removalRevision++\n if (this.activeReads.size === 0) return\n for (const id of ids) this.removedAtRevision.set(id, this.removalRevision)\n this.pruneRemovalHistory()\n }\n\n private pruneRemovalHistory(): void {\n if (this.activeReads.size === 0) {\n this.removedAtRevision.clear()\n return\n }\n let oldestRead = Number.POSITIVE_INFINITY\n for (const read of this.activeReads)\n oldestRead = Math.min(oldestRead, read.revision)\n for (const [id, revision] of this.removedAtRevision)\n if (revision <= oldestRead) this.removedAtRevision.delete(id)\n }\n\n async add(transaction: OfflineTransaction): Promise<void> {\n const key = this.getStorageKey(transaction.id)\n const serialized = this.serializer.serialize(transaction)\n await this.storage.set(key, serialized)\n }\n\n async get(id: string): Promise<OfflineTransaction | null> {\n const key = this.getStorageKey(id)\n const data = await this.storage.get(key)\n if (!data) return null\n\n try {\n return this.serializer.deserialize(data)\n } catch (error) {\n if (error instanceof MissingTemporalConstructorError) {\n error.message = `transaction ${id}: ${error.message}`\n throw error\n }\n console.warn(`Failed to deserialize transaction ${id}:`, error)\n return null\n }\n }\n\n async getAll(): Promise<Array<OfflineTransaction>> {\n return this.withAll((transactions) => transactions)\n }\n\n async withAll<T>(\n consume: (transactions: Array<OfflineTransaction>) => T,\n ): Promise<T> {\n const read = { revision: this.removalRevision }\n this.activeReads.add(read)\n try {\n const keys = await this.storage.keys()\n const transactionKeys = keys.filter((key) =>\n key.startsWith(this.keyPrefix),\n )\n\n const transactions: Array<OfflineTransaction> = []\n\n for (const key of transactionKeys) {\n const data = await this.storage.get(key)\n if (data) {\n try {\n const transaction = this.serializer.deserialize(data)\n transactions.push(transaction)\n } catch (error) {\n if (error instanceof MissingTemporalConstructorError) {\n error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`\n throw error\n }\n console.warn(\n `Failed to deserialize transaction from key ${key}:`,\n error,\n )\n }\n }\n }\n\n const currentTransactions = transactions\n .filter(\n ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,\n )\n .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())\n // Keep the read registered until replay admission completes. A durable\n // removal cannot land between filtering and this synchronous consumer.\n return await consume(currentTransactions)\n } finally {\n this.activeReads.delete(read)\n this.pruneRemovalHistory()\n }\n }\n\n async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> {\n const allTransactions = await this.getAll()\n const keySet = new Set(keys)\n\n return allTransactions.filter((transaction) =>\n transaction.keys.some((key) => keySet.has(key)),\n )\n }\n\n async update(\n id: string,\n updates: Partial<OfflineTransaction>,\n ): Promise<void> {\n const existing = await this.get(id)\n if (!existing) {\n throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)\n }\n await this.add({ ...existing, ...updates })\n }\n\n async remove(id: string): Promise<void> {\n const key = this.getStorageKey(id)\n await this.storage.delete(key)\n this.recordRemovals([id])\n }\n\n async removeMany(ids: Array<string>): Promise<void> {\n await Promise.all(ids.map((id) => this.remove(id)))\n }\n\n async clear(): Promise<void> {\n const keys = await this.storage.keys()\n const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix))\n\n await this.removeMany(\n transactionKeys.map((key) => key.slice(this.keyPrefix.length)),\n )\n }\n\n async count(): Promise<number> {\n const keys = await this.storage.keys()\n return keys.filter((key) => key.startsWith(this.keyPrefix)).length\n }\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/offline-transactions",
3
- "version": "1.1.0",
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.13.0"
55
+ "@tanstack/db": "0.14.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@react-native-community/netinfo": ">=11.0.0",
@@ -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()
310
+
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
319
321
 
320
- // Set up event listeners after leadership is established
321
- // This prevents the callback from being called multiple times
322
- this.setupEventListeners()
322
+ // Set up event listeners after leadership is established
323
+ // This prevents the callback from being called multiple times
324
+ this.setupEventListeners()
323
325
 
324
- // Notify initial leadership state
325
- if (this.config.onLeadershipChange) {
326
- this.config.onLeadershipChange(isLeader)
327
- }
326
+ // Notify initial leadership state
327
+ if (this.config.onLeadershipChange) {
328
+ this.config.onLeadershipChange(isLeader)
329
+ }
328
330
 
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
- )
331
+ if (isLeader) {
332
+ await this.loadAndReplayTransactions()
340
333
  }
341
- })
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)
@@ -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
  }