@@ -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
5153type 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+
378392function 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+
395504it ( `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