@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.
- package/README.md +1 -1
- package/dist/cjs/OfflineExecutor.cjs +72 -80
- package/dist/cjs/OfflineExecutor.cjs.map +1 -1
- package/dist/cjs/OfflineExecutor.d.cts +6 -0
- package/dist/cjs/api/OfflineAction.cjs +4 -15
- package/dist/cjs/api/OfflineAction.cjs.map +1 -1
- package/dist/cjs/api/OfflineTransaction.cjs +54 -51
- package/dist/cjs/api/OfflineTransaction.cjs.map +1 -1
- package/dist/cjs/api/OfflineTransaction.d.cts +2 -0
- package/dist/cjs/executor/KeyScheduler.cjs +12 -38
- package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
- package/dist/cjs/executor/TransactionExecutor.cjs +106 -161
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
- package/dist/cjs/outbox/OutboxManager.cjs +48 -77
- package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
- package/dist/esm/OfflineExecutor.d.ts +6 -0
- package/dist/esm/OfflineExecutor.js +72 -80
- package/dist/esm/OfflineExecutor.js.map +1 -1
- package/dist/esm/api/OfflineAction.js +4 -15
- package/dist/esm/api/OfflineAction.js.map +1 -1
- package/dist/esm/api/OfflineTransaction.d.ts +2 -0
- package/dist/esm/api/OfflineTransaction.js +54 -51
- package/dist/esm/api/OfflineTransaction.js.map +1 -1
- package/dist/esm/executor/KeyScheduler.js +12 -38
- package/dist/esm/executor/KeyScheduler.js.map +1 -1
- package/dist/esm/executor/TransactionExecutor.js +106 -161
- package/dist/esm/executor/TransactionExecutor.js.map +1 -1
- package/dist/esm/outbox/OutboxManager.js +48 -77
- package/dist/esm/outbox/OutboxManager.js.map +1 -1
- package/package.json +2 -2
- package/src/OfflineExecutor.ts +93 -94
- package/src/api/OfflineAction.ts +4 -22
- package/src/api/OfflineTransaction.ts +78 -66
- package/src/executor/KeyScheduler.ts +18 -47
- package/src/executor/TransactionExecutor.ts +122 -186
- package/src/outbox/OutboxManager.ts +54 -86
- package/dist/cjs/retry/NonRetriableError.d.cts +0 -1
- package/dist/cjs/telemetry/tracer.cjs +0 -27
- package/dist/cjs/telemetry/tracer.cjs.map +0 -1
- package/dist/cjs/telemetry/tracer.d.cts +0 -30
- package/dist/esm/retry/NonRetriableError.d.ts +0 -1
- package/dist/esm/telemetry/tracer.d.ts +0 -30
- package/dist/esm/telemetry/tracer.js +0 -27
- package/dist/esm/telemetry/tracer.js.map +0 -1
- package/src/retry/NonRetriableError.ts +0 -1
- 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":"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"}
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
55
|
+
"@tanstack/db": "0.14.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@react-native-community/netinfo": ">=11.0.0",
|
package/src/OfflineExecutor.ts
CHANGED
|
@@ -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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
// Notify initial leadership state
|
|
327
|
+
if (this.config.onLeadershipChange) {
|
|
328
|
+
this.config.onLeadershipChange(isLeader)
|
|
329
|
+
}
|
|
328
330
|
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
445
|
-
`
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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)
|
package/src/api/OfflineAction.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|