@tanstack/offline-transactions 1.0.54 → 1.0.56

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.
Files changed (44) hide show
  1. package/dist/cjs/OfflineExecutor.cjs +26 -11
  2. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  3. package/dist/cjs/OfflineExecutor.d.cts +1 -0
  4. package/dist/cjs/api/OfflineTransaction.cjs +4 -1
  5. package/dist/cjs/api/OfflineTransaction.cjs.map +1 -1
  6. package/dist/cjs/executor/KeyScheduler.cjs +7 -1
  7. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
  8. package/dist/cjs/executor/KeyScheduler.d.cts +1 -1
  9. package/dist/cjs/executor/TransactionExecutor.cjs +22 -15
  10. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  11. package/dist/cjs/executor/TransactionExecutor.d.cts +3 -2
  12. package/dist/cjs/outbox/TransactionSerializer.cjs +39 -10
  13. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
  14. package/dist/cjs/storage/IndexedDBAdapter.cjs +16 -12
  15. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
  16. package/dist/cjs/storage/IndexedDBAdapter.d.cts +1 -0
  17. package/dist/cjs/types.cjs.map +1 -1
  18. package/dist/cjs/types.d.cts +3 -0
  19. package/dist/esm/OfflineExecutor.d.ts +1 -0
  20. package/dist/esm/OfflineExecutor.js +26 -11
  21. package/dist/esm/OfflineExecutor.js.map +1 -1
  22. package/dist/esm/api/OfflineTransaction.js +4 -1
  23. package/dist/esm/api/OfflineTransaction.js.map +1 -1
  24. package/dist/esm/executor/KeyScheduler.d.ts +1 -1
  25. package/dist/esm/executor/KeyScheduler.js +7 -1
  26. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  27. package/dist/esm/executor/TransactionExecutor.d.ts +3 -2
  28. package/dist/esm/executor/TransactionExecutor.js +22 -15
  29. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  30. package/dist/esm/outbox/TransactionSerializer.js +39 -10
  31. package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
  32. package/dist/esm/storage/IndexedDBAdapter.d.ts +1 -0
  33. package/dist/esm/storage/IndexedDBAdapter.js +16 -12
  34. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
  35. package/dist/esm/types.d.ts +3 -0
  36. package/dist/esm/types.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/OfflineExecutor.ts +32 -14
  39. package/src/api/OfflineTransaction.ts +7 -2
  40. package/src/executor/KeyScheduler.ts +10 -2
  41. package/src/executor/TransactionExecutor.ts +30 -16
  42. package/src/outbox/TransactionSerializer.ts +59 -12
  43. package/src/storage/IndexedDBAdapter.ts +23 -12
  44. package/src/types.ts +3 -0
@@ -51,6 +51,7 @@ export class OfflineExecutor {
51
51
  private leaderElection: LeaderElection | null
52
52
  private onlineDetector: OnlineDetector
53
53
  private isLeaderState = false
54
+ private disposed = false
54
55
  private unsubscribeOnline: (() => void) | null = null
55
56
  private unsubscribeLeadership: (() => void) | null = null
56
57
 
@@ -209,14 +210,19 @@ export class OfflineExecutor {
209
210
  if (this.leaderElection) {
210
211
  this.unsubscribeLeadership = this.leaderElection.onLeadershipChange(
211
212
  (isLeader) => {
213
+ // A custom elector may repeat the initial result while replay is active.
214
+ if (this.disposed || isLeader === this.isLeaderState) return
212
215
  this.isLeaderState = isLeader
216
+ if (!isLeader) this.executor?.pause()
213
217
 
214
218
  if (this.config.onLeadershipChange) {
215
219
  this.config.onLeadershipChange(isLeader)
216
220
  }
217
221
 
218
222
  if (isLeader) {
219
- this.loadAndReplayTransactions()
223
+ this.loadAndReplayTransactions().catch((error) => {
224
+ console.warn(`Failed to load and replay transactions:`, error)
225
+ })
220
226
  }
221
227
  },
222
228
  )
@@ -264,6 +270,10 @@ export class OfflineExecutor {
264
270
  try {
265
271
  // Probe storage and create adapter
266
272
  const { storage, diagnostic } = await this.createStorage()
273
+ if (this.disposed) {
274
+ this.initResolve()
275
+ return
276
+ }
267
277
 
268
278
  // Cast to writable to set readonly properties
269
279
  ;(this as any).storage = storage
@@ -295,6 +305,13 @@ export class OfflineExecutor {
295
305
 
296
306
  // Request leadership first
297
307
  const isLeader = await this.leaderElection.requestLeadership()
308
+ // Disposal may have run while leadership was pending.
309
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
310
+ if (this.disposed) {
311
+ this.leaderElection.releaseLeadership()
312
+ this.initResolve()
313
+ return
314
+ }
298
315
  this.isLeaderState = isLeader
299
316
  span.setAttribute(`isLeader`, isLeader)
300
317
 
@@ -327,22 +344,17 @@ export class OfflineExecutor {
327
344
  return
328
345
  }
329
346
 
330
- try {
331
- // Load pending transactions and restore optimistic state
332
- await this.executor.loadPendingTransactions()
347
+ // Startup must observe a failed read instead of reporting an empty outbox.
348
+ await this.executor.loadPendingTransactions()
333
349
 
334
- // Start execution in the background - don't await to avoid blocking initialization
335
- // The transactions will execute and complete asynchronously
336
- this.executor.executeAll().catch((error) => {
337
- console.warn(`Failed to execute transactions:`, error)
338
- })
339
- } catch (error) {
340
- console.warn(`Failed to load and replay transactions:`, error)
341
- }
350
+ // Replay completion is separate from initialization and durable admission.
351
+ this.executor.executeAll().catch((error) => {
352
+ console.warn(`Failed to execute transactions:`, error)
353
+ })
342
354
  }
343
355
 
344
356
  get isOfflineEnabled(): boolean {
345
- return this.mode === `offline` && this.isLeaderState
357
+ return !this.disposed && this.mode === `offline` && this.isLeaderState
346
358
  }
347
359
 
348
360
  /**
@@ -442,7 +454,11 @@ export class OfflineExecutor {
442
454
 
443
455
  try {
444
456
  await this.outbox.add(transaction)
445
- await this.executor.execute(transaction)
457
+ // A shared queue error belongs to its execution, not every caller
458
+ // that durably admitted a transaction. Per-ID signals settle callers.
459
+ this.executor.execute(transaction).catch((error) => {
460
+ console.warn(`Failed to execute transactions:`, error)
461
+ })
446
462
  span.setAttribute(`result`, `persisted`)
447
463
  } catch (error) {
448
464
  console.error(
@@ -596,6 +612,8 @@ export class OfflineExecutor {
596
612
  }
597
613
 
598
614
  dispose(): void {
615
+ this.disposed = true
616
+ this.executor?.pause()
599
617
  for (const collection of Object.values(this.config.collections)) {
600
618
  collection.deferDataRefresh = null
601
619
  }
@@ -58,8 +58,13 @@ export class OfflineTransaction {
58
58
  )
59
59
 
60
60
  try {
61
- await this.persistTransaction(offlineTransaction)
62
- // Now block and wait for the executor to complete the real mutation
61
+ // Persistence also drives the shared queue. This transaction can finish
62
+ // before that queue drains; observe both promises from the outset.
63
+ await Promise.race([
64
+ this.persistTransaction(offlineTransaction),
65
+ completionPromise,
66
+ ])
67
+ // A queue pause (offline or retry) is not transaction completion.
63
68
  await completionPromise
64
69
  } catch (error) {
65
70
  const normalizedError =
@@ -5,19 +5,27 @@ export class KeyScheduler {
5
5
  private pendingTransactions: Array<OfflineTransaction> = []
6
6
  private isRunning = false
7
7
 
8
- schedule(transaction: OfflineTransaction): void {
9
- withSyncSpan(
8
+ schedule(transaction: OfflineTransaction): boolean {
9
+ return withSyncSpan(
10
10
  `scheduler.schedule`,
11
11
  {
12
12
  'transaction.id': transaction.id,
13
13
  queueLength: this.pendingTransactions.length,
14
14
  },
15
15
  () => {
16
+ if (
17
+ this.pendingTransactions.some(
18
+ (pending) => pending.id === transaction.id,
19
+ )
20
+ ) {
21
+ return false
22
+ }
16
23
  this.pendingTransactions.push(transaction)
17
24
  // Sort by creation time to maintain FIFO order
18
25
  this.pendingTransactions.sort(
19
26
  (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
20
27
  )
28
+ return true
21
29
  },
22
30
  )
23
31
  }
@@ -61,7 +61,7 @@ export class TransactionExecutor {
61
61
 
62
62
  private async runExecution(): Promise<void> {
63
63
  while (this.scheduler.getPendingCount() > 0) {
64
- if (!this.isOnline()) {
64
+ if (!this.canExecute()) {
65
65
  break
66
66
  }
67
67
 
@@ -100,8 +100,12 @@ export class TransactionExecutor {
100
100
  try {
101
101
  const result = await this.runMutationFn(transaction)
102
102
 
103
- this.scheduler.markCompleted(transaction)
104
- await this.outbox.remove(transaction.id)
103
+ try {
104
+ // Replay can still see this ID until durable deletion settles.
105
+ await this.outbox.remove(transaction.id)
106
+ } finally {
107
+ this.scheduler.markCompleted(transaction)
108
+ }
105
109
 
106
110
  span.setAttribute(`result`, `success`)
107
111
  this.offlineExecutor.resolveTransaction(transaction.id, result)
@@ -233,13 +237,16 @@ export class TransactionExecutor {
233
237
  filteredTransactions = this.config.beforeRetry(transactions)
234
238
  }
235
239
 
236
- for (const transaction of filteredTransactions) {
237
- this.scheduler.schedule(transaction)
238
- }
240
+ // The outbox read or retry hook may outlive this owner's right to replay.
241
+ if (!this.offlineExecutor.isOfflineEnabled) return
242
+
243
+ const newlyLoaded = filteredTransactions.filter((transaction) =>
244
+ this.scheduler.schedule(transaction),
245
+ )
239
246
 
240
247
  // Restore optimistic state for loaded transactions
241
248
  // This ensures the UI shows the optimistic data while transactions are pending
242
- this.restoreOptimisticState(filteredTransactions)
249
+ this.restoreOptimisticState(newlyLoaded)
243
250
 
244
251
  // Reset retry delays for all loaded transactions so they can run immediately
245
252
  this.resetRetryDelays()
@@ -326,6 +333,11 @@ export class TransactionExecutor {
326
333
  this.clearRetryTimer()
327
334
  }
328
335
 
336
+ pause(): void {
337
+ // Retain queued work and let the issued call finish its acknowledgment.
338
+ this.clearRetryTimer()
339
+ }
340
+
329
341
  getPendingCount(): number {
330
342
  return this.scheduler.getPendingCount()
331
343
  }
@@ -334,18 +346,17 @@ export class TransactionExecutor {
334
346
  // Clear existing timer
335
347
  this.clearRetryTimer()
336
348
 
337
- if (!this.isOnline()) {
349
+ if (!this.canExecute()) {
338
350
  return
339
351
  }
340
352
 
341
- // Find the earliest retry time among pending transactions
342
- const earliestRetryTime = this.getEarliestRetryTime()
353
+ const nextRetryTime = this.getNextRetryTime()
343
354
 
344
- if (earliestRetryTime === null) {
355
+ if (nextRetryTime === null) {
345
356
  return // No transactions pending retry
346
357
  }
347
358
 
348
- const delay = Math.max(0, earliestRetryTime - Date.now())
359
+ const delay = Math.max(0, nextRetryTime - Date.now())
349
360
 
350
361
  this.retryTimer = setTimeout(() => {
351
362
  this.executeAll().catch((error) => {
@@ -354,14 +365,15 @@ export class TransactionExecutor {
354
365
  }, delay)
355
366
  }
356
367
 
357
- private getEarliestRetryTime(): number | null {
368
+ private getNextRetryTime(): number | null {
358
369
  const allTransactions = this.scheduler.getAllPendingTransactions()
359
370
 
360
371
  if (allTransactions.length === 0) {
361
372
  return null
362
373
  }
363
374
 
364
- return Math.min(...allTransactions.map((tx) => tx.nextAttemptAt))
375
+ // Later transactions cannot overtake the FIFO head, even if they are ready.
376
+ return allTransactions[0]!.nextAttemptAt
365
377
  }
366
378
 
367
379
  private clearRetryTimer(): void {
@@ -371,8 +383,10 @@ export class TransactionExecutor {
371
383
  }
372
384
  }
373
385
 
374
- private isOnline(): boolean {
375
- return this.offlineExecutor.isOnline()
386
+ private canExecute(): boolean {
387
+ return (
388
+ this.offlineExecutor.isOfflineEnabled && this.offlineExecutor.isOnline()
389
+ )
376
390
  }
377
391
 
378
392
  getRunningCount(): number {
@@ -6,6 +6,21 @@ import type {
6
6
  } from '../types'
7
7
  import type { Collection, PendingMutation } from '@tanstack/db'
8
8
 
9
+ function setDataProperty(
10
+ object: Record<string, unknown>,
11
+ key: string,
12
+ value: unknown,
13
+ ): void {
14
+ if (key !== `__proto__`) object[key] = value
15
+ else
16
+ Object.defineProperty(object, key, {
17
+ value,
18
+ enumerable: true,
19
+ configurable: true,
20
+ writable: true,
21
+ })
22
+ }
23
+
9
24
  export class TransactionSerializer {
10
25
  private collections: Record<string, Collection<any, any, any, any, any>>
11
26
  private collectionIdToKey: Map<string, string>
@@ -24,6 +39,7 @@ export class TransactionSerializer {
24
39
  serialize(transaction: OfflineTransaction): string {
25
40
  const serialized: SerializedOfflineTransaction = {
26
41
  ...transaction,
42
+ valueEncoding: 2,
27
43
  createdAt: transaction.createdAt.toISOString(),
28
44
  mutations: transaction.mutations.map((mutation) =>
29
45
  this.serializeMutation(mutation),
@@ -33,9 +49,19 @@ export class TransactionSerializer {
33
49
  }
34
50
 
35
51
  deserialize(data: string): OfflineTransaction {
36
- // Parse without a reviver - let deserializeValue handle dates in mutation data
37
- // using the { __type: 'Date' } marker system
38
- const parsed: SerializedOfflineTransaction = JSON.parse(data)
52
+ // Old records retain their Date-marker meaning. New records also escape
53
+ // marker-shaped user objects; the encoding tag is not application metadata.
54
+ const {
55
+ valueEncoding,
56
+ ...parsed
57
+ }: Omit<SerializedOfflineTransaction, `valueEncoding`> & {
58
+ valueEncoding?: unknown
59
+ } = JSON.parse(data)
60
+ if (valueEncoding !== undefined && valueEncoding !== 2) {
61
+ throw new Error(
62
+ `Unsupported transaction value encoding: ${valueEncoding}`,
63
+ )
64
+ }
39
65
 
40
66
  const createdAt = new Date(parsed.createdAt)
41
67
  if (isNaN(createdAt.getTime())) {
@@ -48,7 +74,7 @@ export class TransactionSerializer {
48
74
  ...parsed,
49
75
  createdAt,
50
76
  mutations: parsed.mutations.map((mutationData) =>
51
- this.deserializeMutation(mutationData),
77
+ this.deserializeMutation(mutationData, valueEncoding === 2),
52
78
  ),
53
79
  }
54
80
  }
@@ -71,13 +97,16 @@ export class TransactionSerializer {
71
97
  }
72
98
  }
73
99
 
74
- private deserializeMutation(data: SerializedMutation): PendingMutation {
100
+ private deserializeMutation(
101
+ data: SerializedMutation,
102
+ escapedObjects: boolean,
103
+ ): PendingMutation {
75
104
  const collection = this.collections[data.collectionId]
76
105
  if (!collection) {
77
106
  throw new Error(`Collection with id ${data.collectionId} not found`)
78
107
  }
79
108
 
80
- const modified = this.deserializeValue(data.modified)
109
+ const modified = this.deserializeValue(data.modified, escapedObjects)
81
110
 
82
111
  // Extract the key from the modified data using the collection's getKey function
83
112
  // This is needed for optimistic state restoration to work correctly
@@ -89,8 +118,8 @@ export class TransactionSerializer {
89
118
  globalKey: data.globalKey,
90
119
  type: data.type as any,
91
120
  modified,
92
- original: this.deserializeValue(data.original),
93
- changes: this.deserializeValue(data.changes) ?? {},
121
+ original: this.deserializeValue(data.original, escapedObjects),
122
+ changes: this.deserializeValue(data.changes, escapedObjects) ?? {},
94
123
  collection,
95
124
  // These fields would need to be reconstructed by the executor
96
125
  mutationId: ``, // Will be regenerated
@@ -116,16 +145,19 @@ export class TransactionSerializer {
116
145
  const result: any = Array.isArray(value) ? [] : {}
117
146
  for (const key in value) {
118
147
  if (Object.prototype.hasOwnProperty.call(value, key)) {
119
- result[key] = this.serializeValue(value[key])
148
+ setDataProperty(result, key, this.serializeValue(value[key]))
120
149
  }
121
150
  }
122
- return result
151
+ return !Array.isArray(value) &&
152
+ Object.prototype.hasOwnProperty.call(value, `__type`)
153
+ ? { __type: `Object`, value: result }
154
+ : result
123
155
  }
124
156
 
125
157
  return value
126
158
  }
127
159
 
128
- private deserializeValue(value: any): any {
160
+ private deserializeValue(value: any, escapedObjects: boolean): any {
129
161
  if (value === null || value === undefined) {
130
162
  return value
131
163
  }
@@ -144,10 +176,25 @@ export class TransactionSerializer {
144
176
  }
145
177
 
146
178
  if (typeof value === `object`) {
179
+ // Unwrap once, then decode only the fields: the object's own __type is data.
180
+ if (escapedObjects && value.__type === `Object`) {
181
+ if (
182
+ value.value === null ||
183
+ typeof value.value !== `object` ||
184
+ Array.isArray(value.value)
185
+ ) {
186
+ throw new Error(`Corrupted Object marker: expected an object value`)
187
+ }
188
+ value = value.value
189
+ }
147
190
  const result: any = Array.isArray(value) ? [] : {}
148
191
  for (const key in value) {
149
192
  if (Object.prototype.hasOwnProperty.call(value, key)) {
150
- result[key] = this.deserializeValue(value[key])
193
+ setDataProperty(
194
+ result,
195
+ key,
196
+ this.deserializeValue(value[key], escapedObjects),
197
+ )
151
198
  }
152
199
  }
153
200
  return result
@@ -89,6 +89,23 @@ export class IndexedDBAdapter extends BaseStorageAdapter {
89
89
  return transaction.objectStore(this.storeName)
90
90
  }
91
91
 
92
+ private waitForWrite(
93
+ store: IDBObjectStore,
94
+ write: () => void,
95
+ ): Promise<void> {
96
+ const transaction = store.transaction
97
+ return new Promise((resolve, reject) => {
98
+ // A successful request can still be rolled back by its transaction.
99
+ transaction.oncomplete = () => resolve()
100
+ transaction.onabort = () =>
101
+ reject(
102
+ transaction.error ??
103
+ new DOMException(`IndexedDB transaction aborted`, `AbortError`),
104
+ )
105
+ write()
106
+ })
107
+ }
108
+
92
109
  async get(key: string): Promise<string | null> {
93
110
  try {
94
111
  const store = await this.getStore(`readonly`)
@@ -106,10 +123,8 @@ export class IndexedDBAdapter extends BaseStorageAdapter {
106
123
  async set(key: string, value: string): Promise<void> {
107
124
  try {
108
125
  const store = await this.getStore(`readwrite`)
109
- return new Promise((resolve, reject) => {
110
- const request = store.put(value, key)
111
- request.onerror = () => reject(request.error)
112
- request.onsuccess = () => resolve()
126
+ return this.waitForWrite(store, () => {
127
+ store.put(value, key)
113
128
  })
114
129
  } catch (error) {
115
130
  if (
@@ -127,10 +142,8 @@ export class IndexedDBAdapter extends BaseStorageAdapter {
127
142
  async delete(key: string): Promise<void> {
128
143
  try {
129
144
  const store = await this.getStore(`readwrite`)
130
- return new Promise((resolve, reject) => {
131
- const request = store.delete(key)
132
- request.onerror = () => reject(request.error)
133
- request.onsuccess = () => resolve()
145
+ return this.waitForWrite(store, () => {
146
+ store.delete(key)
134
147
  })
135
148
  } catch (error) {
136
149
  console.warn(`IndexedDB delete failed:`, error)
@@ -154,10 +167,8 @@ export class IndexedDBAdapter extends BaseStorageAdapter {
154
167
  async clear(): Promise<void> {
155
168
  try {
156
169
  const store = await this.getStore(`readwrite`)
157
- return new Promise((resolve, reject) => {
158
- const request = store.clear()
159
- request.onerror = () => reject(request.error)
160
- request.onsuccess = () => resolve()
170
+ return this.waitForWrite(store, () => {
171
+ store.clear()
161
172
  })
162
173
  } catch (error) {
163
174
  console.warn(`IndexedDB clear failed:`, error)
package/src/types.ts CHANGED
@@ -57,6 +57,8 @@ export interface OfflineTransaction {
57
57
 
58
58
  // Serialized representation for storage
59
59
  export interface SerializedOfflineTransaction {
60
+ /** Absent for the original Date-marker format. */
61
+ valueEncoding?: 2
60
62
  id: string
61
63
  mutationFnName: string
62
64
  mutations: Array<SerializedMutation>
@@ -131,6 +133,7 @@ export interface LeaderElection {
131
133
  }
132
134
 
133
135
  export interface TransactionSignaler {
136
+ readonly isOfflineEnabled: boolean
134
137
  resolveTransaction: (transactionId: string, result: any) => void
135
138
  rejectTransaction: (transactionId: string, error: Error) => void
136
139
  registerRestorationTransaction: (