@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.
Files changed (60) hide show
  1. package/README.md +157 -23
  2. package/dist/cjs/OfflineExecutor.cjs +6 -3
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/connectivity/OnlineDetector.cjs +1 -1
  5. package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -1
  6. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +1 -7
  7. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
  8. package/dist/cjs/executor/KeyScheduler.cjs +24 -25
  9. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
  10. package/dist/cjs/executor/KeyScheduler.d.cts +4 -2
  11. package/dist/cjs/executor/TransactionExecutor.cjs +134 -35
  12. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  13. package/dist/cjs/executor/TransactionExecutor.d.cts +4 -0
  14. package/dist/cjs/outbox/OutboxManager.cjs +70 -25
  15. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
  16. package/dist/cjs/outbox/OutboxManager.d.cts +8 -0
  17. package/dist/cjs/outbox/TransactionSerializer.cjs +140 -22
  18. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
  19. package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
  20. package/dist/cjs/storage/IndexedDBAdapter.cjs +1 -0
  21. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
  22. package/dist/cjs/storage/LocalStorageAdapter.cjs +1 -1
  23. package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -1
  24. package/dist/cjs/types.cjs.map +1 -1
  25. package/dist/cjs/types.d.cts +5 -1
  26. package/dist/esm/OfflineExecutor.js +6 -3
  27. package/dist/esm/OfflineExecutor.js.map +1 -1
  28. package/dist/esm/connectivity/OnlineDetector.js +1 -1
  29. package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
  30. package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
  31. package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
  32. package/dist/esm/executor/KeyScheduler.d.ts +4 -2
  33. package/dist/esm/executor/KeyScheduler.js +24 -25
  34. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  35. package/dist/esm/executor/TransactionExecutor.d.ts +4 -0
  36. package/dist/esm/executor/TransactionExecutor.js +134 -35
  37. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  38. package/dist/esm/outbox/OutboxManager.d.ts +8 -0
  39. package/dist/esm/outbox/OutboxManager.js +72 -27
  40. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  41. package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
  42. package/dist/esm/outbox/TransactionSerializer.js +140 -22
  43. package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
  44. package/dist/esm/storage/IndexedDBAdapter.js +1 -0
  45. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
  46. package/dist/esm/storage/LocalStorageAdapter.js +1 -1
  47. package/dist/esm/storage/LocalStorageAdapter.js.map +1 -1
  48. package/dist/esm/types.d.ts +5 -1
  49. package/dist/esm/types.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/OfflineExecutor.ts +6 -4
  52. package/src/connectivity/OnlineDetector.ts +6 -1
  53. package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
  54. package/src/executor/KeyScheduler.ts +29 -26
  55. package/src/executor/TransactionExecutor.ts +164 -50
  56. package/src/outbox/OutboxManager.ts +85 -29
  57. package/src/outbox/TransactionSerializer.ts +209 -26
  58. package/src/storage/IndexedDBAdapter.ts +1 -0
  59. package/src/storage/LocalStorageAdapter.ts +1 -1
  60. package/src/types.ts +5 -1
@@ -6,6 +6,79 @@ import type {
6
6
  } from '../types'
7
7
  import type { Collection, PendingMutation } from '@tanstack/db'
8
8
 
9
+ const temporalConstructorNames = [
10
+ `Duration`,
11
+ `Instant`,
12
+ `PlainDate`,
13
+ `PlainDateTime`,
14
+ `PlainMonthDay`,
15
+ `PlainTime`,
16
+ `PlainYearMonth`,
17
+ `ZonedDateTime`,
18
+ ] as const
19
+
20
+ type TemporalConstructorName = (typeof temporalConstructorNames)[number]
21
+ type TemporalConstructor = {
22
+ from: (value: string) => unknown
23
+ prototype?: { toString?: () => string }
24
+ }
25
+
26
+ function getTemporalConstructorName(
27
+ type: unknown,
28
+ ): TemporalConstructorName | undefined {
29
+ if (typeof type !== `string` || !type.startsWith(`Temporal.`)) return
30
+ const constructorName = type.slice(
31
+ `Temporal.`.length,
32
+ ) as TemporalConstructorName
33
+ return temporalConstructorNames.includes(constructorName)
34
+ ? constructorName
35
+ : undefined
36
+ }
37
+
38
+ function requireTemporalConstructor(
39
+ name: TemporalConstructorName,
40
+ ): TemporalConstructor {
41
+ const constructor = (
42
+ globalThis as {
43
+ Temporal?: Partial<Record<TemporalConstructorName, TemporalConstructor>>
44
+ }
45
+ ).Temporal?.[name]
46
+ if (typeof constructor?.from !== `function`)
47
+ throw new MissingTemporalConstructorError(
48
+ `Missing global Temporal.${name} constructor`,
49
+ )
50
+ return constructor
51
+ }
52
+
53
+ function serializeTemporalValue(
54
+ value: object,
55
+ name: TemporalConstructorName,
56
+ ): string | undefined {
57
+ const constructor = requireTemporalConstructor(name)
58
+ const toString = constructor.prototype?.toString
59
+ if (typeof toString !== `function` || toString === Object.prototype.toString)
60
+ return
61
+ try {
62
+ const serialized = toString.call(value)
63
+ return typeof serialized === `string` ? serialized : undefined
64
+ } catch {
65
+ // Temporal prototype methods brand-check their receiver. A matching
66
+ // Symbol.toStringTag without the corresponding internal slots is user data.
67
+ return
68
+ }
69
+ }
70
+
71
+ function mayBeNativeScalar(value: object): boolean {
72
+ const prototype = Object.getPrototypeOf(value)
73
+ return (
74
+ prototype !== null &&
75
+ prototype !== Object.prototype &&
76
+ !Array.isArray(value)
77
+ )
78
+ }
79
+
80
+ export class MissingTemporalConstructorError extends Error {}
81
+
9
82
  function setDataProperty(
10
83
  object: Record<string, unknown>,
11
84
  key: string,
@@ -21,6 +94,17 @@ function setDataProperty(
21
94
  })
22
95
  }
23
96
 
97
+ function assertOutboxPhase(
98
+ phase: unknown,
99
+ ): asserts phase is `deletion-pending` | `rejection-pending` | undefined {
100
+ if (
101
+ phase !== undefined &&
102
+ phase !== `deletion-pending` &&
103
+ phase !== `rejection-pending`
104
+ )
105
+ throw new Error(`Unsupported transaction outbox phase`)
106
+ }
107
+
24
108
  export class TransactionSerializer {
25
109
  private collections: Record<string, Collection<any, any, any, any, any>>
26
110
  private collectionIdToKey: Map<string, string>
@@ -37,10 +121,12 @@ export class TransactionSerializer {
37
121
  }
38
122
 
39
123
  serialize(transaction: OfflineTransaction): string {
124
+ assertOutboxPhase(transaction.outboxPhase)
40
125
  const serialized: SerializedOfflineTransaction = {
41
126
  ...transaction,
42
- valueEncoding: 2,
127
+ valueEncoding: 3,
43
128
  createdAt: transaction.createdAt.toISOString(),
129
+ metadata: this.serializeValue(transaction.metadata, `metadata`),
44
130
  mutations: transaction.mutations.map((mutation) =>
45
131
  this.serializeMutation(mutation),
46
132
  ),
@@ -53,15 +139,22 @@ export class TransactionSerializer {
53
139
  // marker-shaped user objects; the encoding tag is not application metadata.
54
140
  const {
55
141
  valueEncoding,
142
+ outboxPhase,
56
143
  ...parsed
57
- }: Omit<SerializedOfflineTransaction, `valueEncoding`> & {
144
+ }: Omit<SerializedOfflineTransaction, `valueEncoding` | `outboxPhase`> & {
58
145
  valueEncoding?: unknown
146
+ outboxPhase?: unknown
59
147
  } = JSON.parse(data)
60
- if (valueEncoding !== undefined && valueEncoding !== 2) {
148
+ if (
149
+ valueEncoding !== undefined &&
150
+ valueEncoding !== 2 &&
151
+ valueEncoding !== 3
152
+ ) {
61
153
  throw new Error(
62
154
  `Unsupported transaction value encoding: ${valueEncoding}`,
63
155
  )
64
156
  }
157
+ assertOutboxPhase(outboxPhase)
65
158
 
66
159
  const createdAt = new Date(parsed.createdAt)
67
160
  if (isNaN(createdAt.getTime())) {
@@ -72,9 +165,14 @@ export class TransactionSerializer {
72
165
 
73
166
  return {
74
167
  ...parsed,
168
+ ...(outboxPhase === undefined ? {} : { outboxPhase }),
75
169
  createdAt,
170
+ metadata:
171
+ valueEncoding === 3
172
+ ? this.deserializeValue(parsed.metadata, valueEncoding)
173
+ : parsed.metadata,
76
174
  mutations: parsed.mutations.map((mutationData) =>
77
- this.deserializeMutation(mutationData, valueEncoding === 2),
175
+ this.deserializeMutation(mutationData, valueEncoding),
78
176
  ),
79
177
  }
80
178
  }
@@ -99,14 +197,14 @@ export class TransactionSerializer {
99
197
 
100
198
  private deserializeMutation(
101
199
  data: SerializedMutation,
102
- escapedObjects: boolean,
200
+ valueEncoding: 2 | 3 | undefined,
103
201
  ): PendingMutation {
104
202
  const collection = this.collections[data.collectionId]
105
203
  if (!collection) {
106
204
  throw new Error(`Collection with id ${data.collectionId} not found`)
107
205
  }
108
206
 
109
- const modified = this.deserializeValue(data.modified, escapedObjects)
207
+ const modified = this.deserializeValue(data.modified, valueEncoding)
110
208
 
111
209
  // Extract the key from the modified data using the collection's getKey function
112
210
  // This is needed for optimistic state restoration to work correctly
@@ -118,8 +216,8 @@ export class TransactionSerializer {
118
216
  globalKey: data.globalKey,
119
217
  type: data.type as any,
120
218
  modified,
121
- original: this.deserializeValue(data.original, escapedObjects),
122
- changes: this.deserializeValue(data.changes, escapedObjects) ?? {},
219
+ original: this.deserializeValue(data.original, valueEncoding),
220
+ changes: this.deserializeValue(data.changes, valueEncoding) ?? {},
123
221
  collection,
124
222
  // These fields would need to be reconstructed by the executor
125
223
  mutationId: ``, // Will be regenerated
@@ -132,32 +230,104 @@ export class TransactionSerializer {
132
230
  } as PendingMutation
133
231
  }
134
232
 
135
- private serializeValue(value: any): any {
136
- if (value === null || value === undefined) {
137
- return value
138
- }
233
+ private serializeValue(
234
+ value: any,
235
+ jsonKey?: string | false,
236
+ ancestors = new WeakSet<object>(),
237
+ ): any {
238
+ if (value === null || typeof value !== `object`) return value
139
239
 
140
- if (value instanceof Date) {
240
+ if (jsonKey !== false && value instanceof Date) {
141
241
  return { __type: `Date`, value: value.toISOString() }
142
242
  }
143
243
 
144
- if (typeof value === `object`) {
145
- const result: any = Array.isArray(value) ? [] : {}
146
- for (const key in value) {
147
- if (Object.prototype.hasOwnProperty.call(value, key)) {
148
- setDataProperty(result, key, this.serializeValue(value[key]))
244
+ const temporalConstructorName =
245
+ jsonKey !== false && mayBeNativeScalar(value)
246
+ ? getTemporalConstructorName(value[Symbol.toStringTag])
247
+ : undefined
248
+ if (temporalConstructorName) {
249
+ const temporalValue = serializeTemporalValue(
250
+ value,
251
+ temporalConstructorName,
252
+ )
253
+ if (temporalValue !== undefined)
254
+ return {
255
+ __type: `Temporal`,
256
+ type: `Temporal.${temporalConstructorName}`,
257
+ value: temporalValue,
149
258
  }
259
+ }
260
+
261
+ if (ancestors.has(value))
262
+ throw new TypeError(`Converting circular structure to JSON`)
263
+
264
+ const toJSON = typeof jsonKey === `string` && value.toJSON
265
+ if (typeof toJSON === `function`) {
266
+ const replacement = toJSON.call(value, jsonKey)
267
+ if (replacement === value) {
268
+ return this.serializeValue(replacement, false, ancestors)
269
+ }
270
+
271
+ ancestors.add(value)
272
+ try {
273
+ return this.serializeValue(replacement, false, ancestors)
274
+ } finally {
275
+ ancestors.delete(value)
150
276
  }
151
- return !Array.isArray(value) &&
152
- Object.prototype.hasOwnProperty.call(value, `__type`)
153
- ? { __type: `Object`, value: result }
154
- : result
277
+ }
278
+ if (
279
+ jsonKey !== undefined &&
280
+ (value instanceof Boolean ||
281
+ value instanceof BigInt ||
282
+ value instanceof Number ||
283
+ value instanceof String)
284
+ ) {
285
+ return value.valueOf()
155
286
  }
156
287
 
157
- return value
288
+ ancestors.add(value)
289
+
290
+ const isArray = Array.isArray(value)
291
+ const result: any = isArray ? [] : {}
292
+ try {
293
+ if (isArray) {
294
+ const length = value.length
295
+ for (let index = 0; index < length; index++) {
296
+ const key = String(index)
297
+ setDataProperty(
298
+ result,
299
+ key,
300
+ this.serializeValue(
301
+ value[index],
302
+ jsonKey === undefined ? undefined : key,
303
+ ancestors,
304
+ ),
305
+ )
306
+ }
307
+ } else {
308
+ for (const key of Object.keys(value)) {
309
+ setDataProperty(
310
+ result,
311
+ key,
312
+ this.serializeValue(
313
+ value[key],
314
+ jsonKey === undefined ? undefined : key,
315
+ ancestors,
316
+ ),
317
+ )
318
+ }
319
+ }
320
+ } finally {
321
+ ancestors.delete(value)
322
+ }
323
+ if (jsonKey === false && typeof result.toJSON === `function`)
324
+ delete result.toJSON
325
+ return !isArray && Object.prototype.hasOwnProperty.call(value, `__type`)
326
+ ? { __type: `Object`, value: result }
327
+ : result
158
328
  }
159
329
 
160
- private deserializeValue(value: any, escapedObjects: boolean): any {
330
+ private deserializeValue(value: any, valueEncoding: 2 | 3 | undefined): any {
161
331
  if (value === null || value === undefined) {
162
332
  return value
163
333
  }
@@ -175,9 +345,22 @@ export class TransactionSerializer {
175
345
  return date
176
346
  }
177
347
 
348
+ if (
349
+ valueEncoding === 3 &&
350
+ typeof value === `object` &&
351
+ value.__type === `Temporal`
352
+ ) {
353
+ const constructorName = getTemporalConstructorName(value.type)
354
+ if (!constructorName)
355
+ throw new Error(`Corrupted Temporal marker: invalid type field`)
356
+ if (typeof value.value !== `string`)
357
+ throw new Error(`Corrupted Temporal marker: missing value field`)
358
+ return requireTemporalConstructor(constructorName).from(value.value)
359
+ }
360
+
178
361
  if (typeof value === `object`) {
179
362
  // Unwrap once, then decode only the fields: the object's own __type is data.
180
- if (escapedObjects && value.__type === `Object`) {
363
+ if (valueEncoding !== undefined && value.__type === `Object`) {
181
364
  if (
182
365
  value.value === null ||
183
366
  typeof value.value !== `object` ||
@@ -193,7 +376,7 @@ export class TransactionSerializer {
193
376
  setDataProperty(
194
377
  result,
195
378
  key,
196
- this.deserializeValue(value[key], escapedObjects),
379
+ this.deserializeValue(value[key], valueEncoding),
197
380
  )
198
381
  }
199
382
  }
@@ -147,6 +147,7 @@ export class IndexedDBAdapter extends BaseStorageAdapter {
147
147
  })
148
148
  } catch (error) {
149
149
  console.warn(`IndexedDB delete failed:`, error)
150
+ throw error
150
151
  }
151
152
  }
152
153
 
@@ -84,7 +84,7 @@ export class LocalStorageAdapter extends BaseStorageAdapter {
84
84
  return Promise.resolve()
85
85
  } catch (error) {
86
86
  console.warn(`localStorage delete failed:`, error)
87
- return Promise.resolve()
87
+ return Promise.reject(error)
88
88
  }
89
89
  }
90
90
 
package/src/types.ts CHANGED
@@ -49,6 +49,8 @@ export interface OfflineTransaction {
49
49
  createdAt: Date
50
50
  retryCount: number
51
51
  nextAttemptAt: number
52
+ /** Provider work has settled; only durable outbox deletion remains. */
53
+ outboxPhase?: `deletion-pending` | `rejection-pending`
52
54
  lastError?: SerializedError
53
55
  metadata?: Record<string, any>
54
56
  spanContext?: SerializedSpanContext
@@ -58,7 +60,7 @@ export interface OfflineTransaction {
58
60
  // Serialized representation for storage
59
61
  export interface SerializedOfflineTransaction {
60
62
  /** Absent for the original Date-marker format. */
61
- valueEncoding?: 2
63
+ valueEncoding?: 2 | 3
62
64
  id: string
63
65
  mutationFnName: string
64
66
  mutations: Array<SerializedMutation>
@@ -67,6 +69,8 @@ export interface SerializedOfflineTransaction {
67
69
  createdAt: string
68
70
  retryCount: number
69
71
  nextAttemptAt: number
72
+ /** Absent in older records and while mutationFn may still need to run. */
73
+ outboxPhase?: `deletion-pending` | `rejection-pending`
70
74
  lastError?: SerializedError
71
75
  metadata?: Record<string, any>
72
76
  spanContext?: SerializedSpanContext