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