@tanstack/offline-transactions 1.0.56 → 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 (48) hide show
  1. package/README.md +137 -23
  2. package/dist/cjs/OfflineExecutor.cjs +2 -0
  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 +60 -30
  12. package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
  13. package/dist/cjs/outbox/OutboxManager.cjs +66 -24
  14. package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
  15. package/dist/cjs/outbox/OutboxManager.d.cts +6 -0
  16. package/dist/cjs/outbox/TransactionSerializer.cjs +132 -22
  17. package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
  18. package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
  19. package/dist/cjs/types.cjs.map +1 -1
  20. package/dist/cjs/types.d.cts +1 -1
  21. package/dist/esm/OfflineExecutor.js +2 -0
  22. package/dist/esm/OfflineExecutor.js.map +1 -1
  23. package/dist/esm/connectivity/OnlineDetector.js +1 -1
  24. package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
  25. package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
  26. package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
  27. package/dist/esm/executor/KeyScheduler.d.ts +4 -2
  28. package/dist/esm/executor/KeyScheduler.js +24 -25
  29. package/dist/esm/executor/KeyScheduler.js.map +1 -1
  30. package/dist/esm/executor/TransactionExecutor.js +60 -30
  31. package/dist/esm/executor/TransactionExecutor.js.map +1 -1
  32. package/dist/esm/outbox/OutboxManager.d.ts +6 -0
  33. package/dist/esm/outbox/OutboxManager.js +67 -25
  34. package/dist/esm/outbox/OutboxManager.js.map +1 -1
  35. package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
  36. package/dist/esm/outbox/TransactionSerializer.js +132 -22
  37. package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
  38. package/dist/esm/types.d.ts +1 -1
  39. package/dist/esm/types.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/OfflineExecutor.ts +2 -1
  42. package/src/connectivity/OnlineDetector.ts +6 -1
  43. package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
  44. package/src/executor/KeyScheduler.ts +29 -26
  45. package/src/executor/TransactionExecutor.ts +71 -43
  46. package/src/outbox/OutboxManager.ts +82 -28
  47. package/src/outbox/TransactionSerializer.ts +192 -25
  48. package/src/types.ts +1 -1
@@ -56,6 +56,7 @@ export class TransactionExecutor {
56
56
  } finally {
57
57
  this.isExecuting = false
58
58
  this.executionPromise = null
59
+ this.scheduleNextRetry()
59
60
  }
60
61
  }
61
62
 
@@ -73,9 +74,6 @@ export class TransactionExecutor {
73
74
 
74
75
  await this.executeTransaction(transaction)
75
76
  }
76
-
77
- // Schedule next retry after execution completes
78
- this.scheduleNextRetry()
79
77
  }
80
78
 
81
79
  private async executeTransaction(
@@ -97,18 +95,9 @@ export class TransactionExecutor {
97
95
  span.setAttribute(`retry.attempt`, transaction.retryCount)
98
96
  }
99
97
 
98
+ let result: void
100
99
  try {
101
- const result = await this.runMutationFn(transaction)
102
-
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
- }
109
-
110
- span.setAttribute(`result`, `success`)
111
- this.offlineExecutor.resolveTransaction(transaction.id, result)
100
+ result = await this.runMutationFn(transaction)
112
101
  } catch (error) {
113
102
  const err =
114
103
  error instanceof Error ? error : new Error(String(error))
@@ -119,6 +108,20 @@ export class TransactionExecutor {
119
108
  ;(err as any)[HANDLED_EXECUTION_ERROR] = true
120
109
  throw err
121
110
  }
111
+
112
+ let removalError: unknown
113
+ try {
114
+ // Replay can still see this ID until durable deletion settles.
115
+ await this.outbox.remove(transaction.id)
116
+ } catch (error) {
117
+ removalError = error
118
+ } finally {
119
+ this.scheduler.markCompleted(transaction)
120
+ }
121
+
122
+ span.setAttribute(`result`, `success`)
123
+ this.offlineExecutor.resolveTransaction(transaction.id, result)
124
+ if (removalError !== undefined) throw removalError
122
125
  },
123
126
  )
124
127
  } catch (error) {
@@ -180,8 +183,14 @@ export class TransactionExecutor {
180
183
  span.setAttribute(`shouldRetry`, shouldRetry)
181
184
 
182
185
  if (!shouldRetry) {
183
- this.scheduler.markCompleted(transaction)
184
- await this.outbox.remove(transaction.id)
186
+ let removalError: unknown
187
+ try {
188
+ await this.outbox.remove(transaction.id)
189
+ } catch (cleanupError) {
190
+ removalError = cleanupError
191
+ } finally {
192
+ this.scheduler.markCompleted(transaction)
193
+ }
185
194
  console.warn(
186
195
  `Transaction ${transaction.id} failed permanently:`,
187
196
  error,
@@ -190,6 +199,7 @@ export class TransactionExecutor {
190
199
  span.setAttribute(`result`, `permanent_failure`)
191
200
  // Signal permanent failure to the waiting transaction
192
201
  this.offlineExecutor.rejectTransaction(transaction.id, error)
202
+ if (removalError !== undefined) throw removalError
193
203
  return
194
204
  }
195
205
 
@@ -211,7 +221,6 @@ export class TransactionExecutor {
211
221
  span.setAttribute(`retryDelay`, delay)
212
222
  span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
213
223
 
214
- this.scheduler.markFailed(transaction)
215
224
  this.scheduler.updateTransaction(updatedTransaction)
216
225
 
217
226
  try {
@@ -221,45 +230,64 @@ export class TransactionExecutor {
221
230
  span.recordException(persistError as Error)
222
231
  span.setAttribute(`result`, `persist_failed`)
223
232
  throw persistError
233
+ } finally {
234
+ this.scheduler.markFailed(transaction)
224
235
  }
225
-
226
- // Schedule retry timer
227
- this.scheduleNextRetry()
228
236
  },
229
237
  )
230
238
  }
231
239
 
232
240
  async loadPendingTransactions(): Promise<void> {
233
- const transactions = await this.outbox.getAll()
234
- let filteredTransactions = transactions
235
-
236
- if (this.config.beforeRetry) {
237
- filteredTransactions = this.config.beforeRetry(transactions)
238
- }
241
+ let removedIds: Array<string> = []
242
+ await this.outbox.withAll((transactions) => {
243
+ const { isOfflineEnabled } = this.offlineExecutor
244
+ if (!isOfflineEnabled) return
245
+ let filteredTransactions = transactions
246
+
247
+ if (this.config.beforeRetry) {
248
+ filteredTransactions = this.config.beforeRetry(transactions)
249
+ }
239
250
 
240
- // The outbox read or retry hook may outlive this owner's right to replay.
241
- if (!this.offlineExecutor.isOfflineEnabled) return
251
+ // The retry hook is user code and may synchronously revoke replay rights.
252
+ if (!this.offlineExecutor.isOfflineEnabled) return
242
253
 
243
- const newlyLoaded = filteredTransactions.filter((transaction) =>
244
- this.scheduler.schedule(transaction),
245
- )
254
+ const newlyLoaded = filteredTransactions.filter((transaction) =>
255
+ this.scheduler.schedule(transaction),
256
+ )
246
257
 
247
- // Restore optimistic state for loaded transactions
248
- // This ensures the UI shows the optimistic data while transactions are pending
249
- this.restoreOptimisticState(newlyLoaded)
258
+ const retainedIds = new Set(filteredTransactions.map(({ id }) => id))
259
+ removedIds = transactions
260
+ .filter(({ id }) => !retainedIds.has(id))
261
+ .map(({ id }) => id)
262
+ removedIds = this.scheduler.removePendingTransactions(removedIds)
250
263
 
251
- // Reset retry delays for all loaded transactions so they can run immediately
252
- this.resetRetryDelays()
264
+ // Restore optimistic state for loaded transactions
265
+ // This ensures the UI shows the optimistic data while transactions are pending
266
+ this.restoreOptimisticState(newlyLoaded)
253
267
 
254
- // Schedule retry timer for loaded transactions
255
- this.scheduleNextRetry()
268
+ // Reset retry delays for all loaded transactions so they can run immediately
269
+ this.resetRetryDelays()
256
270
 
257
- const removedTransactions = transactions.filter(
258
- (tx) => !filteredTransactions.some((filtered) => filtered.id === tx.id),
259
- )
271
+ // Schedule retry timer for loaded transactions
272
+ this.scheduleNextRetry()
273
+ })
260
274
 
261
- if (removedTransactions.length > 0) {
262
- await this.outbox.removeMany(removedTransactions.map((tx) => tx.id))
275
+ if (removedIds.length > 0) {
276
+ const error = new NonRetriableError(`Transaction excluded by beforeRetry`)
277
+ await Promise.all(
278
+ removedIds.map(async (id) => {
279
+ try {
280
+ await this.outbox.remove(id)
281
+ this.offlineExecutor.rejectTransaction(id, error)
282
+ } catch (cleanupError) {
283
+ console.warn(
284
+ `Failed to remove transaction excluded by beforeRetry:`,
285
+ id,
286
+ cleanupError,
287
+ )
288
+ }
289
+ }),
290
+ )
263
291
  }
264
292
  }
265
293
 
@@ -1,5 +1,8 @@
1
1
  import { withSpan } from '../telemetry/tracer'
2
- import { TransactionSerializer } from './TransactionSerializer'
2
+ import {
3
+ MissingTemporalConstructorError,
4
+ TransactionSerializer,
5
+ } from './TransactionSerializer'
3
6
  import type { OfflineTransaction, StorageAdapter } from '../types'
4
7
  import type { Collection } from '@tanstack/db'
5
8
 
@@ -7,6 +10,9 @@ export class OutboxManager {
7
10
  private storage: StorageAdapter
8
11
  private serializer: TransactionSerializer
9
12
  private keyPrefix = `tx:`
13
+ private removalRevision = 0
14
+ private activeReads = new Set<{ revision: number }>()
15
+ private removedAtRevision = new Map<string, number>()
10
16
 
11
17
  constructor(
12
18
  storage: StorageAdapter,
@@ -21,6 +27,25 @@ export class OutboxManager {
21
27
  return `${this.keyPrefix}${id}`
22
28
  }
23
29
 
30
+ private recordRemovals(ids: Iterable<string>): void {
31
+ this.removalRevision++
32
+ if (this.activeReads.size === 0) return
33
+ for (const id of ids) this.removedAtRevision.set(id, this.removalRevision)
34
+ this.pruneRemovalHistory()
35
+ }
36
+
37
+ private pruneRemovalHistory(): void {
38
+ if (this.activeReads.size === 0) {
39
+ this.removedAtRevision.clear()
40
+ return
41
+ }
42
+ let oldestRead = Number.POSITIVE_INFINITY
43
+ for (const read of this.activeReads)
44
+ oldestRead = Math.min(oldestRead, read.revision)
45
+ for (const [id, revision] of this.removedAtRevision)
46
+ if (revision <= oldestRead) this.removedAtRevision.delete(id)
47
+ }
48
+
24
49
  async add(transaction: OfflineTransaction): Promise<void> {
25
50
  return withSpan(
26
51
  `outbox.add`,
@@ -52,6 +77,10 @@ export class OutboxManager {
52
77
  span.setAttribute(`result`, `found`)
53
78
  return transaction
54
79
  } catch (error) {
80
+ if (error instanceof MissingTemporalConstructorError) {
81
+ error.message = `transaction ${id}: ${error.message}`
82
+ throw error
83
+ }
55
84
  console.warn(`Failed to deserialize transaction ${id}:`, error)
56
85
  span.setAttribute(`result`, `deserialize_error`)
57
86
  return null
@@ -60,35 +89,57 @@ export class OutboxManager {
60
89
  }
61
90
 
62
91
  async getAll(): Promise<Array<OfflineTransaction>> {
63
- return withSpan(`outbox.getAll`, {}, async (span) => {
64
- const keys = await this.storage.keys()
65
- const transactionKeys = keys.filter((key) =>
66
- key.startsWith(this.keyPrefix),
67
- )
68
-
69
- span.setAttribute(`transactionCount`, transactionKeys.length)
70
-
71
- const transactions: Array<OfflineTransaction> = []
72
-
73
- for (const key of transactionKeys) {
74
- const data = await this.storage.get(key)
75
- if (data) {
76
- try {
77
- const transaction = this.serializer.deserialize(data)
78
- transactions.push(transaction)
79
- } catch (error) {
80
- console.warn(
81
- `Failed to deserialize transaction from key ${key}:`,
82
- error,
83
- )
92
+ return this.withAll((transactions) => transactions)
93
+ }
94
+
95
+ async withAll<T>(
96
+ consume: (transactions: Array<OfflineTransaction>) => T,
97
+ ): Promise<T> {
98
+ const read = { revision: this.removalRevision }
99
+ this.activeReads.add(read)
100
+ try {
101
+ return await withSpan(`outbox.getAll`, {}, async (span) => {
102
+ const keys = await this.storage.keys()
103
+ const transactionKeys = keys.filter((key) =>
104
+ key.startsWith(this.keyPrefix),
105
+ )
106
+
107
+ span.setAttribute(`transactionCount`, transactionKeys.length)
108
+
109
+ const transactions: Array<OfflineTransaction> = []
110
+
111
+ for (const key of transactionKeys) {
112
+ const data = await this.storage.get(key)
113
+ if (data) {
114
+ try {
115
+ const transaction = this.serializer.deserialize(data)
116
+ transactions.push(transaction)
117
+ } catch (error) {
118
+ if (error instanceof MissingTemporalConstructorError) {
119
+ error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`
120
+ throw error
121
+ }
122
+ console.warn(
123
+ `Failed to deserialize transaction from key ${key}:`,
124
+ error,
125
+ )
126
+ }
84
127
  }
85
128
  }
86
- }
87
129
 
88
- return transactions.sort(
89
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
90
- )
91
- })
130
+ const currentTransactions = transactions
131
+ .filter(
132
+ ({ id }) => (this.removedAtRevision.get(id) ?? 0) <= read.revision,
133
+ )
134
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
135
+ // Keep the read registered until replay admission completes. A durable
136
+ // removal cannot land between filtering and this synchronous consumer.
137
+ return consume(currentTransactions)
138
+ })
139
+ } finally {
140
+ this.activeReads.delete(read)
141
+ this.pruneRemovalHistory()
142
+ }
92
143
  }
93
144
 
94
145
  async getByKeys(keys: Array<string>): Promise<Array<OfflineTransaction>> {
@@ -119,6 +170,7 @@ export class OutboxManager {
119
170
  return withSpan(`outbox.remove`, { 'transaction.id': id }, async () => {
120
171
  const key = this.getStorageKey(id)
121
172
  await this.storage.delete(key)
173
+ this.recordRemovals([id])
122
174
  })
123
175
  }
124
176
 
@@ -132,7 +184,9 @@ export class OutboxManager {
132
184
  const keys = await this.storage.keys()
133
185
  const transactionKeys = keys.filter((key) => key.startsWith(this.keyPrefix))
134
186
 
135
- await Promise.all(transactionKeys.map((key) => this.storage.delete(key)))
187
+ await this.removeMany(
188
+ transactionKeys.map((key) => key.slice(this.keyPrefix.length)),
189
+ )
136
190
  }
137
191
 
138
192
  async count(): Promise<number> {
@@ -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,
@@ -39,8 +112,9 @@ export class TransactionSerializer {
39
112
  serialize(transaction: OfflineTransaction): string {
40
113
  const serialized: SerializedOfflineTransaction = {
41
114
  ...transaction,
42
- valueEncoding: 2,
115
+ valueEncoding: 3,
43
116
  createdAt: transaction.createdAt.toISOString(),
117
+ metadata: this.serializeValue(transaction.metadata, `metadata`),
44
118
  mutations: transaction.mutations.map((mutation) =>
45
119
  this.serializeMutation(mutation),
46
120
  ),
@@ -57,7 +131,11 @@ export class TransactionSerializer {
57
131
  }: Omit<SerializedOfflineTransaction, `valueEncoding`> & {
58
132
  valueEncoding?: unknown
59
133
  } = JSON.parse(data)
60
- if (valueEncoding !== undefined && valueEncoding !== 2) {
134
+ if (
135
+ valueEncoding !== undefined &&
136
+ valueEncoding !== 2 &&
137
+ valueEncoding !== 3
138
+ ) {
61
139
  throw new Error(
62
140
  `Unsupported transaction value encoding: ${valueEncoding}`,
63
141
  )
@@ -73,8 +151,12 @@ export class TransactionSerializer {
73
151
  return {
74
152
  ...parsed,
75
153
  createdAt,
154
+ metadata:
155
+ valueEncoding === 3
156
+ ? this.deserializeValue(parsed.metadata, valueEncoding)
157
+ : parsed.metadata,
76
158
  mutations: parsed.mutations.map((mutationData) =>
77
- this.deserializeMutation(mutationData, valueEncoding === 2),
159
+ this.deserializeMutation(mutationData, valueEncoding),
78
160
  ),
79
161
  }
80
162
  }
@@ -99,14 +181,14 @@ export class TransactionSerializer {
99
181
 
100
182
  private deserializeMutation(
101
183
  data: SerializedMutation,
102
- escapedObjects: boolean,
184
+ valueEncoding: 2 | 3 | undefined,
103
185
  ): PendingMutation {
104
186
  const collection = this.collections[data.collectionId]
105
187
  if (!collection) {
106
188
  throw new Error(`Collection with id ${data.collectionId} not found`)
107
189
  }
108
190
 
109
- const modified = this.deserializeValue(data.modified, escapedObjects)
191
+ const modified = this.deserializeValue(data.modified, valueEncoding)
110
192
 
111
193
  // Extract the key from the modified data using the collection's getKey function
112
194
  // This is needed for optimistic state restoration to work correctly
@@ -118,8 +200,8 @@ export class TransactionSerializer {
118
200
  globalKey: data.globalKey,
119
201
  type: data.type as any,
120
202
  modified,
121
- original: this.deserializeValue(data.original, escapedObjects),
122
- changes: this.deserializeValue(data.changes, escapedObjects) ?? {},
203
+ original: this.deserializeValue(data.original, valueEncoding),
204
+ changes: this.deserializeValue(data.changes, valueEncoding) ?? {},
123
205
  collection,
124
206
  // These fields would need to be reconstructed by the executor
125
207
  mutationId: ``, // Will be regenerated
@@ -132,32 +214,104 @@ export class TransactionSerializer {
132
214
  } as PendingMutation
133
215
  }
134
216
 
135
- private serializeValue(value: any): any {
136
- if (value === null || value === undefined) {
137
- return value
138
- }
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
139
223
 
140
- if (value instanceof Date) {
224
+ if (jsonKey !== false && value instanceof Date) {
141
225
  return { __type: `Date`, value: value.toISOString() }
142
226
  }
143
227
 
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]))
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,
149
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)
150
253
  }
151
- return !Array.isArray(value) &&
152
- Object.prototype.hasOwnProperty.call(value, `__type`)
153
- ? { __type: `Object`, value: result }
154
- : result
254
+
255
+ ancestors.add(value)
256
+ try {
257
+ return this.serializeValue(replacement, false, ancestors)
258
+ } finally {
259
+ ancestors.delete(value)
260
+ }
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()
155
270
  }
156
271
 
157
- 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
158
312
  }
159
313
 
160
- private deserializeValue(value: any, escapedObjects: boolean): any {
314
+ private deserializeValue(value: any, valueEncoding: 2 | 3 | undefined): any {
161
315
  if (value === null || value === undefined) {
162
316
  return value
163
317
  }
@@ -175,9 +329,22 @@ export class TransactionSerializer {
175
329
  return date
176
330
  }
177
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
+
178
345
  if (typeof value === `object`) {
179
346
  // Unwrap once, then decode only the fields: the object's own __type is data.
180
- if (escapedObjects && value.__type === `Object`) {
347
+ if (valueEncoding !== undefined && value.__type === `Object`) {
181
348
  if (
182
349
  value.value === null ||
183
350
  typeof value.value !== `object` ||
@@ -193,7 +360,7 @@ export class TransactionSerializer {
193
360
  setDataProperty(
194
361
  result,
195
362
  key,
196
- this.deserializeValue(value[key], escapedObjects),
363
+ this.deserializeValue(value[key], valueEncoding),
197
364
  )
198
365
  }
199
366
  }
package/src/types.ts CHANGED
@@ -58,7 +58,7 @@ export interface OfflineTransaction {
58
58
  // Serialized representation for storage
59
59
  export interface SerializedOfflineTransaction {
60
60
  /** Absent for the original Date-marker format. */
61
- valueEncoding?: 2
61
+ valueEncoding?: 2 | 3
62
62
  id: string
63
63
  mutationFnName: string
64
64
  mutations: Array<SerializedMutation>