@tanstack/offline-transactions 1.0.65 → 1.1.0
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 +80 -1
- package/dist/cjs/OfflineExecutor.cjs +2 -13
- package/dist/cjs/OfflineExecutor.cjs.map +1 -1
- package/dist/cjs/api/OfflineTransaction.cjs +1 -1
- package/dist/cjs/api/OfflineTransaction.cjs.map +1 -1
- package/dist/cjs/executor/TransactionExecutor.cjs +71 -31
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
- package/dist/cjs/types.cjs.map +1 -1
- package/dist/cjs/types.d.cts +10 -0
- package/dist/esm/OfflineExecutor.js +2 -13
- package/dist/esm/OfflineExecutor.js.map +1 -1
- package/dist/esm/api/OfflineTransaction.js +1 -1
- package/dist/esm/api/OfflineTransaction.js.map +1 -1
- package/dist/esm/executor/TransactionExecutor.js +72 -32
- package/dist/esm/executor/TransactionExecutor.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 +4 -19
- package/src/api/OfflineTransaction.ts +3 -1
- package/src/executor/TransactionExecutor.ts +89 -39
- package/src/types.ts +10 -0
package/skills/offline/SKILL.md
CHANGED
|
@@ -165,6 +165,7 @@ interface OfflineConfig {
|
|
|
165
165
|
storage?: StorageAdapter // Custom storage (default: auto-detect)
|
|
166
166
|
maxConcurrency?: number // Parallel execution limit
|
|
167
167
|
jitter?: boolean // Add jitter to retry delays
|
|
168
|
+
shouldRetry?: (error: Error, retryCount: number) => boolean | undefined // Override retry decision
|
|
168
169
|
beforeRetry?: (txs) => txs // Transform/filter before retry
|
|
169
170
|
onUnknownMutationFn?: (name, tx) => void // Handle orphaned transactions
|
|
170
171
|
onLeadershipChange?: (isLeader) => void // Leadership state callback
|
|
@@ -206,7 +207,55 @@ const executor = startOfflineExecutor({
|
|
|
206
207
|
})
|
|
207
208
|
```
|
|
208
209
|
|
|
209
|
-
Throwing `NonRetriableError` stops retry and removes the transaction from the outbox. Use for permanent
|
|
210
|
+
Throwing `NonRetriableError` stops retry and removes the transaction from the outbox. Use it for failures known to be permanent under the server contract, such as validation errors or a 409 that means an unrecoverable duplicate. A recoverable 401 can instead reach `shouldRetry`.
|
|
211
|
+
|
|
212
|
+
### Retry decisions
|
|
213
|
+
|
|
214
|
+
Set `shouldRetry` to override the default decision for a named mutation function
|
|
215
|
+
error. The hook receives the named mutation function's `Error` and retry count
|
|
216
|
+
(`0` on the first failure). The same `Error` instance is passed through; a
|
|
217
|
+
non-`Error` rejection is converted to an `Error` and may lose custom fields.
|
|
218
|
+
Return `true` to retry, `false` to stop, or `undefined` to use the
|
|
219
|
+
default decision. For example, return `true` for a recoverable 401 and
|
|
220
|
+
`undefined` otherwise. `NonRetriableError` always stops without calling the
|
|
221
|
+
hook. The default backoff and configured jitter still determine retry timing.
|
|
222
|
+
The hook is synchronous and shared by all named mutation functions. An async
|
|
223
|
+
hook returns a Promise, which fails its outbox row. Put function-specific
|
|
224
|
+
context on the thrown error when retry rules differ between functions.
|
|
225
|
+
If the hook throws or returns another value, the executor records a terminal
|
|
226
|
+
rejection for that row, removes it from the outbox, and rejects its waiting
|
|
227
|
+
promises with the hook failure. Other queued rows continue after the failed
|
|
228
|
+
row's deletion is acknowledged. New commits may join the queue during cleanup,
|
|
229
|
+
but cannot run ahead of that deletion. A fresh executor does not replay the
|
|
230
|
+
failed row. The named mutation function error is logged; the hook failure is
|
|
231
|
+
the caller-facing and stored error. Stored errors retain `name`, `message`, and
|
|
232
|
+
`stack`, but a restart does not restore an arbitrary error subclass or its
|
|
233
|
+
custom fields.
|
|
234
|
+
If marker storage or deletion fails, the caller still receives the hook error
|
|
235
|
+
while the executor stops with the storage error and retains queued work. A
|
|
236
|
+
saved terminal marker skips the named mutation function after restart; an
|
|
237
|
+
unmarked row can replay.
|
|
238
|
+
To decide from an HTTP status, throw an error carrying `response.status` in
|
|
239
|
+
the named mutation function and inspect its numeric status in `shouldRetry`.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
class HttpError extends Error {
|
|
243
|
+
constructor(readonly status: number) {
|
|
244
|
+
super(`HTTP ${status}`)
|
|
245
|
+
this.name = 'HttpError'
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function throwOnHttpFailure(response: Response): void {
|
|
250
|
+
if (!response.ok) throw new HttpError(response.status)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const retryOnRecoverable401 = (error: Error): boolean | undefined =>
|
|
254
|
+
error instanceof HttpError && error.status === 401 ? true : undefined
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Call `throwOnHttpFailure(response)` in the named mutation function and pass
|
|
258
|
+
`shouldRetry: retryOnRecoverable401` in the executor config.
|
|
210
259
|
|
|
211
260
|
### Idempotency keys
|
|
212
261
|
|
|
@@ -339,18 +388,37 @@ mutationFns: {
|
|
|
339
388
|
Correct:
|
|
340
389
|
|
|
341
390
|
```ts
|
|
391
|
+
class HttpError extends Error {
|
|
392
|
+
constructor(readonly status: number) {
|
|
393
|
+
super(`HTTP ${status}`)
|
|
394
|
+
this.name = 'HttpError'
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
342
398
|
mutationFns: {
|
|
343
399
|
createTodo: async ({ transaction }) => {
|
|
344
400
|
const res = await fetch('/api/todos', { ... })
|
|
345
|
-
if (res.status
|
|
346
|
-
throw new NonRetriableError(
|
|
401
|
+
if (res.status === 422) {
|
|
402
|
+
throw new NonRetriableError('Invalid todo')
|
|
403
|
+
}
|
|
404
|
+
if (res.status === 409) {
|
|
405
|
+
throw new NonRetriableError('Duplicate detected')
|
|
406
|
+
}
|
|
407
|
+
// This todo API also treats these responses as permanent.
|
|
408
|
+
if ([404, 405, 410].includes(res.status)) {
|
|
409
|
+
throw new NonRetriableError(`Todo endpoint rejected: ${res.status}`)
|
|
347
410
|
}
|
|
348
|
-
if (!res.ok) throw new
|
|
411
|
+
if (!res.ok) throw new HttpError(res.status)
|
|
349
412
|
},
|
|
350
413
|
}
|
|
351
414
|
```
|
|
352
415
|
|
|
353
|
-
|
|
416
|
+
Classify failures according to whether the app can recover. The default policy
|
|
417
|
+
stops when an error message contains 400, 401, 403, or 422; it can retry other
|
|
418
|
+
errors indefinitely. This example marks 404, 405, and 410 as permanent for the
|
|
419
|
+
todo API while leaving 408 and 429 available for retry. Use `shouldRetry` when
|
|
420
|
+
a 401 can recover after credentials refresh, and use `NonRetriableError` for
|
|
421
|
+
known permanent failures.
|
|
354
422
|
|
|
355
423
|
See also: db-core/mutations-optimistic/SKILL.md — for the underlying mutation primitives.
|
|
356
424
|
|
package/src/OfflineExecutor.ts
CHANGED
|
@@ -547,26 +547,11 @@ export class OfflineExecutor {
|
|
|
547
547
|
return
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
-
// Mark as completed
|
|
551
|
-
// The actual data will come from the sync.
|
|
550
|
+
// Mark as completed: every Collection that tracked it recomputes and
|
|
551
|
+
// releases it. The actual data will come from the sync.
|
|
552
552
|
restorationTx.setState(`completed`)
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const touchedCollections = new Set<string>()
|
|
556
|
-
for (const mutation of restorationTx.mutations) {
|
|
557
|
-
// Defensive check for corrupted deserialized data
|
|
558
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
559
|
-
if (!mutation.collection) {
|
|
560
|
-
continue
|
|
561
|
-
}
|
|
562
|
-
const collectionId = mutation.collection.id
|
|
563
|
-
if (touchedCollections.has(collectionId)) {
|
|
564
|
-
continue
|
|
565
|
-
}
|
|
566
|
-
touchedCollections.add(collectionId)
|
|
567
|
-
mutation.collection._state.transactions.delete(restorationTx.id)
|
|
568
|
-
mutation.collection._state.recomputeOptimisticState(false)
|
|
569
|
-
}
|
|
553
|
+
restorationTx.isPersisted.resolve(restorationTx)
|
|
554
|
+
restorationTx.touchCollection()
|
|
570
555
|
}
|
|
571
556
|
|
|
572
557
|
async removeFromOutbox(id: string): Promise<void> {
|
|
@@ -33,7 +33,9 @@ export class OfflineTransaction {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
mutate(callback: () => void): Transaction {
|
|
36
|
-
|
|
36
|
+
// Repeated calls add to the same transaction: one offline transaction is
|
|
37
|
+
// one transaction, and its id is unique among live transactions.
|
|
38
|
+
this.transaction ??= createTransaction({
|
|
37
39
|
id: this.offlineId,
|
|
38
40
|
autoCommit: false,
|
|
39
41
|
mutationFn: async () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
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'
|
|
@@ -13,6 +13,13 @@ import type {
|
|
|
13
13
|
|
|
14
14
|
const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)
|
|
15
15
|
|
|
16
|
+
function toError(value: unknown): Error {
|
|
17
|
+
return value instanceof Error ||
|
|
18
|
+
Object.prototype.toString.call(value) === `[object Error]`
|
|
19
|
+
? (value as Error)
|
|
20
|
+
: new Error(String(value))
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export class TransactionExecutor {
|
|
17
24
|
private scheduler: KeyScheduler
|
|
18
25
|
private outbox: OutboxManager
|
|
@@ -107,8 +114,7 @@ export class TransactionExecutor {
|
|
|
107
114
|
try {
|
|
108
115
|
await this.runMutationFn(transaction)
|
|
109
116
|
} catch (error) {
|
|
110
|
-
const err =
|
|
111
|
-
error instanceof Error ? error : new Error(String(error))
|
|
117
|
+
const err = toError(error)
|
|
112
118
|
|
|
113
119
|
span.setAttribute(`result`, `error`)
|
|
114
120
|
|
|
@@ -153,8 +159,9 @@ export class TransactionExecutor {
|
|
|
153
159
|
)
|
|
154
160
|
} catch (error) {
|
|
155
161
|
if (
|
|
156
|
-
error
|
|
157
|
-
|
|
162
|
+
error !== null &&
|
|
163
|
+
typeof error === `object` &&
|
|
164
|
+
(error as Record<symbol, unknown>)[HANDLED_EXECUTION_ERROR] === true
|
|
158
165
|
) {
|
|
159
166
|
return
|
|
160
167
|
}
|
|
@@ -202,38 +209,77 @@ export class TransactionExecutor {
|
|
|
202
209
|
'error.message': error.message,
|
|
203
210
|
},
|
|
204
211
|
async (span) => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
let shouldRetry: boolean
|
|
213
|
+
let hookFailure: Error | undefined
|
|
214
|
+
if (error instanceof NonRetriableError) {
|
|
215
|
+
shouldRetry = false
|
|
216
|
+
} else {
|
|
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
|
+
}
|
|
209
238
|
|
|
210
239
|
span.setAttribute(`shouldRetry`, shouldRetry)
|
|
211
240
|
|
|
212
241
|
if (!shouldRetry) {
|
|
242
|
+
const terminalError = hookFailure ?? error
|
|
213
243
|
const rejectionPending: OfflineTransaction = {
|
|
214
244
|
...transaction,
|
|
215
245
|
outboxPhase: `rejection-pending`,
|
|
216
246
|
lastError: {
|
|
217
|
-
name:
|
|
218
|
-
message:
|
|
219
|
-
stack:
|
|
247
|
+
name: terminalError.name,
|
|
248
|
+
message: terminalError.message,
|
|
249
|
+
stack: terminalError.stack,
|
|
220
250
|
},
|
|
221
251
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|
|
226
266
|
try {
|
|
227
267
|
await this.removeSettledTransaction(rejectionPending, true)
|
|
228
268
|
} catch (storageError) {
|
|
229
269
|
span.recordException(storageError as Error)
|
|
230
270
|
span.setAttribute(`result`, `outbox_failure`)
|
|
231
|
-
this.offlineExecutor.rejectTransaction(
|
|
271
|
+
this.offlineExecutor.rejectTransaction(
|
|
272
|
+
transaction.id,
|
|
273
|
+
terminalError,
|
|
274
|
+
)
|
|
232
275
|
throw storageError
|
|
233
276
|
}
|
|
234
277
|
|
|
235
|
-
span.setAttribute(
|
|
236
|
-
|
|
278
|
+
span.setAttribute(
|
|
279
|
+
`result`,
|
|
280
|
+
hookFailure ? `retry_decision_failure` : `permanent_failure`,
|
|
281
|
+
)
|
|
282
|
+
this.offlineExecutor.rejectTransaction(transaction.id, terminalError)
|
|
237
283
|
return
|
|
238
284
|
}
|
|
239
285
|
|
|
@@ -261,6 +307,15 @@ export class TransactionExecutor {
|
|
|
261
307
|
await this.outbox.update(transaction.id, updatedTransaction)
|
|
262
308
|
span.setAttribute(`result`, `scheduled_retry`)
|
|
263
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
|
+
}
|
|
264
319
|
span.recordException(persistError as Error)
|
|
265
320
|
span.setAttribute(`result`, `persist_failed`)
|
|
266
321
|
throw persistError
|
|
@@ -287,8 +342,7 @@ export class TransactionExecutor {
|
|
|
287
342
|
await this.outbox.remove(transaction.id)
|
|
288
343
|
this.scheduler.markCompleted(transaction)
|
|
289
344
|
} catch (error) {
|
|
290
|
-
const storageError =
|
|
291
|
-
error instanceof Error ? error : new Error(String(error))
|
|
345
|
+
const storageError = toError(error)
|
|
292
346
|
this.fatalError = storageError
|
|
293
347
|
this.scheduler.markFailed(transaction)
|
|
294
348
|
this.clearRetryTimer()
|
|
@@ -408,25 +462,19 @@ export class TransactionExecutor {
|
|
|
408
462
|
|
|
409
463
|
restorationTx.applyMutations(offlineTx.mutations)
|
|
410
464
|
|
|
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
|
|
465
|
+
// Register with each affected Collection. If one cannot track it, roll
|
|
466
|
+
// it back, so no Collection keeps a restoration nothing will settle.
|
|
467
|
+
try {
|
|
468
|
+
// The serializer resolves every mutation's Collection or throws.
|
|
469
|
+
for (const mutation of offlineTx.mutations) {
|
|
470
|
+
if (mutation.collection._state.trackTransaction(restorationTx))
|
|
471
|
+
mutation.collection._state.recomputeOptimisticState(true)
|
|
422
472
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
)
|
|
429
|
-
mutation.collection._state.recomputeOptimisticState(true)
|
|
473
|
+
} catch (error) {
|
|
474
|
+
// A secondary rollback settles only this restoration; it must not
|
|
475
|
+
// roll back the user's live transactions on the same keys.
|
|
476
|
+
restorationTx.rollback({ isSecondaryRollback: true })
|
|
477
|
+
throw error
|
|
430
478
|
}
|
|
431
479
|
|
|
432
480
|
this.offlineExecutor.registerRestorationTransaction(
|
|
@@ -434,6 +482,8 @@ export class TransactionExecutor {
|
|
|
434
482
|
restorationTx,
|
|
435
483
|
)
|
|
436
484
|
} catch (error) {
|
|
485
|
+
// A live transaction with this id already shows these mutations.
|
|
486
|
+
if (error instanceof DuplicateTransactionIdError) continue
|
|
437
487
|
console.warn(
|
|
438
488
|
`Failed to restore optimistic state for transaction ${offlineTx.id}:`,
|
|
439
489
|
error,
|
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>
|