@tanstack/offline-transactions 1.0.65 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -2
- package/dist/cjs/OfflineExecutor.cjs +74 -93
- 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 +119 -134
- 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/cjs/types.cjs.map +1 -1
- package/dist/cjs/types.d.cts +10 -0
- package/dist/esm/OfflineExecutor.d.ts +6 -0
- package/dist/esm/OfflineExecutor.js +74 -93
- 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 +120 -135
- 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/dist/esm/types.d.ts +10 -0
- package/dist/esm/types.js.map +1 -1
- package/package.json +2 -2
- package/skills/offline/SKILL.md +73 -5
- package/src/OfflineExecutor.ts +97 -113
- package/src/api/OfflineAction.ts +4 -22
- package/src/api/OfflineTransaction.ts +78 -64
- package/src/executor/KeyScheduler.ts +18 -47
- package/src/executor/TransactionExecutor.ts +148 -162
- package/src/outbox/OutboxManager.ts +54 -86
- package/src/types.ts +10 -0
- 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,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 {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { createTransaction } from '@tanstack/db'
|
|
1
|
+
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 {
|
|
@@ -13,6 +12,13 @@ import type {
|
|
|
13
12
|
|
|
14
13
|
const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)
|
|
15
14
|
|
|
15
|
+
function toError(value: unknown): Error {
|
|
16
|
+
return value instanceof Error ||
|
|
17
|
+
Object.prototype.toString.call(value) === `[object Error]`
|
|
18
|
+
? (value as Error)
|
|
19
|
+
: new Error(String(value))
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
export class TransactionExecutor {
|
|
17
23
|
private scheduler: KeyScheduler
|
|
18
24
|
private outbox: OutboxManager
|
|
@@ -84,77 +90,53 @@ export class TransactionExecutor {
|
|
|
84
90
|
transaction: OfflineTransaction,
|
|
85
91
|
): Promise<void> {
|
|
86
92
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'transaction.retryCount': transaction.retryCount,
|
|
93
|
-
'transaction.keyCount': transaction.keys.length,
|
|
94
|
-
},
|
|
95
|
-
async (span) => {
|
|
96
|
-
const terminalError =
|
|
97
|
-
transaction.outboxPhase === `rejection-pending`
|
|
98
|
-
? this.getTerminalError(transaction)
|
|
99
|
-
: undefined
|
|
100
|
-
this.scheduler.markStarted(transaction)
|
|
101
|
-
|
|
102
|
-
if (transaction.retryCount > 0) {
|
|
103
|
-
span.setAttribute(`retry.attempt`, transaction.retryCount)
|
|
104
|
-
}
|
|
105
|
-
|
|
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))
|
|
93
|
+
const terminalError =
|
|
94
|
+
transaction.outboxPhase === `rejection-pending`
|
|
95
|
+
? this.getTerminalError(transaction)
|
|
96
|
+
: undefined
|
|
97
|
+
this.scheduler.markStarted(transaction)
|
|
112
98
|
|
|
113
|
-
|
|
99
|
+
if (!transaction.outboxPhase) {
|
|
100
|
+
try {
|
|
101
|
+
await this.runMutationFn(transaction)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const err = toError(error)
|
|
114
104
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
105
|
+
await this.handleError(transaction, err)
|
|
106
|
+
;(err as any)[HANDLED_EXECUTION_ERROR] = true
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
109
|
+
}
|
|
120
110
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
throw storageError
|
|
140
|
-
}
|
|
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
|
+
}
|
|
141
129
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
)
|
|
148
|
-
} else {
|
|
149
|
-
span.setAttribute(`result`, `success`)
|
|
150
|
-
this.offlineExecutor.resolveTransaction(transaction.id, undefined)
|
|
151
|
-
}
|
|
152
|
-
},
|
|
153
|
-
)
|
|
130
|
+
if (terminalError) {
|
|
131
|
+
this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
|
|
132
|
+
} else {
|
|
133
|
+
this.offlineExecutor.resolveTransaction(transaction.id, undefined)
|
|
134
|
+
}
|
|
154
135
|
} catch (error) {
|
|
155
136
|
if (
|
|
156
|
-
error
|
|
157
|
-
|
|
137
|
+
error !== null &&
|
|
138
|
+
typeof error === `object` &&
|
|
139
|
+
(error as Record<symbol, unknown>)[HANDLED_EXECUTION_ERROR] === true
|
|
158
140
|
) {
|
|
159
141
|
return
|
|
160
142
|
}
|
|
@@ -194,81 +176,97 @@ export class TransactionExecutor {
|
|
|
194
176
|
transaction: OfflineTransaction,
|
|
195
177
|
error: Error,
|
|
196
178
|
): Promise<void> {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
span.setAttribute(`shouldRetry`, shouldRetry)
|
|
211
|
-
|
|
212
|
-
if (!shouldRetry) {
|
|
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
|
-
},
|
|
221
|
-
}
|
|
222
|
-
console.warn(
|
|
223
|
-
`Transaction ${transaction.id} failed permanently:`,
|
|
224
|
-
error,
|
|
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`,
|
|
225
192
|
)
|
|
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
|
-
}
|
|
234
|
-
|
|
235
|
-
span.setAttribute(`result`, `permanent_failure`)
|
|
236
|
-
this.offlineExecutor.rejectTransaction(transaction.id, error)
|
|
237
|
-
return
|
|
238
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
|
+
}
|
|
239
205
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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,
|
|
243
223
|
)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
span.setAttribute(`retryDelay`, delay)
|
|
256
|
-
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
|
+
}
|
|
257
233
|
|
|
258
|
-
|
|
234
|
+
this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
259
237
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
} catch (persistError) {
|
|
264
|
-
span.recordException(persistError as Error)
|
|
265
|
-
span.setAttribute(`result`, `persist_failed`)
|
|
266
|
-
throw persistError
|
|
267
|
-
} finally {
|
|
268
|
-
this.scheduler.markFailed(transaction)
|
|
269
|
-
}
|
|
270
|
-
},
|
|
238
|
+
const delay = Math.max(
|
|
239
|
+
0,
|
|
240
|
+
this.retryPolicy.calculateDelay(transaction.retryCount),
|
|
271
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
|
+
}
|
|
272
270
|
}
|
|
273
271
|
|
|
274
272
|
private async removeSettledTransaction(
|
|
@@ -287,8 +285,7 @@ export class TransactionExecutor {
|
|
|
287
285
|
await this.outbox.remove(transaction.id)
|
|
288
286
|
this.scheduler.markCompleted(transaction)
|
|
289
287
|
} catch (error) {
|
|
290
|
-
const storageError =
|
|
291
|
-
error instanceof Error ? error : new Error(String(error))
|
|
288
|
+
const storageError = toError(error)
|
|
292
289
|
this.fatalError = storageError
|
|
293
290
|
this.scheduler.markFailed(transaction)
|
|
294
291
|
this.clearRetryTimer()
|
|
@@ -399,34 +396,21 @@ export class TransactionExecutor {
|
|
|
399
396
|
mutationFn: async () => {},
|
|
400
397
|
})
|
|
401
398
|
|
|
402
|
-
// Prevent unhandled promise rejection when cleanup calls rollback()
|
|
403
|
-
// We don't care about this promise - it's just for holding mutations
|
|
404
|
-
restorationTx.isPersisted.promise.catch(() => {
|
|
405
|
-
// Intentionally ignored - restoration transactions are cleaned up
|
|
406
|
-
// via cleanupRestorationTransaction, not through normal commit flow
|
|
407
|
-
})
|
|
408
|
-
|
|
409
399
|
restorationTx.applyMutations(offlineTx.mutations)
|
|
410
400
|
|
|
411
|
-
// Register with each affected
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
const collectionId = mutation.collection.id
|
|
420
|
-
if (touchedCollections.has(collectionId)) {
|
|
421
|
-
continue
|
|
401
|
+
// Register with each affected Collection. If one cannot track it, roll
|
|
402
|
+
// it back, so no Collection keeps a restoration nothing will settle.
|
|
403
|
+
try {
|
|
404
|
+
// The serializer resolves every mutation's Collection or throws.
|
|
405
|
+
for (const mutation of offlineTx.mutations) {
|
|
406
|
+
if (mutation.collection._state.trackTransaction(restorationTx))
|
|
407
|
+
mutation.collection._state.recomputeOptimisticState(true)
|
|
422
408
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
)
|
|
429
|
-
mutation.collection._state.recomputeOptimisticState(true)
|
|
409
|
+
} catch (error) {
|
|
410
|
+
// A secondary rollback settles only this restoration; it must not
|
|
411
|
+
// roll back the user's live transactions on the same keys.
|
|
412
|
+
restorationTx.rollback({ isSecondaryRollback: true })
|
|
413
|
+
throw error
|
|
430
414
|
}
|
|
431
415
|
|
|
432
416
|
this.offlineExecutor.registerRestorationTransaction(
|
|
@@ -434,6 +418,8 @@ export class TransactionExecutor {
|
|
|
434
418
|
restorationTx,
|
|
435
419
|
)
|
|
436
420
|
} catch (error) {
|
|
421
|
+
// A live transaction with this id already shows these mutations.
|
|
422
|
+
if (error instanceof DuplicateTransactionIdError) continue
|
|
437
423
|
console.warn(
|
|
438
424
|
`Failed to restore optimistic state for transaction ${offlineTx.id}:`,
|
|
439
425
|
error,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { withSpan } from '../telemetry/tracer'
|
|
2
1
|
import {
|
|
3
2
|
MissingTemporalConstructorError,
|
|
4
3
|
TransactionSerializer,
|
|
@@ -49,45 +48,26 @@ export class OutboxManager {
|
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
async add(transaction: OfflineTransaction): Promise<void> {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'transaction.id': transaction.id,
|
|
56
|
-
'transaction.mutationFnName': transaction.mutationFnName,
|
|
57
|
-
'transaction.keyCount': transaction.keys.length,
|
|
58
|
-
},
|
|
59
|
-
async () => {
|
|
60
|
-
const key = this.getStorageKey(transaction.id)
|
|
61
|
-
const serialized = this.serializer.serialize(transaction)
|
|
62
|
-
await this.storage.set(key, serialized)
|
|
63
|
-
},
|
|
64
|
-
)
|
|
51
|
+
const key = this.getStorageKey(transaction.id)
|
|
52
|
+
const serialized = this.serializer.serialize(transaction)
|
|
53
|
+
await this.storage.set(key, serialized)
|
|
65
54
|
}
|
|
66
55
|
|
|
67
56
|
async get(id: string): Promise<OfflineTransaction | null> {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!data) {
|
|
73
|
-
span.setAttribute(`result`, `not_found`)
|
|
74
|
-
return null
|
|
75
|
-
}
|
|
57
|
+
const key = this.getStorageKey(id)
|
|
58
|
+
const data = await this.storage.get(key)
|
|
59
|
+
if (!data) return null
|
|
76
60
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
error.message = `transaction ${id}: ${error.message}`
|
|
84
|
-
throw error
|
|
85
|
-
}
|
|
86
|
-
console.warn(`Failed to deserialize transaction ${id}:`, error)
|
|
87
|
-
span.setAttribute(`result`, `deserialize_error`)
|
|
88
|
-
return null
|
|
61
|
+
try {
|
|
62
|
+
return this.serializer.deserialize(data)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof MissingTemporalConstructorError) {
|
|
65
|
+
error.message = `transaction ${id}: ${error.message}`
|
|
66
|
+
throw error
|
|
89
67
|
}
|
|
90
|
-
|
|
68
|
+
console.warn(`Failed to deserialize transaction ${id}:`, error)
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
91
71
|
}
|
|
92
72
|
|
|
93
73
|
async getAll(): Promise<Array<OfflineTransaction>> {
|
|
@@ -100,44 +80,40 @@ export class OutboxManager {
|
|
|
100
80
|
const read = { revision: this.removalRevision }
|
|
101
81
|
this.activeReads.add(read)
|
|
102
82
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (error instanceof MissingTemporalConstructorError) {
|
|
121
|
-
error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`
|
|
122
|
-
throw error
|
|
123
|
-
}
|
|
124
|
-
console.warn(
|
|
125
|
-
`Failed to deserialize transaction from key ${key}:`,
|
|
126
|
-
error,
|
|
127
|
-
)
|
|
83
|
+
const keys = await this.storage.keys()
|
|
84
|
+
const transactionKeys = keys.filter((key) =>
|
|
85
|
+
key.startsWith(this.keyPrefix),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const transactions: Array<OfflineTransaction> = []
|
|
89
|
+
|
|
90
|
+
for (const key of transactionKeys) {
|
|
91
|
+
const data = await this.storage.get(key)
|
|
92
|
+
if (data) {
|
|
93
|
+
try {
|
|
94
|
+
const transaction = this.serializer.deserialize(data)
|
|
95
|
+
transactions.push(transaction)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof MissingTemporalConstructorError) {
|
|
98
|
+
error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`
|
|
99
|
+
throw error
|
|
128
100
|
}
|
|
101
|
+
console.warn(
|
|
102
|
+
`Failed to deserialize transaction from key ${key}:`,
|
|
103
|
+
error,
|
|
104
|
+
)
|
|
129
105
|
}
|
|
130
106
|
}
|
|
107
|
+
}
|
|
131
108
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
})
|
|
109
|
+
const currentTransactions = transactions
|
|
110
|
+
.filter(
|
|
111
|
+
({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,
|
|
112
|
+
)
|
|
113
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
|
114
|
+
// Keep the read registered until replay admission completes. A durable
|
|
115
|
+
// removal cannot land between filtering and this synchronous consumer.
|
|
116
|
+
return await consume(currentTransactions)
|
|
141
117
|
} finally {
|
|
142
118
|
this.activeReads.delete(read)
|
|
143
119
|
this.pruneRemovalHistory()
|
|
@@ -157,29 +133,21 @@ export class OutboxManager {
|
|
|
157
133
|
id: string,
|
|
158
134
|
updates: Partial<OfflineTransaction>,
|
|
159
135
|
): Promise<void> {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const updated = { ...existing, ...updates }
|
|
167
|
-
await this.add(updated)
|
|
168
|
-
})
|
|
136
|
+
const existing = await this.get(id)
|
|
137
|
+
if (!existing) {
|
|
138
|
+
throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)
|
|
139
|
+
}
|
|
140
|
+
await this.add({ ...existing, ...updates })
|
|
169
141
|
}
|
|
170
142
|
|
|
171
143
|
async remove(id: string): Promise<void> {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
this.recordRemovals([id])
|
|
176
|
-
})
|
|
144
|
+
const key = this.getStorageKey(id)
|
|
145
|
+
await this.storage.delete(key)
|
|
146
|
+
this.recordRemovals([id])
|
|
177
147
|
}
|
|
178
148
|
|
|
179
149
|
async removeMany(ids: Array<string>): Promise<void> {
|
|
180
|
-
|
|
181
|
-
await Promise.all(ids.map((id) => this.remove(id)))
|
|
182
|
-
})
|
|
150
|
+
await Promise.all(ids.map((id) => this.remove(id)))
|
|
183
151
|
}
|
|
184
152
|
|
|
185
153
|
async clear(): Promise<void> {
|
package/src/types.ts
CHANGED
|
@@ -101,6 +101,16 @@ export interface OfflineConfig {
|
|
|
101
101
|
storage?: StorageAdapter
|
|
102
102
|
maxConcurrency?: number
|
|
103
103
|
jitter?: boolean
|
|
104
|
+
/**
|
|
105
|
+
* Override retry decisions for errors from all named mutation functions.
|
|
106
|
+
* Return synchronously; undefined uses the default decision. Put any
|
|
107
|
+
* function-specific context needed by this shared hook on the Error.
|
|
108
|
+
* NonRetriableError always stops retry.
|
|
109
|
+
* A throw or invalid return fails only that outbox row and rejects its
|
|
110
|
+
* waiters with the hook failure. Queued work runs after terminal cleanup.
|
|
111
|
+
* The default policy still supplies the delay and configured jitter.
|
|
112
|
+
*/
|
|
113
|
+
shouldRetry?: (error: Error, retryCount: number) => boolean | undefined
|
|
104
114
|
beforeRetry?: (
|
|
105
115
|
transactions: Array<OfflineTransaction>,
|
|
106
116
|
) => Array<OfflineTransaction>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { NonRetriableError } from '../types.cjs';
|