@@ -138,6 +138,7 @@ function makeClient(): any {
138138 } ,
139139 eval : async ( script : string , opts : { keys : string [ ] ; arguments : string [ ] } ) => {
140140 const [ key ] = opts . keys
141+ if ( script . startsWith ( 'for _, key in ipairs(KEYS)' ) ) return 1
141142 if ( script . includes ( "redis.call('exists', KEYS[1])" ) && ! b ( ) . streams . has ( key ) ) {
142143 return script . includes ( 'zscore' ) ? - 1 : false
143144 }
@@ -149,13 +150,16 @@ function makeClient(): any {
149150 return opts . arguments [ 0 ]
150151 }
151152 if ( script . includes ( "redis.call('del', KEYS[1], KEYS[4], KEYS[5])" ) ) {
152- const [ , generationKey , versionKey , dedupeKey , agentKey ] = opts . keys
153+ const [ , generationKey , versionKey , dedupeKey , agentKey , invalidationKey ] = opts . keys
153154 const [ version , , marker ] = opts . arguments
154155 const current = b ( ) . kv . get ( versionKey )
156+ const invalidated = b ( ) . kv . get ( invalidationKey )
157+ if ( invalidated && Number ( invalidated ) >= Number ( version ) ) return 0
155158 if ( current && Number ( current ) > Number ( version ) ) return 0
156159 if ( current === version && b ( ) . kv . get ( generationKey ) === marker ) return 0
157160 b ( ) . kv . set ( generationKey , marker )
158161 b ( ) . kv . set ( versionKey , version )
162+ b ( ) . kv . set ( invalidationKey , version )
159163 b ( ) . streams . delete ( key )
160164 b ( ) . dedupe . delete ( dedupeKey )
161165 b ( ) . kv . delete ( agentKey )
@@ -258,7 +262,7 @@ const REDIS_URL = 'redis://fake'
258262const NAME = 'workspace-file-doc:file-1'
259263
260264interface StoreTestAccess {
261- localInvalidations : Map < string , number >
265+ localInvalidations : Map < string , { version : number ; expiresAt : number } >
262266 rooms : Map <
263267 string ,
264268 {
@@ -509,6 +513,41 @@ describe('FileDocStore', () => {
509513 await expect ( store . getStreamState ( NAME ) ) . resolves . toBeNull ( )
510514 } )
511515
516+ it ( 'does not repeat an invalidation after the same durable version is reseeded' , async ( ) => {
517+ const store = await newStore ( )
518+ await store . seedIfEmpty ( NAME , updateFor ( 'old' ) , 10 )
519+ await expect ( store . invalidateDocument ( NAME , 20 ) ) . resolves . toBe ( true )
520+ await expect ( store . seedIfEmpty ( NAME , updateFor ( 'replacement' ) , 20 ) ) . resolves . toBe ( true )
521+ const generation = await store . getDocumentGeneration ( NAME )
522+ const doc = new Y . Doc ( )
523+ Y . applyUpdate ( doc , ( await store . getStreamState ( NAME ) ) ! )
524+ const before = Y . encodeStateVector ( doc )
525+ doc . getText ( 'body' ) . insert ( 11 , ' accepted' )
526+ await store . publishClientUpdateAndWait (
527+ NAME ,
528+ 'accepted-edit' ,
529+ Y . encodeStateAsUpdate ( doc , before ) ,
530+ generation
531+ )
532+
533+ await expect ( store . invalidateDocument ( NAME , 20 ) ) . resolves . toBe ( false )
534+ expect ( await store . getDocumentGeneration ( NAME ) ) . toBe ( generation )
535+ const recovered = new Y . Doc ( )
536+ Y . applyUpdate ( recovered , ( await store . getStreamState ( NAME ) ) ! )
537+ expect ( recovered . getText ( 'body' ) . toString ( ) ) . toBe ( 'replacement accepted' )
538+ expect ( state . backing ! . dedupe . get ( `filedoc:updates:${ NAME } ` ) ) . toHaveLength ( 1 )
539+ doc . destroy ( )
540+ recovered . destroy ( )
541+ } )
542+
543+ it ( 'applies the first invalidation even when its durable version was already seeded' , async ( ) => {
544+ const store = await newStore ( )
545+ await store . seedIfEmpty ( NAME , updateFor ( 'same content, changed eligibility' ) , 20 )
546+ await expect ( store . invalidateDocument ( NAME , 20 ) ) . resolves . toBe ( true )
547+ await expect ( store . invalidateDocument ( NAME , 20 ) ) . resolves . toBe ( false )
548+ await expect ( store . getStreamState ( NAME ) ) . resolves . toBeNull ( )
549+ } )
550+
512551 it ( 'does not resurrect a tracked stream with a dependency-only update after Redis loses it' , async ( ) => {
513552 const store = await newStore ( )
514553 await store . seedIfEmpty ( NAME , updateFor ( 'base' ) , 10 )
@@ -576,6 +615,51 @@ describe('FileDocStore', () => {
576615 await expect ( store . getStreamState ( NAME ) ) . rejects . toThrow ( 'replaced' )
577616 } )
578617
618+ it . each ( [ true , false ] ) (
619+ 'validates a modern snapshot following a legacy seed (same identity: %s)' ,
620+ async ( sameIdentity ) => {
621+ const store = await newStore ( )
622+ const seed = new Y . Doc ( )
623+ seed . getMap ( 'config' ) . set ( 'initialContentLoaded' , true )
624+ seed . getMap ( 'config' ) . set ( 'docId' , 'legacy-document' )
625+ seed . getText ( 'body' ) . insert ( 0 , 'legacy' )
626+ seedLegacyStream ( Y . encodeStateAsUpdate ( seed ) )
627+ const backing = state . backing !
628+ backing . kv . set (
629+ `filedoc:generation:${ NAME } ` ,
630+ sameIdentity ? 'legacy-document' : 'different-document'
631+ )
632+ backing . streams . get ( `filedoc:stream:${ NAME } ` ) ! . push ( {
633+ id : `${ ++ backing . seq } -0` ,
634+ message : {
635+ u : Buffer . from ( Y . encodeStateAsUpdate ( seed ) ) . toString ( 'base64' ) ,
636+ s : '1' ,
637+ g : sameIdentity ? 'legacy-document' : 'different-document' ,
638+ } ,
639+ } )
640+ const attached = new Y . Doc ( )
641+ if ( sameIdentity ) {
642+ await store . attachRoom ( NAME , attached )
643+ const before = Y . encodeStateVector ( seed )
644+ seed . getText ( 'body' ) . insert ( 6 , ' peer' )
645+ await store . publishClientUpdateAndWait (
646+ NAME ,
647+ 'peer-edit' ,
648+ Y . encodeStateAsUpdate ( seed , before ) ,
649+ 'legacy-document'
650+ )
651+ await store . catchUp ( NAME )
652+ expect ( attached . getText ( 'body' ) . toString ( ) ) . toBe ( 'legacy peer' )
653+ store . detachRoom ( NAME )
654+ } else {
655+ await expect ( store . attachRoom ( NAME , attached ) ) . rejects . toThrow ( 'replaced' )
656+ expect ( storeInternals ( store ) . rooms . has ( NAME ) ) . toBe ( false )
657+ }
658+ seed . destroy ( )
659+ attached . destroy ( )
660+ }
661+ )
662+
579663 it ( 'replays stream history in bounded pages' , async ( ) => {
580664 const streamKey = `filedoc:stream:${ NAME } `
581665 const noop = Buffer . from ( updateFor ( '' ) ) . toString ( 'base64' )
@@ -1083,26 +1167,43 @@ describe('FileDocStore', () => {
10831167 doc . destroy ( )
10841168 } )
10851169
1086- it ( 'bounds single-replica invalidation markers to the lifetime of active rooms' , async ( ) => {
1170+ it ( 'expires idle single-replica invalidation watermarks' , async ( ) => {
1171+ vi . useFakeTimers ( )
10871172 const store = new FileDocStore ( undefined )
1088- for ( let index = 0 ; index < 100 ; index ++ ) {
1089- await store . invalidateDocument ( `closed-${ index } ` , 10 )
1173+ try {
1174+ for ( let index = 0 ; index < 100 ; index ++ ) {
1175+ await store . invalidateDocument ( `closed-${ index } ` , 10 )
1176+ }
1177+ expect ( storeInternals ( store ) . localInvalidations . size ) . toBe ( 100 )
1178+ await vi . advanceTimersByTimeAsync ( 660_000 )
1179+ expect ( storeInternals ( store ) . localInvalidations . size ) . toBe ( 0 )
1180+ } finally {
1181+ await store . shutdown ( )
1182+ vi . useRealTimers ( )
10901183 }
1091- expect ( storeInternals ( store ) . localInvalidations . size ) . toBe ( 0 )
1184+ } )
1185+
1186+ it ( 'deduplicates single-replica invalidations across same-version seeds and room reopen' , async ( ) => {
1187+ const store = new FileDocStore ( undefined )
10921188 const staleDoc = new Y . Doc ( )
10931189 await store . attachRoom ( NAME , staleDoc )
10941190 await store . invalidateDocument ( NAME , 20 )
10951191 await expect ( store . seedIfEmpty ( NAME , updateFor ( 'stale fetched seed' ) , 10 ) ) . resolves . toBe ( false )
10961192 await expect ( store . isDocumentGenerationCurrent ( NAME ) ) . resolves . toBe ( false )
10971193 expect ( storeInternals ( store ) . localInvalidations . size ) . toBe ( 1 )
1194+ await expect ( store . seedIfEmpty ( NAME , updateFor ( 'same-version seed' ) , 20 ) ) . resolves . toBe ( true )
1195+ await expect ( store . invalidateDocument ( NAME , 20 ) ) . resolves . toBe ( false )
1196+ await expect ( store . isDocumentGenerationCurrent ( NAME ) ) . resolves . toBe ( true )
10981197
10991198 store . detachRoom ( NAME )
1100- expect ( storeInternals ( store ) . localInvalidations . size ) . toBe ( 0 )
1199+ expect ( storeInternals ( store ) . localInvalidations . size ) . toBe ( 1 )
11011200 const freshDoc = new Y . Doc ( )
11021201 await store . attachRoom ( NAME , freshDoc )
1103- await expect ( store . seedIfEmpty ( NAME , updateFor ( 'fresh authoritative seed' ) , 30 ) ) . resolves . toBe (
1202+ await expect ( store . seedIfEmpty ( NAME , updateFor ( 'fresh authoritative seed' ) , 20 ) ) . resolves . toBe (
11041203 true
11051204 )
1205+ await expect ( store . invalidateDocument ( NAME , 20 ) ) . resolves . toBe ( false )
1206+ await expect ( store . isDocumentGenerationCurrent ( NAME ) ) . resolves . toBe ( true )
11061207 await store . invalidateDocument ( NAME , 40 )
11071208 await store . shutdown ( )
11081209 expect ( storeInternals ( store ) . localInvalidations . size ) . toBe ( 0 )
0 commit comments