@tanstack/offline-transactions 1.0.56 → 1.0.59
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 +157 -23
- package/dist/cjs/OfflineExecutor.cjs +6 -3
- package/dist/cjs/OfflineExecutor.cjs.map +1 -1
- package/dist/cjs/connectivity/OnlineDetector.cjs +1 -1
- package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -1
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +1 -7
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
- package/dist/cjs/executor/KeyScheduler.cjs +24 -25
- package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
- package/dist/cjs/executor/KeyScheduler.d.cts +4 -2
- package/dist/cjs/executor/TransactionExecutor.cjs +134 -35
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
- package/dist/cjs/executor/TransactionExecutor.d.cts +4 -0
- package/dist/cjs/outbox/OutboxManager.cjs +70 -25
- package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
- package/dist/cjs/outbox/OutboxManager.d.cts +8 -0
- package/dist/cjs/outbox/TransactionSerializer.cjs +140 -22
- package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
- package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
- 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 +5 -1
- package/dist/esm/OfflineExecutor.js +6 -3
- package/dist/esm/OfflineExecutor.js.map +1 -1
- package/dist/esm/connectivity/OnlineDetector.js +1 -1
- package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
- package/dist/esm/executor/KeyScheduler.d.ts +4 -2
- package/dist/esm/executor/KeyScheduler.js +24 -25
- package/dist/esm/executor/KeyScheduler.js.map +1 -1
- package/dist/esm/executor/TransactionExecutor.d.ts +4 -0
- package/dist/esm/executor/TransactionExecutor.js +134 -35
- package/dist/esm/executor/TransactionExecutor.js.map +1 -1
- package/dist/esm/outbox/OutboxManager.d.ts +8 -0
- package/dist/esm/outbox/OutboxManager.js +72 -27
- package/dist/esm/outbox/OutboxManager.js.map +1 -1
- package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
- package/dist/esm/outbox/TransactionSerializer.js +140 -22
- 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 +5 -1
- package/dist/esm/types.js.map +1 -1
- package/package.json +2 -2
- package/src/OfflineExecutor.ts +6 -4
- package/src/connectivity/OnlineDetector.ts +6 -1
- package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
- package/src/executor/KeyScheduler.ts +29 -26
- package/src/executor/TransactionExecutor.ts +164 -50
- package/src/outbox/OutboxManager.ts +85 -29
- package/src/outbox/TransactionSerializer.ts +209 -26
- package/src/storage/IndexedDBAdapter.ts +1 -0
- package/src/storage/LocalStorageAdapter.ts +1 -1
- package/src/types.ts +5 -1
|
@@ -3,7 +3,7 @@ import type { OfflineTransaction } from '../types'
|
|
|
3
3
|
|
|
4
4
|
export class KeyScheduler {
|
|
5
5
|
private pendingTransactions: Array<OfflineTransaction> = []
|
|
6
|
-
private
|
|
6
|
+
private activeTransactionId: string | undefined
|
|
7
7
|
|
|
8
8
|
schedule(transaction: OfflineTransaction): boolean {
|
|
9
9
|
return withSyncSpan(
|
|
@@ -35,7 +35,10 @@ export class KeyScheduler {
|
|
|
35
35
|
`scheduler.getNext`,
|
|
36
36
|
{ pendingCount: this.pendingTransactions.length },
|
|
37
37
|
(span) => {
|
|
38
|
-
if (
|
|
38
|
+
if (
|
|
39
|
+
this.activeTransactionId !== undefined ||
|
|
40
|
+
this.pendingTransactions.length === 0
|
|
41
|
+
) {
|
|
39
42
|
span.setAttribute(`result`, `empty`)
|
|
40
43
|
return undefined
|
|
41
44
|
}
|
|
@@ -59,17 +62,17 @@ export class KeyScheduler {
|
|
|
59
62
|
return Date.now() >= transaction.nextAttemptAt
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
markStarted(
|
|
63
|
-
this.
|
|
65
|
+
markStarted(transaction: OfflineTransaction): void {
|
|
66
|
+
this.activeTransactionId = transaction.id
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
markCompleted(transaction: OfflineTransaction): void {
|
|
67
70
|
this.removeTransaction(transaction)
|
|
68
|
-
this.
|
|
71
|
+
this.activeTransactionId = undefined
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
markFailed(_transaction: OfflineTransaction): void {
|
|
72
|
-
this.
|
|
75
|
+
this.activeTransactionId = undefined
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
private removeTransaction(transaction: OfflineTransaction): void {
|
|
@@ -82,16 +85,7 @@ export class KeyScheduler {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
updateTransaction(transaction: OfflineTransaction): void {
|
|
85
|
-
|
|
86
|
-
(tx) => tx.id === transaction.id,
|
|
87
|
-
)
|
|
88
|
-
if (index >= 0) {
|
|
89
|
-
this.pendingTransactions[index] = transaction
|
|
90
|
-
// Re-sort to maintain FIFO order after update
|
|
91
|
-
this.pendingTransactions.sort(
|
|
92
|
-
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
93
|
-
)
|
|
94
|
-
}
|
|
88
|
+
this.updateTransactions([transaction])
|
|
95
89
|
}
|
|
96
90
|
|
|
97
91
|
getPendingCount(): number {
|
|
@@ -99,12 +93,23 @@ export class KeyScheduler {
|
|
|
99
93
|
}
|
|
100
94
|
|
|
101
95
|
getRunningCount(): number {
|
|
102
|
-
return this.
|
|
96
|
+
return this.activeTransactionId === undefined ? 0 : 1
|
|
103
97
|
}
|
|
104
98
|
|
|
105
99
|
clear(): void {
|
|
106
100
|
this.pendingTransactions = []
|
|
107
|
-
this.
|
|
101
|
+
this.activeTransactionId = undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @internal Reconcile one replay snapshot without canceling issued work. */
|
|
105
|
+
removePendingTransactions(transactionIds: Iterable<string>): Array<string> {
|
|
106
|
+
const ids = new Set(transactionIds)
|
|
107
|
+
if (this.activeTransactionId !== undefined)
|
|
108
|
+
ids.delete(this.activeTransactionId)
|
|
109
|
+
this.pendingTransactions = this.pendingTransactions.filter(
|
|
110
|
+
({ id }) => !ids.has(id),
|
|
111
|
+
)
|
|
112
|
+
return [...ids]
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
getAllPendingTransactions(): Array<OfflineTransaction> {
|
|
@@ -112,14 +117,12 @@ export class KeyScheduler {
|
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
updateTransactions(updatedTransactions: Array<OfflineTransaction>): void {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
}
|
|
120
|
+
const updatedById = new Map(
|
|
121
|
+
updatedTransactions.map((transaction) => [transaction.id, transaction]),
|
|
122
|
+
)
|
|
123
|
+
this.pendingTransactions = this.pendingTransactions.map(
|
|
124
|
+
(transaction) => updatedById.get(transaction.id) ?? transaction,
|
|
125
|
+
)
|
|
123
126
|
// Re-sort to maintain FIFO order after updates
|
|
124
127
|
this.pendingTransactions.sort(
|
|
125
128
|
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
|
@@ -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
|
}
|
|
@@ -56,6 +60,7 @@ export class TransactionExecutor {
|
|
|
56
60
|
} finally {
|
|
57
61
|
this.isExecuting = false
|
|
58
62
|
this.executionPromise = null
|
|
63
|
+
this.scheduleNextRetry()
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
|
|
@@ -73,9 +78,6 @@ export class TransactionExecutor {
|
|
|
73
78
|
|
|
74
79
|
await this.executeTransaction(transaction)
|
|
75
80
|
}
|
|
76
|
-
|
|
77
|
-
// Schedule next retry after execution completes
|
|
78
|
-
this.scheduleNextRetry()
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
private async executeTransaction(
|
|
@@ -91,33 +93,61 @@ export class TransactionExecutor {
|
|
|
91
93
|
'transaction.keyCount': transaction.keys.length,
|
|
92
94
|
},
|
|
93
95
|
async (span) => {
|
|
96
|
+
const terminalError =
|
|
97
|
+
transaction.outboxPhase === `rejection-pending`
|
|
98
|
+
? this.getTerminalError(transaction)
|
|
99
|
+
: undefined
|
|
94
100
|
this.scheduler.markStarted(transaction)
|
|
95
101
|
|
|
96
102
|
if (transaction.retryCount > 0) {
|
|
97
103
|
span.setAttribute(`retry.attempt`, transaction.retryCount)
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
const result = await this.runMutationFn(transaction)
|
|
102
|
-
|
|
106
|
+
if (!transaction.outboxPhase) {
|
|
103
107
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
await this.runMutationFn(transaction)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const err =
|
|
111
|
+
error instanceof Error ? error : new Error(String(error))
|
|
112
|
+
|
|
113
|
+
span.setAttribute(`result`, `error`)
|
|
114
|
+
|
|
115
|
+
await this.handleError(transaction, err)
|
|
116
|
+
;(err as any)[HANDLED_EXECUTION_ERROR] = true
|
|
117
|
+
throw err
|
|
108
118
|
}
|
|
119
|
+
}
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
|
|
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` }
|
|
126
|
+
try {
|
|
127
|
+
await this.removeSettledTransaction(
|
|
128
|
+
deletionPending,
|
|
129
|
+
!transaction.outboxPhase,
|
|
130
|
+
)
|
|
112
131
|
} catch (error) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
140
|
+
}
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)
|
|
121
151
|
}
|
|
122
152
|
},
|
|
123
153
|
)
|
|
@@ -180,15 +210,29 @@ export class TransactionExecutor {
|
|
|
180
210
|
span.setAttribute(`shouldRetry`, shouldRetry)
|
|
181
211
|
|
|
182
212
|
if (!shouldRetry) {
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|
|
185
222
|
console.warn(
|
|
186
223
|
`Transaction ${transaction.id} failed permanently:`,
|
|
187
224
|
error,
|
|
188
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
|
+
}
|
|
189
234
|
|
|
190
235
|
span.setAttribute(`result`, `permanent_failure`)
|
|
191
|
-
// Signal permanent failure to the waiting transaction
|
|
192
236
|
this.offlineExecutor.rejectTransaction(transaction.id, error)
|
|
193
237
|
return
|
|
194
238
|
}
|
|
@@ -211,7 +255,6 @@ export class TransactionExecutor {
|
|
|
211
255
|
span.setAttribute(`retryDelay`, delay)
|
|
212
256
|
span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
|
|
213
257
|
|
|
214
|
-
this.scheduler.markFailed(transaction)
|
|
215
258
|
this.scheduler.updateTransaction(updatedTransaction)
|
|
216
259
|
|
|
217
260
|
try {
|
|
@@ -221,45 +264,116 @@ export class TransactionExecutor {
|
|
|
221
264
|
span.recordException(persistError as Error)
|
|
222
265
|
span.setAttribute(`result`, `persist_failed`)
|
|
223
266
|
throw persistError
|
|
267
|
+
} finally {
|
|
268
|
+
this.scheduler.markFailed(transaction)
|
|
224
269
|
}
|
|
225
|
-
|
|
226
|
-
// Schedule retry timer
|
|
227
|
-
this.scheduleNextRetry()
|
|
228
270
|
},
|
|
229
271
|
)
|
|
230
272
|
}
|
|
231
273
|
|
|
232
|
-
async
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
238
296
|
}
|
|
297
|
+
}
|
|
239
298
|
|
|
240
|
-
|
|
241
|
-
if (
|
|
299
|
+
assertHealthy(): void {
|
|
300
|
+
if (this.fatalError) throw this.fatalError
|
|
301
|
+
}
|
|
242
302
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
+
|
|
316
|
+
async loadPendingTransactions(): Promise<void> {
|
|
317
|
+
let removedIds: Array<string> = []
|
|
318
|
+
await this.outbox.withAll((transactions) => {
|
|
319
|
+
const { isOfflineEnabled } = this.offlineExecutor
|
|
320
|
+
if (!isOfflineEnabled) return
|
|
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
|
+
})
|
|
246
333
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
this.restoreOptimisticState(newlyLoaded)
|
|
334
|
+
// The retry hook is user code and may synchronously revoke replay rights.
|
|
335
|
+
if (!this.offlineExecutor.isOfflineEnabled) return
|
|
250
336
|
|
|
251
|
-
|
|
252
|
-
|
|
337
|
+
const newlyLoaded = filteredTransactions.filter((transaction) =>
|
|
338
|
+
this.scheduler.schedule(transaction),
|
|
339
|
+
)
|
|
253
340
|
|
|
254
|
-
|
|
255
|
-
|
|
341
|
+
removedIds = providerPending
|
|
342
|
+
.filter(({ id }) => !selectedById.has(id))
|
|
343
|
+
.map(({ id }) => id)
|
|
344
|
+
removedIds = this.scheduler.removePendingTransactions(removedIds)
|
|
345
|
+
|
|
346
|
+
// Restore optimistic state for loaded transactions
|
|
347
|
+
// This ensures the UI shows the optimistic data while transactions are pending
|
|
348
|
+
this.restoreOptimisticState(
|
|
349
|
+
newlyLoaded.filter(
|
|
350
|
+
(transaction) => transaction.outboxPhase !== `rejection-pending`,
|
|
351
|
+
),
|
|
352
|
+
)
|
|
256
353
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
354
|
+
// Reset retry delays for all loaded transactions so they can run immediately
|
|
355
|
+
this.resetRetryDelays()
|
|
260
356
|
|
|
261
|
-
|
|
262
|
-
|
|
357
|
+
// Schedule retry timer for loaded transactions
|
|
358
|
+
this.scheduleNextRetry()
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
if (removedIds.length > 0) {
|
|
362
|
+
const error = new NonRetriableError(`Transaction excluded by beforeRetry`)
|
|
363
|
+
await Promise.all(
|
|
364
|
+
removedIds.map(async (id) => {
|
|
365
|
+
try {
|
|
366
|
+
await this.outbox.remove(id)
|
|
367
|
+
this.offlineExecutor.rejectTransaction(id, error)
|
|
368
|
+
} catch (cleanupError) {
|
|
369
|
+
console.warn(
|
|
370
|
+
`Failed to remove transaction excluded by beforeRetry:`,
|
|
371
|
+
id,
|
|
372
|
+
cleanupError,
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
263
377
|
}
|
|
264
378
|
}
|
|
265
379
|
|
|
@@ -346,7 +460,7 @@ export class TransactionExecutor {
|
|
|
346
460
|
// Clear existing timer
|
|
347
461
|
this.clearRetryTimer()
|
|
348
462
|
|
|
349
|
-
if (!this.canExecute()) {
|
|
463
|
+
if (this.fatalError || !this.canExecute()) {
|
|
350
464
|
return
|
|
351
465
|
}
|
|
352
466
|
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { withSpan } from '../telemetry/tracer'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MissingTemporalConstructorError,
|
|
4
|
+
TransactionSerializer,
|
|
5
|
+
} from './TransactionSerializer'
|
|
3
6
|
import type { OfflineTransaction, StorageAdapter } from '../types'
|
|
4
7
|
import type { Collection } from '@tanstack/db'
|
|
5
8
|
|
|
9
|
+
export class OutboxTransactionNotFoundError extends Error {}
|
|
10
|
+
|
|
6
11
|
export class OutboxManager {
|
|
7
12
|
private storage: StorageAdapter
|
|
8
13
|
private serializer: TransactionSerializer
|
|
9
14
|
private keyPrefix = `tx:`
|
|
15
|
+
private removalRevision = 0
|
|
16
|
+
private activeReads = new Set<{ revision: number }>()
|
|
17
|
+
private removedAtRevision = new Map<string, number>()
|
|
10
18
|
|
|
11
19
|
constructor(
|
|
12
20
|
storage: StorageAdapter,
|
|
@@ -21,6 +29,25 @@ export class OutboxManager {
|
|
|
21
29
|
return `${this.keyPrefix}${id}`
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
private recordRemovals(ids: Iterable<string>): void {
|
|
33
|
+
this.removalRevision++
|
|
34
|
+
if (this.activeReads.size === 0) return
|
|
35
|
+
for (const id of ids) this.removedAtRevision.set(id, this.removalRevision)
|
|
36
|
+
this.pruneRemovalHistory()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private pruneRemovalHistory(): void {
|
|
40
|
+
if (this.activeReads.size === 0) {
|
|
41
|
+
this.removedAtRevision.clear()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
let oldestRead = Number.POSITIVE_INFINITY
|
|
45
|
+
for (const read of this.activeReads)
|
|
46
|
+
oldestRead = Math.min(oldestRead, read.revision)
|
|
47
|
+
for (const [id, revision] of this.removedAtRevision)
|
|
48
|
+
if (revision <= oldestRead) this.removedAtRevision.delete(id)
|
|
49
|
+
}
|
|
50
|
+
|
|
24
51
|
async add(transaction: OfflineTransaction): Promise<void> {
|
|
25
52
|
return withSpan(
|
|
26
53
|
`outbox.add`,
|
|
@@ -52,6 +79,10 @@ export class OutboxManager {
|
|
|
52
79
|
span.setAttribute(`result`, `found`)
|
|
53
80
|
return transaction
|
|
54
81
|
} catch (error) {
|
|
82
|
+
if (error instanceof MissingTemporalConstructorError) {
|
|
83
|
+
error.message = `transaction ${id}: ${error.message}`
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
55
86
|
console.warn(`Failed to deserialize transaction ${id}:`, error)
|
|
56
87
|
span.setAttribute(`result`, `deserialize_error`)
|
|
57
88
|
return null
|
|
@@ -60,35 +91,57 @@ export class OutboxManager {
|
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
async getAll(): Promise<Array<OfflineTransaction>> {
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
return this.withAll((transactions) => transactions)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async withAll<T>(
|
|
98
|
+
consume: (transactions: Array<OfflineTransaction>) => T,
|
|
99
|
+
): Promise<T> {
|
|
100
|
+
const read = { revision: this.removalRevision }
|
|
101
|
+
this.activeReads.add(read)
|
|
102
|
+
try {
|
|
103
|
+
return await withSpan(`outbox.getAll`, {}, async (span) => {
|
|
104
|
+
const keys = await this.storage.keys()
|
|
105
|
+
const transactionKeys = keys.filter((key) =>
|
|
106
|
+
key.startsWith(this.keyPrefix),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
span.setAttribute(`transactionCount`, transactionKeys.length)
|
|
110
|
+
|
|
111
|
+
const transactions: Array<OfflineTransaction> = []
|
|
112
|
+
|
|
113
|
+
for (const key of transactionKeys) {
|
|
114
|
+
const data = await this.storage.get(key)
|
|
115
|
+
if (data) {
|
|
116
|
+
try {
|
|
117
|
+
const transaction = this.serializer.deserialize(data)
|
|
118
|
+
transactions.push(transaction)
|
|
119
|
+
} catch (error) {
|
|
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
|
+
)
|
|
128
|
+
}
|
|
84
129
|
}
|
|
85
130
|
}
|
|
86
|
-
}
|
|
87
131
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
132
|
+
const currentTransactions = transactions
|
|
133
|
+
.filter(
|
|
134
|
+
({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,
|
|
135
|
+
)
|
|
136
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
|
137
|
+
// Keep the read registered until replay admission completes. A durable
|
|
138
|
+
// removal cannot land between filtering and this synchronous consumer.
|
|
139
|
+
return consume(currentTransactions)
|
|
140
|
+
})
|
|
141
|
+
} finally {
|
|
142
|
+
this.activeReads.delete(read)
|
|
143
|
+
this.pruneRemovalHistory()
|
|
144
|
+
}
|
|
92
145
|
}
|
|
93
146
|
|
|
94
147
|
async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> {
|
|
@@ -107,7 +160,7 @@ export class OutboxManager {
|
|
|
107
160
|
return withSpan(`outbox.update`, { 'transaction.id': id }, async () => {
|
|
108
161
|
const existing = await this.get(id)
|
|
109
162
|
if (!existing) {
|
|
110
|
-
throw new
|
|
163
|
+
throw new OutboxTransactionNotFoundError(`Transaction ${id} not found`)
|
|
111
164
|
}
|
|
112
165
|
|
|
113
166
|
const updated = { ...existing, ...updates }
|
|
@@ -119,6 +172,7 @@ export class OutboxManager {
|
|
|
119
172
|
return withSpan(`outbox.remove`, { 'transaction.id': id }, async () => {
|
|
120
173
|
const key = this.getStorageKey(id)
|
|
121
174
|
await this.storage.delete(key)
|
|
175
|
+
this.recordRemovals([id])
|
|
122
176
|
})
|
|
123
177
|
}
|
|
124
178
|
|
|
@@ -132,7 +186,9 @@ export class OutboxManager {
|
|
|
132
186
|
const keys = await this.storage.keys()
|
|
133
187
|
const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix))
|
|
134
188
|
|
|
135
|
-
await
|
|
189
|
+
await this.removeMany(
|
|
190
|
+
transactionKeys.map((key) => key.slice(this.keyPrefix.length)),
|
|
191
|
+
)
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
async count(): Promise<number> {
|