@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.
- package/README.md +137 -23
- package/dist/cjs/OfflineExecutor.cjs +2 -0
- package/dist/cjs/OfflineExecutor.cjs.map +1 -1
- package/dist/cjs/connectivity/OnlineDetector.cjs +1 -1
- package/dist/cjs/connectivity/OnlineDetector.cjs.map +1 -1
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs +1 -7
- package/dist/cjs/connectivity/ReactNativeOnlineDetector.cjs.map +1 -1
- package/dist/cjs/executor/KeyScheduler.cjs +24 -25
- package/dist/cjs/executor/KeyScheduler.cjs.map +1 -1
- package/dist/cjs/executor/KeyScheduler.d.cts +4 -2
- package/dist/cjs/executor/TransactionExecutor.cjs +60 -30
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
- package/dist/cjs/outbox/OutboxManager.cjs +66 -24
- package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
- package/dist/cjs/outbox/OutboxManager.d.cts +6 -0
- package/dist/cjs/outbox/TransactionSerializer.cjs +132 -22
- package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
- package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
- package/dist/cjs/types.cjs.map +1 -1
- package/dist/cjs/types.d.cts +1 -1
- package/dist/esm/OfflineExecutor.js +2 -0
- package/dist/esm/OfflineExecutor.js.map +1 -1
- package/dist/esm/connectivity/OnlineDetector.js +1 -1
- package/dist/esm/connectivity/OnlineDetector.js.map +1 -1
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js +1 -7
- package/dist/esm/connectivity/ReactNativeOnlineDetector.js.map +1 -1
- package/dist/esm/executor/KeyScheduler.d.ts +4 -2
- package/dist/esm/executor/KeyScheduler.js +24 -25
- package/dist/esm/executor/KeyScheduler.js.map +1 -1
- package/dist/esm/executor/TransactionExecutor.js +60 -30
- package/dist/esm/executor/TransactionExecutor.js.map +1 -1
- package/dist/esm/outbox/OutboxManager.d.ts +6 -0
- package/dist/esm/outbox/OutboxManager.js +67 -25
- package/dist/esm/outbox/OutboxManager.js.map +1 -1
- package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
- package/dist/esm/outbox/TransactionSerializer.js +132 -22
- package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
- package/dist/esm/types.d.ts +1 -1
- package/dist/esm/types.js.map +1 -1
- package/package.json +2 -2
- package/src/OfflineExecutor.ts +2 -1
- package/src/connectivity/OnlineDetector.ts +6 -1
- package/src/connectivity/ReactNativeOnlineDetector.ts +1 -11
- package/src/executor/KeyScheduler.ts +29 -26
- package/src/executor/TransactionExecutor.ts +71 -43
- package/src/outbox/OutboxManager.ts +82 -28
- package/src/outbox/TransactionSerializer.ts +192 -25
- 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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
filteredTransactions =
|
|
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
|
-
|
|
241
|
-
|
|
251
|
+
// The retry hook is user code and may synchronously revoke replay rights.
|
|
252
|
+
if (!this.offlineExecutor.isOfflineEnabled) return
|
|
242
253
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
const newlyLoaded = filteredTransactions.filter((transaction) =>
|
|
255
|
+
this.scheduler.schedule(transaction),
|
|
256
|
+
)
|
|
246
257
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
268
|
+
// Reset retry delays for all loaded transactions so they can run immediately
|
|
269
|
+
this.resetRetryDelays()
|
|
256
270
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
271
|
+
// Schedule retry timer for loaded transactions
|
|
272
|
+
this.scheduleNextRetry()
|
|
273
|
+
})
|
|
260
274
|
|
|
261
|
-
if (
|
|
262
|
-
|
|
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 {
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
122
|
-
changes: this.deserializeValue(data.changes,
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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],
|
|
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>
|