Skip to content

Commit 83e9719

Browse files
committed
fix(offline-transactions): harden value serialization
1 parent b73bfd3 commit 83e9719

3 files changed

Lines changed: 209 additions & 34 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/offline-transactions': patch
3+
---
4+
5+
Reject cyclic offline transaction values with a bounded error, preserve user objects that only imitate Temporal tags, and avoid allocating an index list for every serialized array.

packages/offline-transactions/src/outbox/TransactionSerializer.ts

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ const temporalConstructorNames = [
1818
] as const
1919

2020
type TemporalConstructorName = (typeof temporalConstructorNames)[number]
21-
type TemporalConstructor = { from: (value: string) => unknown }
21+
type TemporalConstructor = {
22+
from: (value: string) => unknown
23+
prototype?: { toString?: () => string }
24+
}
2225

2326
function getTemporalConstructorName(
2427
type: unknown,
@@ -47,6 +50,24 @@ function requireTemporalConstructor(
4750
return constructor
4851
}
4952

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+
5071
export class MissingTemporalConstructorError extends Error {}
5172

5273
function setDataProperty(
@@ -184,7 +205,11 @@ export class TransactionSerializer {
184205
} as PendingMutation
185206
}
186207

187-
private serializeValue(value: any, jsonKey?: string | false): any {
208+
private serializeValue(
209+
value: any,
210+
jsonKey?: string | false,
211+
ancestors = new WeakSet<object>(),
212+
): any {
188213
if (value === null || typeof value !== `object`) return value
189214

190215
if (jsonKey !== false && value instanceof Date) {
@@ -196,17 +221,21 @@ export class TransactionSerializer {
196221
? getTemporalConstructorName(value[Symbol.toStringTag])
197222
: undefined
198223
if (temporalConstructorName) {
199-
requireTemporalConstructor(temporalConstructorName)
200-
return {
201-
__type: `Temporal`,
202-
type: `Temporal.${temporalConstructorName}`,
203-
value: value.toString(),
204-
}
224+
const temporalValue = serializeTemporalValue(
225+
value,
226+
temporalConstructorName,
227+
)
228+
if (temporalValue !== undefined)
229+
return {
230+
__type: `Temporal`,
231+
type: `Temporal.${temporalConstructorName}`,
232+
value: temporalValue,
233+
}
205234
}
206235

207236
const toJSON = typeof jsonKey === `string` && value.toJSON
208237
if (typeof toJSON === `function`)
209-
return this.serializeValue(toJSON.call(value, jsonKey), false)
238+
return this.serializeValue(toJSON.call(value, jsonKey), false, ancestors)
210239
if (
211240
jsonKey !== undefined &&
212241
(value instanceof Boolean ||
@@ -216,20 +245,43 @@ export class TransactionSerializer {
216245
) {
217246
return value.valueOf()
218247
}
248+
249+
if (ancestors.has(value))
250+
throw new TypeError(`Converting circular structure to JSON`)
251+
ancestors.add(value)
252+
219253
const isArray = Array.isArray(value)
220254
const result: any = isArray ? [] : {}
221-
const keys = isArray
222-
? Array.from({ length: value.length }, (_, index) => String(index))
223-
: Object.keys(value)
224-
for (const key of keys) {
225-
setDataProperty(
226-
result,
227-
key,
228-
this.serializeValue(
229-
value[key],
230-
jsonKey === undefined ? undefined : key,
231-
),
232-
)
255+
try {
256+
if (isArray) {
257+
const length = value.length
258+
for (let index = 0; index < length; index++) {
259+
const key = String(index)
260+
setDataProperty(
261+
result,
262+
key,
263+
this.serializeValue(
264+
value[index],
265+
jsonKey === undefined ? undefined : key,
266+
ancestors,
267+
),
268+
)
269+
}
270+
} else {
271+
for (const key of Object.keys(value)) {
272+
setDataProperty(
273+
result,
274+
key,
275+
this.serializeValue(
276+
value[key],
277+
jsonKey === undefined ? undefined : key,
278+
ancestors,
279+
),
280+
)
281+
}
282+
}
283+
} finally {
284+
ancestors.delete(value)
233285
}
234286
if (jsonKey === false && typeof result.toJSON === `function`)
235287
delete result.toJSON

packages/offline-transactions/tests/transaction-serializer.property.test.ts

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ import type { PendingMutation } from '@tanstack/db'
4343
* versions. Malformed markers and missing Temporal constructors test failure
4444
* paths before durable data can be replaced.
4545
*
46-
* Limits: cycles, undefined, non-finite numbers, arbitrary native objects, and
47-
* cross-realm boxed values are outside current evidence. This oracle does not
48-
* claim byte stability for object key order beyond JSON's established rules.
46+
* Limits: undefined, non-finite numbers, arbitrary native objects, and
47+
* cross-realm boxed values are outside current evidence. Cycles must fail
48+
* visibly while repeated non-cyclic references retain their values. This
49+
* oracle does not claim byte stability for object key order beyond JSON's
50+
* established rules.
4951
*/
5052

5153
type Value =
@@ -375,6 +377,18 @@ class TemporalStub {
375377
}
376378
}
377379

380+
function temporalStubConstructor(name: TemporalName) {
381+
return class extends TemporalStub {
382+
constructor(value: string) {
383+
super(name, value)
384+
}
385+
386+
static from(value: string): TemporalStub {
387+
return new TemporalStub(name, value)
388+
}
389+
}
390+
}
391+
378392
function metadataTransaction(
379393
metadata: Record<string, unknown>,
380394
): OfflineTransaction {
@@ -392,6 +406,101 @@ function metadataTransaction(
392406
}
393407
}
394408

409+
it(`rejects cyclic metadata with a bounded JSON-style error`, () => {
410+
const metadata: Record<string, unknown> = {}
411+
metadata.self = metadata
412+
413+
expect(() =>
414+
new TransactionSerializer({}).serialize(metadataTransaction(metadata)),
415+
).toThrowError(new TypeError(`Converting circular structure to JSON`))
416+
})
417+
418+
it(`rejects cyclic mutation values with a bounded JSON-style error`, () => {
419+
const collection = { id: `cycle-writer` } as any
420+
const modified: Record<string, unknown> = { id: `one` }
421+
modified.self = modified
422+
const transaction: OfflineTransaction = {
423+
...metadataTransaction({}),
424+
mutations: [
425+
{
426+
globalKey: `cycle-writer:one`,
427+
type: `insert`,
428+
modified,
429+
original: {},
430+
changes: modified,
431+
collection,
432+
} as PendingMutation,
433+
],
434+
keys: [`cycle-writer:one`],
435+
}
436+
437+
expect(() =>
438+
new TransactionSerializer({ rows: collection }).serialize(transaction),
439+
).toThrowError(new TypeError(`Converting circular structure to JSON`))
440+
})
441+
442+
it(`preserves repeated references that do not form a cycle`, () => {
443+
const shared = { nested: [`value`] }
444+
const transaction = metadataTransaction({ left: shared, right: shared })
445+
446+
const wire = JSON.parse(new TransactionSerializer({}).serialize(transaction))
447+
448+
expect(wire.metadata).toEqual({
449+
left: { nested: [`value`] },
450+
right: { nested: [`value`] },
451+
})
452+
})
453+
454+
it(`preserves spoofed Temporal tags as data while restoring branded values`, async () => {
455+
class PlainDateStub {
456+
readonly #value: string
457+
458+
constructor(value: string) {
459+
this.#value = value
460+
}
461+
462+
static from(value: string): PlainDateStub {
463+
return new PlainDateStub(value)
464+
}
465+
466+
get [Symbol.toStringTag](): `Temporal.PlainDate` {
467+
return `Temporal.PlainDate`
468+
}
469+
470+
toString(): string {
471+
return this.#value
472+
}
473+
}
474+
475+
const temporalGlobal = globalThis as { Temporal?: Record<string, unknown> }
476+
const previousTemporal = temporalGlobal.Temporal
477+
temporalGlobal.Temporal = { PlainDate: PlainDateStub }
478+
const storage = new FakeStorageAdapter()
479+
const outbox = new OutboxManager(storage, {})
480+
const transaction = metadataTransaction({
481+
spoofed: {
482+
keep: `user data`,
483+
[Symbol.toStringTag]: `Temporal.PlainDate`,
484+
toString: () => `not-a-date`,
485+
},
486+
genuine: new PlainDateStub(`2026-09-22`),
487+
})
488+
489+
try {
490+
await outbox.add(transaction)
491+
const restored = await outbox.get(transaction.id)
492+
493+
expect(restored?.metadata).toMatchObject({
494+
spoofed: { keep: `user data` },
495+
genuine: expect.any(PlainDateStub),
496+
})
497+
expect(String((restored?.metadata as any).genuine)).toBe(`2026-09-22`)
498+
} finally {
499+
if (previousTemporal === undefined) delete temporalGlobal.Temporal
500+
else temporalGlobal.Temporal = previousTemporal
501+
}
502+
})
503+
395504
it(`rejects native scalars before storage when global restoration is unavailable`, async () => {
396505
const temporalGlobal = globalThis as { Temporal?: Record<string, unknown> }
397506
const previousTemporal = temporalGlobal.Temporal
@@ -428,16 +537,28 @@ it(`uses one validated Temporal tag when writing a marker`, () => {
428537
const temporalGlobal = globalThis as { Temporal?: Record<string, unknown> }
429538
const previousTemporal = temporalGlobal.Temporal
430539
let reads = 0
431-
temporalGlobal.Temporal = {
432-
PlainDate: { from: (value: string) => value },
433-
}
434-
const value = {
540+
class PlainDateStub {
541+
readonly #value: string
542+
543+
constructor(value: string) {
544+
this.#value = value
545+
}
546+
547+
static from(value: string): PlainDateStub {
548+
return new PlainDateStub(value)
549+
}
550+
435551
get [Symbol.toStringTag]() {
436552
reads++
437553
return reads === 1 ? `Temporal.PlainDate` : `Temporal.Invalid`
438-
},
439-
toString: () => `2026-09-16`,
554+
}
555+
556+
toString(): string {
557+
return this.#value
558+
}
440559
}
560+
temporalGlobal.Temporal = { PlainDate: PlainDateStub }
561+
const value = new PlainDateStub(`2026-09-16`)
441562

442563
try {
443564
const wire = JSON.parse(
@@ -656,10 +777,7 @@ it(`preserves native scalar identity across storage restart`, async () => {
656777
).Temporal
657778
;(globalThis as { Temporal?: Record<string, unknown> }).Temporal =
658779
Object.fromEntries(
659-
temporalCases.map(([name]) => [
660-
name,
661-
{ from: (value: string) => new TemporalStub(name, value) },
662-
]),
780+
temporalCases.map(([name]) => [name, temporalStubConstructor(name)]),
663781
)
664782

665783
const values = Object.fromEntries(

0 commit comments

Comments
 (0)