@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,5 +1,4 @@
|
|
|
1
1
|
import { createTransaction, safeRandomUUID } from '@tanstack/db'
|
|
2
|
-
import { NonRetriableError } from '../types'
|
|
3
2
|
import type { PendingMutation, Transaction } from '@tanstack/db'
|
|
4
3
|
import type {
|
|
5
4
|
CreateOfflineTransactionOptions,
|
|
@@ -10,10 +9,12 @@ import type {
|
|
|
10
9
|
export class OfflineTransaction {
|
|
11
10
|
private offlineId: string
|
|
12
11
|
private mutationFnName: string
|
|
13
|
-
private autoCommit: boolean
|
|
12
|
+
private autoCommit: boolean | undefined
|
|
14
13
|
private idempotencyKey: string
|
|
15
14
|
private metadata: Record<string, any>
|
|
16
15
|
private transaction: Transaction | null = null
|
|
16
|
+
/** The mutation function ran, so the executor settles the waiter. */
|
|
17
|
+
private persisting = false
|
|
17
18
|
private persistTransaction: (tx: OfflineTransactionType) => Promise<void>
|
|
18
19
|
private executor: any // Will be typed properly - reference to OfflineExecutor
|
|
19
20
|
|
|
@@ -25,7 +26,8 @@ export class OfflineTransaction {
|
|
|
25
26
|
) {
|
|
26
27
|
this.offlineId = safeRandomUUID()
|
|
27
28
|
this.mutationFnName = options.mutationFnName
|
|
28
|
-
|
|
29
|
+
// The TanStack DB transaction applies the default.
|
|
30
|
+
this.autoCommit = options.autoCommit
|
|
29
31
|
this.idempotencyKey = options.idempotencyKey ?? safeRandomUUID()
|
|
30
32
|
this.metadata = options.metadata ?? {}
|
|
31
33
|
this.persistTransaction = persistTransaction
|
|
@@ -33,65 +35,81 @@ export class OfflineTransaction {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
mutate(callback: () => void): Transaction {
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
this
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
// One offline transaction is one transaction, and its id is unique among
|
|
39
|
+
// live transactions. Repeated calls add to it while it is pending. With
|
|
40
|
+
// autoCommit, it commits after this callback and reports a failure
|
|
41
|
+
// through `when('settled')`, so a later call throws: it is no longer pending.
|
|
42
|
+
if (!this.transaction) {
|
|
43
|
+
this.transaction = createTransaction({
|
|
44
|
+
id: this.offlineId,
|
|
45
|
+
autoCommit: this.autoCommit,
|
|
46
|
+
mutationFn: async () => {
|
|
47
|
+
this.persisting = true
|
|
48
|
+
// This is the blocking mutationFn that waits for the executor
|
|
49
|
+
// First persist the transaction to the outbox
|
|
50
|
+
const offlineTransaction: OfflineTransactionType = {
|
|
51
|
+
id: this.offlineId,
|
|
52
|
+
mutationFnName: this.mutationFnName,
|
|
53
|
+
mutations: this.transaction!.mutations,
|
|
54
|
+
keys: this.extractKeys(this.transaction!.mutations),
|
|
55
|
+
idempotencyKey: this.idempotencyKey,
|
|
56
|
+
createdAt: new Date(),
|
|
57
|
+
retryCount: 0,
|
|
58
|
+
nextAttemptAt: Date.now(),
|
|
59
|
+
metadata: this.metadata,
|
|
60
|
+
spanContext: undefined,
|
|
61
|
+
version: 1,
|
|
62
|
+
}
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
const completionPromise = this.executor.waitForTransactionCompletion(
|
|
65
|
+
this.offlineId,
|
|
66
|
+
)
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
try {
|
|
69
|
+
// Persistence also drives the shared queue. This transaction can finish
|
|
70
|
+
// before that queue drains; observe both promises from the outset.
|
|
71
|
+
await Promise.race([
|
|
72
|
+
this.persistTransaction(offlineTransaction),
|
|
73
|
+
completionPromise,
|
|
74
|
+
])
|
|
75
|
+
// A queue pause (offline or retry) is not transaction completion.
|
|
76
|
+
await completionPromise
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const normalizedError =
|
|
79
|
+
error instanceof Error ? error : new Error(String(error))
|
|
80
|
+
this.executor.rejectTransaction(this.offlineId, normalizedError)
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
return
|
|
85
|
+
},
|
|
86
|
+
metadata: this.metadata,
|
|
87
|
+
})
|
|
88
|
+
// A transaction without mutations completes, and one rolled back before
|
|
89
|
+
// it commits fails, without its mutation function, so the executor never
|
|
90
|
+
// settles its waiter. Settle that waiter here with the same outcome. Once
|
|
91
|
+
// the mutation function runs, only the executor settles it.
|
|
92
|
+
void this.transaction.when(`settled`).then(
|
|
93
|
+
() => {
|
|
94
|
+
if (!this.persisting)
|
|
95
|
+
this.executor.resolveTransaction(this.offlineId, undefined)
|
|
96
|
+
},
|
|
97
|
+
(error: unknown) => {
|
|
98
|
+
if (!this.persisting)
|
|
99
|
+
this.executor.rejectTransaction(
|
|
100
|
+
this.offlineId,
|
|
101
|
+
error instanceof Error
|
|
102
|
+
? error
|
|
103
|
+
: new Error(`Offline transaction was rolled back`),
|
|
104
|
+
)
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
}
|
|
82
108
|
|
|
83
109
|
this.transaction.mutate(() => {
|
|
84
110
|
callback()
|
|
85
111
|
})
|
|
86
112
|
|
|
87
|
-
if (this.autoCommit) {
|
|
88
|
-
// Auto-commit for direct OfflineTransaction usage
|
|
89
|
-
this.commit().catch((error) => {
|
|
90
|
-
console.error(`Auto-commit failed:`, error)
|
|
91
|
-
throw error
|
|
92
|
-
})
|
|
93
|
-
}
|
|
94
|
-
|
|
95
113
|
return this.transaction
|
|
96
114
|
}
|
|
97
115
|
|
|
@@ -100,18 +118,12 @@ export class OfflineTransaction {
|
|
|
100
118
|
throw new Error(`No mutations to commit. Call mutate() first.`)
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// Only rollback for NonRetriableError - other errors should allow retry
|
|
110
|
-
if (error instanceof NonRetriableError) {
|
|
111
|
-
this.transaction.rollback()
|
|
112
|
-
}
|
|
113
|
-
throw error
|
|
114
|
-
}
|
|
121
|
+
// The mutationFn persists the transaction and waits for the executor. A
|
|
122
|
+
// commit that rejects has already rolled the transaction back. A sibling's
|
|
123
|
+
// failure can also roll it back while it waits, and then commit() resolves,
|
|
124
|
+
// so `when('settled')` is the receipt.
|
|
125
|
+
await this.transaction.commit()
|
|
126
|
+
return this.transaction
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
rollback(): void {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { withSyncSpan } from '../telemetry/tracer'
|
|
2
1
|
import type { OfflineTransaction } from '../types'
|
|
3
2
|
|
|
4
3
|
export class KeyScheduler {
|
|
@@ -6,56 +5,28 @@ export class KeyScheduler {
|
|
|
6
5
|
private activeTransactionId: string | undefined
|
|
7
6
|
|
|
8
7
|
schedule(transaction: OfflineTransaction): boolean {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
(pending) => pending.id === transaction.id,
|
|
19
|
-
)
|
|
20
|
-
) {
|
|
21
|
-
return false
|
|
22
|
-
}
|
|
23
|
-
this.pendingTransactions.push(transaction)
|
|
24
|
-
// Sort by creation time to maintain FIFO order
|
|
25
|
-
this.pendingTransactions.sort(
|
|
26
|
-
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
27
|
-
)
|
|
28
|
-
return true
|
|
29
|
-
},
|
|
8
|
+
if (
|
|
9
|
+
this.pendingTransactions.some((pending) => pending.id === transaction.id)
|
|
10
|
+
) {
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
this.pendingTransactions.push(transaction)
|
|
14
|
+
// Sort by creation time to maintain FIFO order
|
|
15
|
+
this.pendingTransactions.sort(
|
|
16
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
30
17
|
)
|
|
18
|
+
return true
|
|
31
19
|
}
|
|
32
20
|
|
|
33
21
|
getNext(): OfflineTransaction | undefined {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
span.setAttribute(`result`, `empty`)
|
|
43
|
-
return undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const firstTransaction = this.pendingTransactions[0]!
|
|
47
|
-
|
|
48
|
-
if (!this.isReadyToRun(firstTransaction)) {
|
|
49
|
-
span.setAttribute(`result`, `waiting_for_first`)
|
|
50
|
-
span.setAttribute(`transaction.id`, firstTransaction.id)
|
|
51
|
-
return undefined
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
span.setAttribute(`result`, `found`)
|
|
55
|
-
span.setAttribute(`transaction.id`, firstTransaction.id)
|
|
56
|
-
return firstTransaction
|
|
57
|
-
},
|
|
58
|
-
)
|
|
22
|
+
if (
|
|
23
|
+
this.activeTransactionId !== undefined ||
|
|
24
|
+
this.pendingTransactions.length === 0
|
|
25
|
+
) {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
const firstTransaction = this.pendingTransactions[0]!
|
|
29
|
+
return this.isReadyToRun(firstTransaction) ? firstTransaction : undefined
|
|
59
30
|
}
|
|
60
31
|
|
|
61
32
|
private isReadyToRun(transaction: OfflineTransaction): boolean {
|
|
@@ -2,7 +2,6 @@ import { DuplicateTransactionIdError, createTransaction } from '@tanstack/db'
|
|
|
2
2
|
import { OutboxTransactionNotFoundError } from '../outbox/OutboxManager'
|
|
3
3
|
import { DefaultRetryPolicy } from '../retry/RetryPolicy'
|
|
4
4
|
import { NonRetriableError } from '../types'
|
|
5
|
-
import { withNestedSpan } from '../telemetry/tracer'
|
|
6
5
|
import type { KeyScheduler } from './KeyScheduler'
|
|
7
6
|
import type { OutboxManager } from '../outbox/OutboxManager'
|
|
8
7
|
import type {
|
|
@@ -91,72 +90,48 @@ export class TransactionExecutor {
|
|
|
91
90
|
transaction: OfflineTransaction,
|
|
92
91
|
): Promise<void> {
|
|
93
92
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
'transaction.retryCount': transaction.retryCount,
|
|
100
|
-
'transaction.keyCount': transaction.keys.length,
|
|
101
|
-
},
|
|
102
|
-
async (span) => {
|
|
103
|
-
const terminalError =
|
|
104
|
-
transaction.outboxPhase === `rejection-pending`
|
|
105
|
-
? this.getTerminalError(transaction)
|
|
106
|
-
: undefined
|
|
107
|
-
this.scheduler.markStarted(transaction)
|
|
108
|
-
|
|
109
|
-
if (transaction.retryCount > 0) {
|
|
110
|
-
span.setAttribute(`retry.attempt`, transaction.retryCount)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (!transaction.outboxPhase) {
|
|
114
|
-
try {
|
|
115
|
-
await this.runMutationFn(transaction)
|
|
116
|
-
} catch (error) {
|
|
117
|
-
const err = toError(error)
|
|
93
|
+
const terminalError =
|
|
94
|
+
transaction.outboxPhase === `rejection-pending`
|
|
95
|
+
? this.getTerminalError(transaction)
|
|
96
|
+
: undefined
|
|
97
|
+
this.scheduler.markStarted(transaction)
|
|
118
98
|
|
|
119
|
-
|
|
99
|
+
if (!transaction.outboxPhase) {
|
|
100
|
+
try {
|
|
101
|
+
await this.runMutationFn(transaction)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const err = toError(error)
|
|
120
104
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
105
|
+
await this.handleError(transaction, err)
|
|
106
|
+
;(err as any)[HANDLED_EXECUTION_ERROR] = true
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
109
|
+
}
|
|
126
110
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
throw storageError
|
|
146
|
-
}
|
|
111
|
+
// A successful provider call can outlive this executor. Persist that
|
|
112
|
+
// fact before removal so a restarted executor only retries deletion.
|
|
113
|
+
const deletionPending: OfflineTransaction = transaction.outboxPhase
|
|
114
|
+
? transaction
|
|
115
|
+
: { ...transaction, outboxPhase: `deletion-pending` }
|
|
116
|
+
try {
|
|
117
|
+
await this.removeSettledTransaction(
|
|
118
|
+
deletionPending,
|
|
119
|
+
!transaction.outboxPhase,
|
|
120
|
+
)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const storageError = error as Error
|
|
123
|
+
this.offlineExecutor.rejectTransaction(
|
|
124
|
+
transaction.id,
|
|
125
|
+
terminalError ?? storageError,
|
|
126
|
+
)
|
|
127
|
+
throw storageError
|
|
128
|
+
}
|
|
147
129
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
154
|
-
} else {
|
|
155
|
-
span.setAttribute(`result`, `success`)
|
|
156
|
-
this.offlineExecutor.resolveTransaction(transaction.id, undefined)
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
)
|
|
130
|
+
if (terminalError) {
|
|
131
|
+
this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
|
|
132
|
+
} else {
|
|
133
|
+
this.offlineExecutor.resolveTransaction(transaction.id, undefined)
|
|
134
|
+
}
|
|
160
135
|
} catch (error) {
|
|
161
136
|
if (
|
|
162
137
|
error !== null &&
|
|
@@ -201,129 +176,97 @@ export class TransactionExecutor {
|
|
|
201
176
|
transaction: OfflineTransaction,
|
|
202
177
|
error: Error,
|
|
203
178
|
): Promise<void> {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
let decision: boolean | undefined
|
|
218
|
-
try {
|
|
219
|
-
decision = this.config.shouldRetry?.(error, transaction.retryCount)
|
|
220
|
-
if (decision !== undefined && typeof decision !== `boolean`) {
|
|
221
|
-
// An async hook is invalid, but its rejection must stay row-local.
|
|
222
|
-
void Promise.resolve(decision).catch(() => {})
|
|
223
|
-
throw new TypeError(
|
|
224
|
-
`OfflineConfig.shouldRetry must return true, false, or undefined`,
|
|
225
|
-
)
|
|
226
|
-
}
|
|
227
|
-
} catch (hookError) {
|
|
228
|
-
hookFailure = toError(hookError)
|
|
229
|
-
}
|
|
230
|
-
if (hookFailure) {
|
|
231
|
-
shouldRetry = false
|
|
232
|
-
} else {
|
|
233
|
-
shouldRetry =
|
|
234
|
-
decision ??
|
|
235
|
-
this.retryPolicy.shouldRetry(error, transaction.retryCount)
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
span.setAttribute(`shouldRetry`, shouldRetry)
|
|
240
|
-
|
|
241
|
-
if (!shouldRetry) {
|
|
242
|
-
const terminalError = hookFailure ?? error
|
|
243
|
-
const rejectionPending: OfflineTransaction = {
|
|
244
|
-
...transaction,
|
|
245
|
-
outboxPhase: `rejection-pending`,
|
|
246
|
-
lastError: {
|
|
247
|
-
name: terminalError.name,
|
|
248
|
-
message: terminalError.message,
|
|
249
|
-
stack: terminalError.stack,
|
|
250
|
-
},
|
|
251
|
-
}
|
|
252
|
-
if (hookFailure) {
|
|
253
|
-
span.recordException(hookFailure)
|
|
254
|
-
console.warn(
|
|
255
|
-
`Retry decision failed for transaction ${transaction.id}:`,
|
|
256
|
-
hookFailure,
|
|
257
|
-
`Named mutation function error:`,
|
|
258
|
-
error,
|
|
259
|
-
)
|
|
260
|
-
} else {
|
|
261
|
-
console.warn(
|
|
262
|
-
`Transaction ${transaction.id} failed permanently:`,
|
|
263
|
-
error,
|
|
264
|
-
)
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
await this.removeSettledTransaction(rejectionPending, true)
|
|
268
|
-
} catch (storageError) {
|
|
269
|
-
span.recordException(storageError as Error)
|
|
270
|
-
span.setAttribute(`result`, `outbox_failure`)
|
|
271
|
-
this.offlineExecutor.rejectTransaction(
|
|
272
|
-
transaction.id,
|
|
273
|
-
terminalError,
|
|
274
|
-
)
|
|
275
|
-
throw storageError
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
span.setAttribute(
|
|
279
|
-
`result`,
|
|
280
|
-
hookFailure ? `retry_decision_failure` : `permanent_failure`,
|
|
179
|
+
let shouldRetry: boolean
|
|
180
|
+
let hookFailure: Error | undefined
|
|
181
|
+
if (error instanceof NonRetriableError) {
|
|
182
|
+
shouldRetry = false
|
|
183
|
+
} else {
|
|
184
|
+
let decision: boolean | undefined
|
|
185
|
+
try {
|
|
186
|
+
decision = this.config.shouldRetry?.(error, transaction.retryCount)
|
|
187
|
+
if (decision !== undefined && typeof decision !== `boolean`) {
|
|
188
|
+
// An async hook is invalid, but its rejection must stay row-local.
|
|
189
|
+
void Promise.resolve(decision).catch(() => {})
|
|
190
|
+
throw new TypeError(
|
|
191
|
+
`OfflineConfig.shouldRetry must return true, false, or undefined`,
|
|
281
192
|
)
|
|
282
|
-
this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
|
|
283
|
-
return
|
|
284
193
|
}
|
|
194
|
+
} catch (hookError) {
|
|
195
|
+
hookFailure = toError(hookError)
|
|
196
|
+
}
|
|
197
|
+
if (hookFailure) {
|
|
198
|
+
shouldRetry = false
|
|
199
|
+
} else {
|
|
200
|
+
shouldRetry =
|
|
201
|
+
decision ??
|
|
202
|
+
this.retryPolicy.shouldRetry(error, transaction.retryCount)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
285
205
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
206
|
+
if (!shouldRetry) {
|
|
207
|
+
const terminalError = hookFailure ?? error
|
|
208
|
+
const rejectionPending: OfflineTransaction = {
|
|
209
|
+
...transaction,
|
|
210
|
+
outboxPhase: `rejection-pending`,
|
|
211
|
+
lastError: {
|
|
212
|
+
name: terminalError.name,
|
|
213
|
+
message: terminalError.message,
|
|
214
|
+
stack: terminalError.stack,
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
if (hookFailure) {
|
|
218
|
+
console.warn(
|
|
219
|
+
`Retry decision failed for transaction ${transaction.id}:`,
|
|
220
|
+
hookFailure,
|
|
221
|
+
`Named mutation function error:`,
|
|
222
|
+
error,
|
|
289
223
|
)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
span.setAttribute(`retryDelay`, delay)
|
|
302
|
-
span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
|
|
224
|
+
} else {
|
|
225
|
+
console.warn(`Transaction ${transaction.id} failed permanently:`, error)
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
await this.removeSettledTransaction(rejectionPending, true)
|
|
229
|
+
} catch (storageError) {
|
|
230
|
+
this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
|
|
231
|
+
throw storageError
|
|
232
|
+
}
|
|
303
233
|
|
|
304
|
-
|
|
234
|
+
this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
305
237
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
} catch (persistError) {
|
|
310
|
-
// Public removal can finish while the named mutation function runs.
|
|
311
|
-
// The removed row cannot be retried, but its caller still owns the
|
|
312
|
-
// provider failure and later queued work must be able to run.
|
|
313
|
-
if (persistError instanceof OutboxTransactionNotFoundError) {
|
|
314
|
-
this.scheduler.markCompleted(transaction)
|
|
315
|
-
span.setAttribute(`result`, `outbox_removed`)
|
|
316
|
-
this.offlineExecutor.rejectTransaction(transaction.id, error)
|
|
317
|
-
return
|
|
318
|
-
}
|
|
319
|
-
span.recordException(persistError as Error)
|
|
320
|
-
span.setAttribute(`result`, `persist_failed`)
|
|
321
|
-
throw persistError
|
|
322
|
-
} finally {
|
|
323
|
-
this.scheduler.markFailed(transaction)
|
|
324
|
-
}
|
|
325
|
-
},
|
|
238
|
+
const delay = Math.max(
|
|
239
|
+
0,
|
|
240
|
+
this.retryPolicy.calculateDelay(transaction.retryCount),
|
|
326
241
|
)
|
|
242
|
+
const updatedTransaction: OfflineTransaction = {
|
|
243
|
+
...transaction,
|
|
244
|
+
retryCount: transaction.retryCount + 1,
|
|
245
|
+
nextAttemptAt: Date.now() + delay,
|
|
246
|
+
lastError: {
|
|
247
|
+
name: error.name,
|
|
248
|
+
message: error.message,
|
|
249
|
+
stack: error.stack,
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.scheduler.updateTransaction(updatedTransaction)
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await this.outbox.update(transaction.id, updatedTransaction)
|
|
257
|
+
} catch (persistError) {
|
|
258
|
+
// Public removal can finish while the named mutation function runs.
|
|
259
|
+
// The removed row cannot be retried, but its caller still owns the
|
|
260
|
+
// provider failure and later queued work must be able to run.
|
|
261
|
+
if (persistError instanceof OutboxTransactionNotFoundError) {
|
|
262
|
+
this.scheduler.markCompleted(transaction)
|
|
263
|
+
this.offlineExecutor.rejectTransaction(transaction.id, error)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
throw persistError
|
|
267
|
+
} finally {
|
|
268
|
+
this.scheduler.markFailed(transaction)
|
|
269
|
+
}
|
|
327
270
|
}
|
|
328
271
|
|
|
329
272
|
private async removeSettledTransaction(
|
|
@@ -453,13 +396,6 @@ export class TransactionExecutor {
|
|
|
453
396
|
mutationFn: async () => {},
|
|
454
397
|
})
|
|
455
398
|
|
|
456
|
-
// Prevent unhandled promise rejection when cleanup calls rollback()
|
|
457
|
-
// We don't care about this promise - it's just for holding mutations
|
|
458
|
-
restorationTx.isPersisted.promise.catch(() => {
|
|
459
|
-
// Intentionally ignored - restoration transactions are cleaned up
|
|
460
|
-
// via cleanupRestorationTransaction, not through normal commit flow
|
|
461
|
-
})
|
|
462
|
-
|
|
463
399
|
restorationTx.applyMutations(offlineTx.mutations)
|
|
464
400
|
|
|
465
401
|
// Register with each affected Collection. If one cannot track it, roll
|