@tanstack/offline-transactions 1.0.54 → 1.0.58

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 (64) hide show
  1. package/README.md +137 -23
  2. package/dist/cjs/OfflineExecutor.cjs +28 -11
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/OfflineExecutor.d.cts +1 -0
  5. package/dist/cjs/api/OfflineTransaction.cjs +4 -1
  6. package/dist/cjs/api/OfflineTransaction.cjs.map +1 -1
  7. package/dist/cjs/connectivity/OnlineDetector.cjs +1 -1
  8. package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -1
  9. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +1 -7
  10. package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
  11. package/dist/cjs/executor/KeyScheduler.cjs +31 -26
  12. package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
  13. package/dist/cjs/executor/KeyScheduler.d.cts +5 -3
  14. package/dist/cjs/executor/TransactionExecutor.cjs +72 -35
  15. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  16. package/dist/cjs/executor/TransactionExecutor.d.cts +3 -2
  17. package/dist/cjs/outbox/OutboxManager.cjs +66 -24
  18. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
  19. package/dist/cjs/outbox/OutboxManager.d.cts +6 -0
  20. package/dist/cjs/outbox/TransactionSerializer.cjs +159 -20
  21. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
  22. package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
  23. package/dist/cjs/storage/IndexedDBAdapter.cjs +16 -12
  24. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
  25. package/dist/cjs/storage/IndexedDBAdapter.d.cts +1 -0
  26. package/dist/cjs/types.cjs.map +1 -1
  27. package/dist/cjs/types.d.cts +3 -0
  28. package/dist/esm/OfflineExecutor.d.ts +1 -0
  29. package/dist/esm/OfflineExecutor.js +28 -11
  30. package/dist/esm/OfflineExecutor.js.map +1 -1
  31. package/dist/esm/api/OfflineTransaction.js +4 -1
  32. package/dist/esm/api/OfflineTransaction.js.map +1 -1
  33. package/dist/esm/connectivity/OnlineDetector.js +1 -1
  34. package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
  35. package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
  36. package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
  37. package/dist/esm/executor/KeyScheduler.d.ts +5 -3
  38. package/dist/esm/executor/KeyScheduler.js +31 -26
  39. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  40. package/dist/esm/executor/TransactionExecutor.d.ts +3 -2
  41. package/dist/esm/executor/TransactionExecutor.js +72 -35
  42. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  43. package/dist/esm/outbox/OutboxManager.d.ts +6 -0
  44. package/dist/esm/outbox/OutboxManager.js +67 -25
  45. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  46. package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
  47. package/dist/esm/outbox/TransactionSerializer.js +159 -20
  48. package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
  49. package/dist/esm/storage/IndexedDBAdapter.d.ts +1 -0
  50. package/dist/esm/storage/IndexedDBAdapter.js +16 -12
  51. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
  52. package/dist/esm/types.d.ts +3 -0
  53. package/dist/esm/types.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/OfflineExecutor.ts +34 -15
  56. package/src/api/OfflineTransaction.ts +7 -2
  57. package/src/connectivity/OnlineDetector.ts +6 -1
  58. package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
  59. package/src/executor/KeyScheduler.ts +39 -28
  60. package/src/executor/TransactionExecutor.ts +88 -46
  61. package/src/outbox/OutboxManager.ts +82 -28
  62. package/src/outbox/TransactionSerializer.ts +236 -22
  63. package/src/storage/IndexedDBAdapter.ts +23 -12
  64. package/src/types.ts +3 -0
@@ -6,6 +6,94 @@ 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
+
82
+ function setDataProperty(
83
+ object: Record<string, unknown>,
84
+ key: string,
85
+ value: unknown,
86
+ ): void {
87
+ if (key !== `__proto__`) object[key] = value
88
+ else
89
+ Object.defineProperty(object, key, {
90
+ value,
91
+ enumerable: true,
92
+ configurable: true,
93
+ writable: true,
94
+ })
95
+ }
96
+
9
97
  export class TransactionSerializer {
10
98
  private collections: Record<string, Collection<any, any, any, any, any>>
11
99
  private collectionIdToKey: Map<string, string>
@@ -24,7 +112,9 @@ export class TransactionSerializer {
24
112
  serialize(transaction: OfflineTransaction): string {
25
113
  const serialized: SerializedOfflineTransaction = {
26
114
  ...transaction,
115
+ valueEncoding: 3,
27
116
  createdAt: transaction.createdAt.toISOString(),
117
+ metadata: this.serializeValue(transaction.metadata, `metadata`),
28
118
  mutations: transaction.mutations.map((mutation) =>
29
119
  this.serializeMutation(mutation),
30
120
  ),
@@ -33,9 +123,23 @@ export class TransactionSerializer {
33
123
  }
34
124
 
35
125
  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)
126
+ // Old records retain their Date-marker meaning. New records also escape
127
+ // marker-shaped user objects; the encoding tag is not application metadata.
128
+ const {
129
+ valueEncoding,
130
+ ...parsed
131
+ }: Omit<SerializedOfflineTransaction, `valueEncoding`> & {
132
+ valueEncoding?: unknown
133
+ } = JSON.parse(data)
134
+ if (
135
+ valueEncoding !== undefined &&
136
+ valueEncoding !== 2 &&
137
+ valueEncoding !== 3
138
+ ) {
139
+ throw new Error(
140
+ `Unsupported transaction value encoding: ${valueEncoding}`,
141
+ )
142
+ }
39
143
 
40
144
  const createdAt = new Date(parsed.createdAt)
41
145
  if (isNaN(createdAt.getTime())) {
@@ -47,8 +151,12 @@ export class TransactionSerializer {
47
151
  return {
48
152
  ...parsed,
49
153
  createdAt,
154
+ metadata:
155
+ valueEncoding === 3
156
+ ? this.deserializeValue(parsed.metadata, valueEncoding)
157
+ : parsed.metadata,
50
158
  mutations: parsed.mutations.map((mutationData) =>
51
- this.deserializeMutation(mutationData),
159
+ this.deserializeMutation(mutationData, valueEncoding),
52
160
  ),
53
161
  }
54
162
  }
@@ -71,13 +179,16 @@ export class TransactionSerializer {
71
179
  }
72
180
  }
73
181
 
74
- private deserializeMutation(data: SerializedMutation): PendingMutation {
182
+ private deserializeMutation(
183
+ data: SerializedMutation,
184
+ valueEncoding: 2 | 3 | undefined,
185
+ ): PendingMutation {
75
186
  const collection = this.collections[data.collectionId]
76
187
  if (!collection) {
77
188
  throw new Error(`Collection with id ${data.collectionId} not found`)
78
189
  }
79
190
 
80
- const modified = this.deserializeValue(data.modified)
191
+ const modified = this.deserializeValue(data.modified, valueEncoding)
81
192
 
82
193
  // Extract the key from the modified data using the collection's getKey function
83
194
  // This is needed for optimistic state restoration to work correctly
@@ -89,8 +200,8 @@ export class TransactionSerializer {
89
200
  globalKey: data.globalKey,
90
201
  type: data.type as any,
91
202
  modified,
92
- original: this.deserializeValue(data.original),
93
- changes: this.deserializeValue(data.changes) ?? {},
203
+ original: this.deserializeValue(data.original, valueEncoding),
204
+ changes: this.deserializeValue(data.changes, valueEncoding) ?? {},
94
205
  collection,
95
206
  // These fields would need to be reconstructed by the executor
96
207
  mutationId: ``, // Will be regenerated
@@ -103,29 +214,104 @@ export class TransactionSerializer {
103
214
  } as PendingMutation
104
215
  }
105
216
 
106
- private serializeValue(value: any): any {
107
- if (value === null || value === undefined) {
108
- return value
109
- }
217
+ private serializeValue(
218
+ value: any,
219
+ jsonKey?: string | false,
220
+ ancestors = new WeakSet<object>(),
221
+ ): any {
222
+ if (value === null || typeof value !== `object`) return value
110
223
 
111
- if (value instanceof Date) {
224
+ if (jsonKey !== false && value instanceof Date) {
112
225
  return { __type: `Date`, value: value.toISOString() }
113
226
  }
114
227
 
115
- if (typeof value === `object`) {
116
- const result: any = Array.isArray(value) ? [] : {}
117
- for (const key in value) {
118
- if (Object.prototype.hasOwnProperty.call(value, key)) {
119
- result[key] = this.serializeValue(value[key])
228
+ const temporalConstructorName =
229
+ jsonKey !== false && mayBeNativeScalar(value)
230
+ ? getTemporalConstructorName(value[Symbol.toStringTag])
231
+ : undefined
232
+ if (temporalConstructorName) {
233
+ const temporalValue = serializeTemporalValue(
234
+ value,
235
+ temporalConstructorName,
236
+ )
237
+ if (temporalValue !== undefined)
238
+ return {
239
+ __type: `Temporal`,
240
+ type: `Temporal.${temporalConstructorName}`,
241
+ value: temporalValue,
120
242
  }
243
+ }
244
+
245
+ if (ancestors.has(value))
246
+ throw new TypeError(`Converting circular structure to JSON`)
247
+
248
+ const toJSON = typeof jsonKey === `string` && value.toJSON
249
+ if (typeof toJSON === `function`) {
250
+ const replacement = toJSON.call(value, jsonKey)
251
+ if (replacement === value) {
252
+ return this.serializeValue(replacement, false, ancestors)
253
+ }
254
+
255
+ ancestors.add(value)
256
+ try {
257
+ return this.serializeValue(replacement, false, ancestors)
258
+ } finally {
259
+ ancestors.delete(value)
121
260
  }
122
- return result
261
+ }
262
+ if (
263
+ jsonKey !== undefined &&
264
+ (value instanceof Boolean ||
265
+ value instanceof BigInt ||
266
+ value instanceof Number ||
267
+ value instanceof String)
268
+ ) {
269
+ return value.valueOf()
123
270
  }
124
271
 
125
- return value
272
+ ancestors.add(value)
273
+
274
+ const isArray = Array.isArray(value)
275
+ const result: any = isArray ? [] : {}
276
+ try {
277
+ if (isArray) {
278
+ const length = value.length
279
+ for (let index = 0; index < length; index++) {
280
+ const key = String(index)
281
+ setDataProperty(
282
+ result,
283
+ key,
284
+ this.serializeValue(
285
+ value[index],
286
+ jsonKey === undefined ? undefined : key,
287
+ ancestors,
288
+ ),
289
+ )
290
+ }
291
+ } else {
292
+ for (const key of Object.keys(value)) {
293
+ setDataProperty(
294
+ result,
295
+ key,
296
+ this.serializeValue(
297
+ value[key],
298
+ jsonKey === undefined ? undefined : key,
299
+ ancestors,
300
+ ),
301
+ )
302
+ }
303
+ }
304
+ } finally {
305
+ ancestors.delete(value)
306
+ }
307
+ if (jsonKey === false && typeof result.toJSON === `function`)
308
+ delete result.toJSON
309
+ return !isArray && Object.prototype.hasOwnProperty.call(value, `__type`)
310
+ ? { __type: `Object`, value: result }
311
+ : result
126
312
  }
127
313
 
128
- private deserializeValue(value: any): any {
314
+ private deserializeValue(value: any, valueEncoding: 2 | 3 | undefined): any {
129
315
  if (value === null || value === undefined) {
130
316
  return value
131
317
  }
@@ -143,11 +329,39 @@ export class TransactionSerializer {
143
329
  return date
144
330
  }
145
331
 
332
+ if (
333
+ valueEncoding === 3 &&
334
+ typeof value === `object` &&
335
+ value.__type === `Temporal`
336
+ ) {
337
+ const constructorName = getTemporalConstructorName(value.type)
338
+ if (!constructorName)
339
+ throw new Error(`Corrupted Temporal marker: invalid type field`)
340
+ if (typeof value.value !== `string`)
341
+ throw new Error(`Corrupted Temporal marker: missing value field`)
342
+ return requireTemporalConstructor(constructorName).from(value.value)
343
+ }
344
+
146
345
  if (typeof value === `object`) {
346
+ // Unwrap once, then decode only the fields: the object's own __type is data.
347
+ if (valueEncoding !== undefined && value.__type === `Object`) {
348
+ if (
349
+ value.value === null ||
350
+ typeof value.value !== `object` ||
351
+ Array.isArray(value.value)
352
+ ) {
353
+ throw new Error(`Corrupted Object marker: expected an object value`)
354
+ }
355
+ value = value.value
356
+ }
147
357
  const result: any = Array.isArray(value) ? [] : {}
148
358
  for (const key in value) {
149
359
  if (Object.prototype.hasOwnProperty.call(value, key)) {
150
- result[key] = this.deserializeValue(value[key])
360
+ setDataProperty(
361
+ result,
362
+ key,
363
+ this.deserializeValue(value[key], valueEncoding),
364
+ )
151
365
  }
152
366
  }
153
367
  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 | 3
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: (