@@ -30,7 +30,7 @@ interface Backing {
3030 /** Largest stream range response requested, proving replay is paginated. */
3131 maxRangeCount : number
3232 /** Optional deterministic compaction hook invoked before each range page is read. */
33- onRange ?: ( call : number , key : string ) => void
33+ onRange ?: ( call : number , key : string , start : string ) => void
3434 rangeCalls : number
3535 /** Largest multiplexed XREAD request and COUNT observed. */
3636 maxReadStreams : number
@@ -69,7 +69,7 @@ function makeClient(): any {
6969 } ,
7070 xRange : async ( key : string , start : string , end : string , options ?: { COUNT ?: number } ) => {
7171 b ( ) . rangeCalls ++
72- b ( ) . onRange ?.( b ( ) . rangeCalls , key )
72+ b ( ) . onRange ?.( b ( ) . rangeCalls , key , start )
7373 const startId = start . startsWith ( '(' ) ? start . slice ( 1 ) : start
7474 const entries = ( b ( ) . streams . get ( key ) ?? [ ] ) . filter (
7575 ( entry ) =>
@@ -138,6 +138,9 @@ function makeClient(): any {
138138 } ,
139139 eval : async ( script : string , opts : { keys : string [ ] ; arguments : string [ ] } ) => {
140140 const [ key ] = opts . keys
141+ if ( script . includes ( "redis.call('exists', KEYS[1])" ) && ! b ( ) . streams . has ( key ) ) {
142+ return script . includes ( 'zscore' ) ? - 1 : false
143+ }
141144 if ( script . includes ( 'return ARGV[1]' ) ) {
142145 const generation = b ( ) . kv . get ( opts . keys [ 1 ] )
143146 if ( generation !== undefined ) return generation
@@ -291,6 +294,15 @@ function updateFor(text: string): Uint8Array {
291294}
292295
293296let stores : FileDocStore [ ] = [ ]
297+
298+ /** An existing stream from a relay predating generation markers; modern seeds use seedIfEmpty. */
299+ function seedLegacyStream ( update = updateFor ( '' ) ) : void {
300+ const backing = state . backing !
301+ backing . streams . set ( `filedoc:stream:${ NAME } ` , [
302+ { id : `${ ++ backing . seq } -0` , message : { u : Buffer . from ( update ) . toString ( 'base64' ) } } ,
303+ ] )
304+ }
305+
294306async function newStore ( ) : Promise < FileDocStore > {
295307 const store = new FileDocStore ( REDIS_URL )
296308 await store . init ( )
@@ -415,7 +427,7 @@ describe('FileDocStore', () => {
415427 const token = await a . shouldSeed ( NAME )
416428 expect ( token ) . toBeTruthy ( )
417429 // A seeds and releases its lock.
418- a . publish ( NAME , updateFor ( 'hello' ) )
430+ await a . seedIfEmpty ( NAME , updateFor ( 'hello' ) )
419431 await vi . waitFor ( async ( ) => expect ( await a . getStreamState ( NAME ) ) . not . toBeNull ( ) )
420432 await a . releaseSeedLock ( NAME , token as string )
421433 // A different task must NOT seed again — the lock is free but the stream is non-empty.
@@ -426,7 +438,7 @@ describe('FileDocStore', () => {
426438 it ( 'fences stale publishers after invalidation and lets the next authoritative seed start fresh' , async ( ) => {
427439 const store = await newStore ( )
428440 const original = updateFor ( 'old generation' )
429- await store . publishAndWait ( NAME , original )
441+ await store . seedIfEmpty ( NAME , original )
430442 await store . invalidateDocument ( NAME , 10 )
431443
432444 await expect ( store . getStreamState ( NAME ) ) . resolves . toBeNull ( )
@@ -447,7 +459,7 @@ describe('FileDocStore', () => {
447459
448460 it ( 'getStreamState reconstructs the shared document from the stream' , async ( ) => {
449461 const a = await newStore ( )
450- a . publish ( NAME , updateFor ( 'shared content' ) )
462+ await a . seedIfEmpty ( NAME , updateFor ( 'shared content' ) )
451463 let state : Uint8Array | null = null
452464 await vi . waitFor ( async ( ) => {
453465 state = await a . getStreamState ( NAME )
@@ -512,13 +524,31 @@ describe('FileDocStore', () => {
512524 await expect ( store . getStreamState ( NAME ) ) . resolves . toBeNull ( )
513525 } )
514526
527+ it ( 'rejects appends and duplicate acknowledgements when only the stream is lost' , async ( ) => {
528+ const store = await newStore ( )
529+ await store . seedIfEmpty ( NAME , updateFor ( 'base' ) , 10 )
530+ const generation = await store . getDocumentGeneration ( NAME )
531+ const delta = updateFor ( 'edit' )
532+ await store . publishClientUpdateAndWait ( NAME , 'accepted-update' , delta , generation )
533+ state . backing ! . streams . delete ( `filedoc:stream:${ NAME } ` )
534+
535+ await expect ( store . publishAndWait ( NAME , delta , generation ) ) . rejects . toThrow ( 'replaced' )
536+ await expect (
537+ store . publishClientUpdateAndWait ( NAME , 'new-update' , delta , generation )
538+ ) . rejects . toThrow ( 'replaced' )
539+ await expect (
540+ store . publishClientUpdateAndWait ( NAME , 'accepted-update' , delta , generation )
541+ ) . rejects . toThrow ( 'replaced' )
542+ expect ( state . backing ! . streams . has ( `filedoc:stream:${ NAME } ` ) ) . toBe ( false )
543+ } )
544+
515545 it ( 'adopts the identity of a pre-upgrade stream before acknowledging its edits' , async ( ) => {
516546 const store = await newStore ( )
517547 const seed = new Y . Doc ( )
518548 seed . getMap ( 'config' ) . set ( 'initialContentLoaded' , true )
519549 seed . getMap ( 'config' ) . set ( 'docId' , 'legacy-document' )
520550 seed . getText ( 'body' ) . insert ( 0 , 'legacy' )
521- await store . publishAndWait ( NAME , Y . encodeStateAsUpdate ( seed ) )
551+ seedLegacyStream ( Y . encodeStateAsUpdate ( seed ) )
522552 const attached = new Y . Doc ( )
523553 await store . attachRoom ( NAME , attached )
524554 expect ( await store . getDocumentGeneration ( NAME ) ) . toBe ( 'legacy-document' )
@@ -631,9 +661,44 @@ describe('FileDocStore', () => {
631661 recovered . destroy ( )
632662 } )
633663
664+ it ( 'reads the replacement snapshot when peer deltas cross the old replay tail' , async ( ) => {
665+ const streamKey = `filedoc:stream:${ NAME } `
666+ const source = new Y . Doc ( )
667+ const entries : Array < { id : string ; message : Record < string , string > } > = [ ]
668+ source . on ( 'update' , ( update : Uint8Array ) => {
669+ entries . push ( {
670+ id : `${ entries . length + 1 } -0` ,
671+ message : { u : Buffer . from ( update ) . toString ( 'base64' ) } ,
672+ } )
673+ } )
674+ for ( let i = 1 ; i <= 8 ; i ++ )
675+ source . getText ( 'body' ) . insert ( source . getText ( 'body' ) . length , String ( i ) )
676+ const snapshot = Y . encodeStateAsUpdate ( source )
677+ state . backing ! . streams . set ( streamKey , entries . slice ( ) )
678+ for ( let i = 9 ; i <= 11 ; i ++ )
679+ source . getText ( 'body' ) . insert ( source . getText ( 'body' ) . length , String ( i ) )
680+ state . backing ! . onRange = ( _call , key , start ) => {
681+ if ( key !== streamKey || start !== '(4-0' ) return
682+ state . backing ! . streams . set ( streamKey , [
683+ ...entries . slice ( 7 ) ,
684+ { id : '12-0' , message : { u : Buffer . from ( snapshot ) . toString ( 'base64' ) , s : '1' } } ,
685+ ] )
686+ state . backing ! . onRange = undefined
687+ }
688+ const store = await newStore ( )
689+ const recovered = new Y . Doc ( )
690+ try {
691+ Y . applyUpdate ( recovered , ( await store . getStreamState ( NAME ) ) ! )
692+ expect ( recovered . getText ( 'body' ) . toString ( ) ) . toBe ( source . getText ( 'body' ) . toString ( ) )
693+ } finally {
694+ source . destroy ( )
695+ recovered . destroy ( )
696+ }
697+ } )
698+
634699 it ( 'attachRoom catches a fresh task up to the current shared state' , async ( ) => {
635700 const a = await newStore ( )
636- a . publish ( NAME , updateFor ( 'already here' ) )
701+ await a . seedIfEmpty ( NAME , updateFor ( 'already here' ) )
637702 await vi . waitFor ( async ( ) => expect ( await a . getStreamState ( NAME ) ) . not . toBeNull ( ) )
638703
639704 // A second task opens the same file: its doc must load the existing content, not start empty.
@@ -645,6 +710,7 @@ describe('FileDocStore', () => {
645710 } )
646711
647712 it ( 'converges a peer task via the tailer after attach' , async ( ) => {
713+ seedLegacyStream ( )
648714 const a = await newStore ( )
649715 const b = await newStore ( )
650716 const bDoc = new Y . Doc ( )
@@ -707,6 +773,7 @@ describe('FileDocStore', () => {
707773 } )
708774
709775 it ( 'tags an agent-streamed frame so a peer tailer applies it as REDIS_AGENT_ORIGIN (never persisted)' , async ( ) => {
776+ seedLegacyStream ( )
710777 const streamKey = `filedoc:stream:${ NAME } `
711778 const a = await newStore ( )
712779 const b = await newStore ( )
@@ -732,6 +799,7 @@ describe('FileDocStore', () => {
732799 } )
733800
734801 it ( 'latches realEdited synchronously so a concurrent compaction can never mislabel a real edit' , async ( ) => {
802+ seedLegacyStream ( )
735803 // The data-loss race: a real edit sits in room.doc synchronously, but if realEdited were set only
736804 // AFTER appendUpdate's awaits, a concurrent agent-triggered compaction could snapshot that content and
737805 // stamp it an agent (no-persist) frame — losing the edit. The latch must be set in the same tick.
@@ -787,6 +855,7 @@ describe('FileDocStore', () => {
787855 } )
788856
789857 it ( 'retries a transient append failure so the edit is not lost from the shared log' , async ( ) => {
858+ seedLegacyStream ( )
790859 const a = await newStore ( )
791860 state . backing ! . failXAdd = 2 // first two xAdd attempts throw; the third must succeed
792861 a . publish ( NAME , updateFor ( 'resilient' ) )
@@ -802,34 +871,38 @@ describe('FileDocStore', () => {
802871 } )
803872
804873 it ( 'deduplicates acknowledged client retries by update id' , async ( ) => {
874+ seedLegacyStream ( )
805875 const store = await newStore ( )
806876 const update = updateFor ( 'retry-safe' )
807877
808878 await store . publishClientUpdateAndWait ( NAME , 'update-1' , update )
809879 await store . publishClientUpdateAndWait ( NAME , 'update-1' , update )
810880
811- expect ( state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ) . toHaveLength ( 1 )
881+ expect ( state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ) . toHaveLength ( 2 )
812882 } )
813883
814884 it ( 'does not drop different payloads that reuse an acknowledged update id' , async ( ) => {
885+ seedLegacyStream ( )
815886 const store = await newStore ( )
816887
817888 await store . publishClientUpdateAndWait ( NAME , 'update-1' , updateFor ( 'first' ) )
818889 await store . publishClientUpdateAndWait ( NAME , 'update-1' , updateFor ( 'second' ) )
819890
820- expect ( state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ) . toHaveLength ( 2 )
891+ expect ( state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ) . toHaveLength ( 3 )
821892 } )
822893
823894 it ( 'uses unambiguous acknowledged-update deduplication keys' , async ( ) => {
895+ seedLegacyStream ( )
824896 const store = await newStore ( )
825897
826898 await store . publishClientUpdateAndWait ( NAME , 'a' , new Uint8Array ( [ 0 , 98 ] ) )
827899 await store . publishClientUpdateAndWait ( NAME , 'a\0' , new Uint8Array ( [ 98 ] ) )
828900
829- expect ( state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ) . toHaveLength ( 2 )
901+ expect ( state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ) . toHaveLength ( 3 )
830902 } )
831903
832904 it ( 'bounds acknowledged-update deduplication independently of stream traffic' , async ( ) => {
905+ seedLegacyStream ( )
833906 const store = await newStore ( )
834907 const update = updateFor ( 'bounded' )
835908
@@ -923,6 +996,7 @@ describe('FileDocStore', () => {
923996 } )
924997
925998 it ( 'preserves exactly the delta bytes observed after a compaction barrier' , async ( ) => {
999+ seedLegacyStream ( )
9261000 const store = await newStore ( )
9271001 const doc = new Y . Doc ( )
9281002 await store . attachRoom ( NAME , doc )
@@ -984,7 +1058,7 @@ describe('FileDocStore', () => {
9841058 it ( 'streamHasContent fences a seed apply against an already-seeded stream' , async ( ) => {
9851059 const a = await newStore ( )
9861060 expect ( await a . streamHasContent ( NAME ) ) . toBe ( false )
987- a . publish ( NAME , updateFor ( 'seeded' ) )
1061+ await a . seedIfEmpty ( NAME , updateFor ( 'seeded' ) )
9881062 await vi . waitFor ( async ( ) => expect ( await a . streamHasContent ( NAME ) ) . toBe ( true ) )
9891063 } )
9901064
@@ -1065,7 +1139,7 @@ describe('FileDocStore', () => {
10651139 author . getText ( 'body' ) . insert ( 4 , 'peer' )
10661140
10671141 const a = await newStore ( )
1068- a . publish ( NAME , updates [ 0 ] ) // 'base'
1142+ seedLegacyStream ( updates [ 0 ] )
10691143 await vi . waitFor ( async ( ) => expect ( await a . getStreamState ( NAME ) ) . not . toBeNull ( ) )
10701144
10711145 // Task B attaches; while its synchronous catch-up runs, task A publishes the second edit. The tailer
0 commit comments