@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.
- package/README.md +157 -23
- package/dist/cjs/OfflineExecutor.cjs +6 -3
- 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 +134 -35
- package/dist/cjs/executor/TransactionExecutor.cjs.map +1 -1
- package/dist/cjs/executor/TransactionExecutor.d.cts +4 -0
- package/dist/cjs/outbox/OutboxManager.cjs +70 -25
- package/dist/cjs/outbox/OutboxManager.cjs.map +1 -1
- package/dist/cjs/outbox/OutboxManager.d.cts +8 -0
- package/dist/cjs/outbox/TransactionSerializer.cjs +140 -22
- package/dist/cjs/outbox/TransactionSerializer.cjs.map +1 -1
- package/dist/cjs/outbox/TransactionSerializer.d.cts +2 -0
- package/dist/cjs/storage/IndexedDBAdapter.cjs +1 -0
- package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
- package/dist/cjs/storage/LocalStorageAdapter.cjs +1 -1
- package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -1
- package/dist/cjs/types.cjs.map +1 -1
- package/dist/cjs/types.d.cts +5 -1
- package/dist/esm/OfflineExecutor.js +6 -3
- 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.d.ts +4 -0
- package/dist/esm/executor/TransactionExecutor.js +134 -35
- package/dist/esm/executor/TransactionExecutor.js.map +1 -1
- package/dist/esm/outbox/OutboxManager.d.ts +8 -0
- package/dist/esm/outbox/OutboxManager.js +72 -27
- package/dist/esm/outbox/OutboxManager.js.map +1 -1
- package/dist/esm/outbox/TransactionSerializer.d.ts +2 -0
- package/dist/esm/outbox/TransactionSerializer.js +140 -22
- package/dist/esm/outbox/TransactionSerializer.js.map +1 -1
- package/dist/esm/storage/IndexedDBAdapter.js +1 -0
- package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
- package/dist/esm/storage/LocalStorageAdapter.js +1 -1
- package/dist/esm/storage/LocalStorageAdapter.js.map +1 -1
- package/dist/esm/types.d.ts +5 -1
- package/dist/esm/types.js.map +1 -1
- package/package.json +2 -2
- package/src/OfflineExecutor.ts +6 -4
- 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 +164 -50
- package/src/outbox/OutboxManager.ts +85 -29
- package/src/outbox/TransactionSerializer.ts +209 -26
- package/src/storage/IndexedDBAdapter.ts +1 -0
- package/src/storage/LocalStorageAdapter.ts +1 -1
- 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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
122
|
-
changes: this.deserializeValue(data.changes,
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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],
|
|
379
|
+
this.deserializeValue(value[key], valueEncoding),
|
|
197
380
|
)
|
|
198
381
|
}
|
|
199
382
|
}
|
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
|