@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
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 /** 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":"
|
|
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 /**\n * Override retry decisions for errors from all named mutation functions.\n * Return synchronously; undefined uses the default decision. Put any\n * function-specific context needed by this shared hook on the Error.\n * NonRetriableError always stops retry.\n * A throw or invalid return fails only that outbox row and rejects its\n * waiters with the hook failure. Queued work runs after terminal cleanup.\n * The default policy still supplies the delay and configured jitter.\n */\n shouldRetry?: (error: Error, retryCount: number) => boolean | undefined\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":"AAmLO,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.
|
|
3
|
+
"version": "1.1.1",
|
|
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.
|
|
55
|
+
"@tanstack/db": "0.14.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@react-native-community/netinfo": ">=11.0.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
|
@@ -26,7 +26,6 @@ import { createOfflineAction } from './api/OfflineAction'
|
|
|
26
26
|
// TanStack DB primitives
|
|
27
27
|
|
|
28
28
|
// Replay
|
|
29
|
-
import { withNestedSpan, withSpan } from './telemetry/tracer'
|
|
30
29
|
import { NonRetriableError } from './types'
|
|
31
30
|
import type {
|
|
32
31
|
CreateOfflineActionOptions,
|
|
@@ -41,6 +40,9 @@ import type {
|
|
|
41
40
|
} from './types'
|
|
42
41
|
import type { Transaction } from '@tanstack/db'
|
|
43
42
|
|
|
43
|
+
/** How many settled ids an executor remembers to reject late waiters. */
|
|
44
|
+
const SETTLED_TRANSACTIONS_REMEMBERED = 1000
|
|
45
|
+
|
|
44
46
|
export class OfflineExecutor {
|
|
45
47
|
private config: OfflineConfig
|
|
46
48
|
|
|
@@ -75,6 +77,12 @@ export class OfflineExecutor {
|
|
|
75
77
|
}
|
|
76
78
|
> = new Map()
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Ids this executor settled, oldest first. A waiter for one of them has
|
|
82
|
+
* nothing left to wait for. An unknown id can still arrive from another tab.
|
|
83
|
+
*/
|
|
84
|
+
private readonly settledTransactions = new Set<string>()
|
|
85
|
+
|
|
78
86
|
// Track restoration transactions for cleanup when offline transactions complete
|
|
79
87
|
private restorationTransactions: Map<string, Transaction> = new Map()
|
|
80
88
|
|
|
@@ -268,77 +276,66 @@ export class OfflineExecutor {
|
|
|
268
276
|
}
|
|
269
277
|
|
|
270
278
|
private async initialize(): Promise<void> {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Cast to writable to set readonly properties
|
|
281
|
-
;(this as any).storage = storage
|
|
282
|
-
;(this as any).storageDiagnostic = diagnostic
|
|
283
|
-
;(this as any).mode = diagnostic.mode
|
|
279
|
+
try {
|
|
280
|
+
// Probe storage and create adapter
|
|
281
|
+
const { storage, diagnostic } = await this.createStorage()
|
|
282
|
+
if (this.disposed) {
|
|
283
|
+
this.initResolve()
|
|
284
|
+
return
|
|
285
|
+
}
|
|
284
286
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
+
// Cast to writable to set readonly properties
|
|
288
|
+
;(this as any).storage = storage
|
|
289
|
+
;(this as any).storageDiagnostic = diagnostic
|
|
290
|
+
;(this as any).mode = diagnostic.mode
|
|
287
291
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
span.setAttribute(`result`, `online-only`)
|
|
294
|
-
this.initResolve()
|
|
295
|
-
return
|
|
292
|
+
if (!storage) {
|
|
293
|
+
// Online-only mode - notify callback and skip offline setup
|
|
294
|
+
if (this.config.onStorageFailure) {
|
|
295
|
+
this.config.onStorageFailure(diagnostic)
|
|
296
296
|
}
|
|
297
|
+
this.initResolve()
|
|
298
|
+
return
|
|
299
|
+
}
|
|
297
300
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// Request leadership first
|
|
309
|
-
const isLeader = await this.leaderElection.requestLeadership()
|
|
310
|
-
// Disposal may have run while leadership was pending.
|
|
311
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
312
|
-
if (this.disposed) {
|
|
313
|
-
this.leaderElection.releaseLeadership()
|
|
314
|
-
this.initResolve()
|
|
315
|
-
return
|
|
316
|
-
}
|
|
317
|
-
this.isLeaderState = isLeader
|
|
318
|
-
span.setAttribute(`isLeader`, isLeader)
|
|
301
|
+
// Storage available - set up offline components
|
|
302
|
+
this.outbox = new OutboxManager(storage, this.config.collections)
|
|
303
|
+
this.executor = new TransactionExecutor(
|
|
304
|
+
this.scheduler,
|
|
305
|
+
this.outbox,
|
|
306
|
+
this.config,
|
|
307
|
+
this,
|
|
308
|
+
)
|
|
309
|
+
this.leaderElection = this.createLeaderElection()
|
|
319
310
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
311
|
+
// Request leadership first
|
|
312
|
+
const isLeader = await this.leaderElection.requestLeadership()
|
|
313
|
+
// Disposal may have run while leadership was pending.
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
315
|
+
if (this.disposed) {
|
|
316
|
+
this.leaderElection.releaseLeadership()
|
|
317
|
+
this.initResolve()
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
this.isLeaderState = isLeader
|
|
323
321
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
322
|
+
// Set up event listeners after leadership is established
|
|
323
|
+
// This prevents the callback from being called multiple times
|
|
324
|
+
this.setupEventListeners()
|
|
328
325
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
span.setAttribute(`result`, `offline-enabled`)
|
|
333
|
-
this.initResolve()
|
|
334
|
-
} catch (error) {
|
|
335
|
-
console.warn(`Failed to initialize offline executor:`, error)
|
|
336
|
-
span.setAttribute(`result`, `failed`)
|
|
337
|
-
this.initReject(
|
|
338
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
339
|
-
)
|
|
326
|
+
// Notify initial leadership state
|
|
327
|
+
if (this.config.onLeadershipChange) {
|
|
328
|
+
this.config.onLeadershipChange(isLeader)
|
|
340
329
|
}
|
|
341
|
-
|
|
330
|
+
|
|
331
|
+
if (isLeader) {
|
|
332
|
+
await this.loadAndReplayTransactions()
|
|
333
|
+
}
|
|
334
|
+
this.initResolve()
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.warn(`Failed to initialize offline executor:`, error)
|
|
337
|
+
this.initReject(error instanceof Error ? error : new Error(String(error)))
|
|
338
|
+
}
|
|
342
339
|
}
|
|
343
340
|
|
|
344
341
|
private async loadAndReplayTransactions(): Promise<void> {
|
|
@@ -441,37 +438,33 @@ export class OfflineExecutor {
|
|
|
441
438
|
// Wait for initialization to complete
|
|
442
439
|
await this.initPromise
|
|
443
440
|
|
|
444
|
-
|
|
445
|
-
`
|
|
446
|
-
|
|
447
|
-
'transaction.id': transaction.id,
|
|
448
|
-
'transaction.mutationFnName': transaction.mutationFnName,
|
|
449
|
-
},
|
|
450
|
-
async (span) => {
|
|
451
|
-
if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
|
|
452
|
-
span.setAttribute(`result`, `rejected_not_admitted`)
|
|
453
|
-
throw new NonRetriableError(`Offline transaction was not admitted`)
|
|
454
|
-
}
|
|
441
|
+
if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
|
|
442
|
+
throw new NonRetriableError(`Offline transaction was not admitted`)
|
|
443
|
+
}
|
|
455
444
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
)
|
|
445
|
+
try {
|
|
446
|
+
this.executor.assertHealthy()
|
|
447
|
+
await this.outbox.add(transaction)
|
|
448
|
+
// A shared queue error belongs to its execution, not every caller
|
|
449
|
+
// that durably admitted a transaction. Per-ID signals settle callers.
|
|
450
|
+
this.executor.execute(transaction).catch((error) => {
|
|
451
|
+
console.warn(`Failed to execute transactions:`, error)
|
|
452
|
+
})
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.error(
|
|
455
|
+
`Failed to persist offline transaction ${transaction.id}:`,
|
|
456
|
+
error,
|
|
457
|
+
)
|
|
458
|
+
throw error
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private rememberSettled(transactionId: string): void {
|
|
463
|
+
this.settledTransactions.add(transactionId)
|
|
464
|
+
if (this.settledTransactions.size > SETTLED_TRANSACTIONS_REMEMBERED)
|
|
465
|
+
this.settledTransactions.delete(
|
|
466
|
+
this.settledTransactions.values().next().value!,
|
|
467
|
+
)
|
|
475
468
|
}
|
|
476
469
|
|
|
477
470
|
// Method for OfflineTransaction to wait for completion
|
|
@@ -480,6 +473,10 @@ export class OfflineExecutor {
|
|
|
480
473
|
if (existing) {
|
|
481
474
|
return existing.promise
|
|
482
475
|
}
|
|
476
|
+
if (this.settledTransactions.has(transactionId))
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Offline transaction ${transactionId} already settled; wait for it before it settles`,
|
|
479
|
+
)
|
|
483
480
|
|
|
484
481
|
const deferred: {
|
|
485
482
|
promise: Promise<any>
|
|
@@ -498,6 +495,7 @@ export class OfflineExecutor {
|
|
|
498
495
|
|
|
499
496
|
// Method for TransactionExecutor to signal completion
|
|
500
497
|
resolveTransaction(transactionId: string, result: any): void {
|
|
498
|
+
this.rememberSettled(transactionId)
|
|
501
499
|
const deferred = this.pendingTransactionPromises.get(transactionId)
|
|
502
500
|
if (deferred) {
|
|
503
501
|
deferred.resolve(result)
|
|
@@ -510,6 +508,7 @@ export class OfflineExecutor {
|
|
|
510
508
|
|
|
511
509
|
// Method for TransactionExecutor to signal failure
|
|
512
510
|
rejectTransaction(transactionId: string, error: Error): void {
|
|
511
|
+
this.rememberSettled(transactionId)
|
|
513
512
|
const deferred = this.pendingTransactionPromises.get(transactionId)
|
|
514
513
|
if (deferred) {
|
|
515
514
|
deferred.reject(error)
|
|
@@ -547,26 +546,11 @@ export class OfflineExecutor {
|
|
|
547
546
|
return
|
|
548
547
|
}
|
|
549
548
|
|
|
550
|
-
// Mark as completed
|
|
551
|
-
// The actual data will come from the sync.
|
|
549
|
+
// Mark as completed: every Collection that tracked it recomputes and
|
|
550
|
+
// releases it. The actual data will come from the sync.
|
|
552
551
|
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
|
-
}
|
|
552
|
+
restorationTx.isPersisted.resolve(restorationTx)
|
|
553
|
+
restorationTx.touchCollection()
|
|
570
554
|
}
|
|
571
555
|
|
|
572
556
|
async removeFromOutbox(id: string): Promise<void> {
|
package/src/api/OfflineAction.ts
CHANGED
|
@@ -22,44 +22,26 @@ export function createOfflineAction<T>(
|
|
|
22
22
|
executor: any,
|
|
23
23
|
): (variables: T) => Transaction {
|
|
24
24
|
const { mutationFnName, onMutate } = options
|
|
25
|
-
console.log(`createOfflineAction 2`, options)
|
|
26
25
|
|
|
27
26
|
return (variables: T): Transaction => {
|
|
28
27
|
const offlineTransaction = new OfflineTransaction(
|
|
29
28
|
{
|
|
30
29
|
mutationFnName,
|
|
31
|
-
|
|
30
|
+
// The transaction commits after onMutate and reports a failure
|
|
31
|
+
// through `when('settled')`.
|
|
32
|
+
autoCommit: true,
|
|
32
33
|
},
|
|
33
34
|
mutationFn,
|
|
34
35
|
persistTransaction,
|
|
35
36
|
executor,
|
|
36
37
|
)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
console.log(`mutate`)
|
|
39
|
+
return offlineTransaction.mutate(() => {
|
|
40
40
|
const maybePromise = onMutate(variables) as unknown
|
|
41
41
|
|
|
42
42
|
if (isPromiseLike(maybePromise)) {
|
|
43
43
|
throw new OnMutateMustBeSynchronousError()
|
|
44
44
|
}
|
|
45
45
|
})
|
|
46
|
-
|
|
47
|
-
// Immediately commit
|
|
48
|
-
const commitPromise = (async () => {
|
|
49
|
-
try {
|
|
50
|
-
await transaction.commit()
|
|
51
|
-
console.log(`offlineAction committed - success`)
|
|
52
|
-
} catch {
|
|
53
|
-
console.log(`offlineAction commit failed - error`)
|
|
54
|
-
}
|
|
55
|
-
})()
|
|
56
|
-
|
|
57
|
-
// Don't await - this is fire-and-forget for optimistic actions
|
|
58
|
-
// But catch to prevent unhandled rejection
|
|
59
|
-
commitPromise.catch(() => {
|
|
60
|
-
// Already handled in try/catch above
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
return transaction
|
|
64
46
|
}
|
|
65
47
|
}
|
|
@@ -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,63 +35,81 @@ export class OfflineTransaction {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
mutate(callback: () => void): Transaction {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
const completionPromise = this.executor.waitForTransactionCompletion(
|
|
65
|
+
this.offlineId,
|
|
66
|
+
)
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
108
|
|
|
81
109
|
this.transaction.mutate(() => {
|
|
82
110
|
callback()
|
|
83
111
|
})
|
|
84
112
|
|
|
85
|
-
if (this.autoCommit) {
|
|
86
|
-
// Auto-commit for direct OfflineTransaction usage
|
|
87
|
-
this.commit().catch((error) => {
|
|
88
|
-
console.error(`Auto-commit failed:`, error)
|
|
89
|
-
throw error
|
|
90
|
-
})
|
|
91
|
-
}
|
|
92
|
-
|
|
93
113
|
return this.transaction
|
|
94
114
|
}
|
|
95
115
|
|
|
@@ -98,18 +118,12 @@ export class OfflineTransaction {
|
|
|
98
118
|
throw new Error(`No mutations to commit. Call mutate() first.`)
|
|
99
119
|
}
|
|
100
120
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// Only rollback for NonRetriableError - other errors should allow retry
|
|
108
|
-
if (error instanceof NonRetriableError) {
|
|
109
|
-
this.transaction.rollback()
|
|
110
|
-
}
|
|
111
|
-
throw error
|
|
112
|
-
}
|
|
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
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
rollback(): void {
|