@@ -128,6 +128,31 @@ vi.mock('redis', () => ({ createClient: () => makeClient() }))
128128import { FileDocStore , REDIS_AGENT_ORIGIN , REDIS_ORIGIN } from '@/handlers/file-doc-store'
129129
130130const REDIS_URL = 'redis://fake'
131+
132+ interface StoreRoomInternals {
133+ lastId : string
134+ pendingDeltas : Map < string , number >
135+ realEdited : boolean
136+ publishes : number
137+ compactRetryAfter : number
138+ doc : Y . Doc
139+ seededObserved : boolean
140+ }
141+
142+ interface FileDocStoreInternals {
143+ rooms : Map < string , StoreRoomInternals >
144+ applyEntry ( room : StoreRoomInternals , id : string , message : Record < string , string > ) : void
145+ appendUpdate ( name : string , update : Uint8Array , agent ?: boolean ) : Promise < void >
146+ write : { xTrim : ( ...args : unknown [ ] ) => Promise < unknown > }
147+ maybeCompact ( name : string , force ?: boolean ) : Promise < void >
148+ }
149+
150+ /** Reaches the private state these tests assert on, without `any`. */
151+ function internals ( store : object ) : FileDocStoreInternals {
152+ return store as unknown as FileDocStoreInternals
153+ }
154+
155+ const COMPACT_THRESHOLD_ENTRIES = 400
131156const NAME = 'workspace-file-doc:file-1'
132157
133158function docWithText ( text : string ) : Y . Doc {
@@ -338,14 +363,16 @@ describe('FileDocStore', () => {
338363 const a = await newStore ( )
339364 // This task has integrated only up to entry 400 (all no-ops) — its local doc is empty and lags the
340365 // two peer entries. Inject that lagging room directly (a real edit was integrated → realEdited).
341- ; ( a as any ) . rooms . set ( NAME , {
366+ internals ( a ) . rooms . set ( NAME , {
342367 doc : new Y . Doc ( ) ,
343368 lastId : '400-0' ,
344369 publishes : 0 ,
370+ compactRetryAfter : 0 ,
371+ pendingDeltas : new Map ( ) ,
345372 seededObserved : true ,
346373 realEdited : true ,
347374 } )
348- await ( a as any ) . maybeCompact ( NAME )
375+ await internals ( a ) . maybeCompact ( NAME )
349376
350377 // A fresh catch-up must still reconstruct the peer content — compaction must not have trimmed 401/402.
351378 const doc = new Y . Doc ( )
@@ -391,16 +418,241 @@ describe('FileDocStore', () => {
391418 const a = await newStore ( )
392419 const doc = new Y . Doc ( )
393420 await a . attachRoom ( NAME , doc )
394- const room = ( a as any ) . rooms . get ( NAME )
421+ const room = internals ( a ) . rooms . get ( NAME ) !
395422 expect ( room . realEdited ) . toBe ( false )
396423 // Kick off a real (non-agent) append but do NOT await it: realEdited must already be true before the
397424 // xAdd/expire awaits resolve, so any compaction racing on the awaits sees the real edit.
398- const pending = ( a as any ) . appendUpdate ( NAME , updateFor ( 'real user edit' ) )
425+ const pending = internals ( a ) . appendUpdate ( NAME , updateFor ( 'real user edit' ) )
399426 expect ( room . realEdited ) . toBe ( true )
400427 await pending
401428 doc . destroy ( )
402429 } )
403430
431+ it ( 'compacts on appended bytes, before the entry threshold is anywhere near reached' , async ( ) => {
432+ const streamKey = `filedoc:stream:${ NAME } `
433+ const a = await newStore ( )
434+ const doc = new Y . Doc ( )
435+ await a . attachRoom ( NAME , doc )
436+
437+ // A handful of large pastes: far below COMPACT_THRESHOLD entries, far above the byte ceiling.
438+ // Before bytes were counted this stream held tens of megabytes and never compacted.
439+ const updates : Uint8Array [ ] = [ ]
440+ doc . on ( 'update' , ( u : Uint8Array ) => updates . push ( u ) )
441+ for ( let i = 0 ; i < 4 ; i ++ ) {
442+ doc . getText ( 'body' ) . insert ( 0 , 'x' . repeat ( 3 * 1024 * 1024 ) )
443+ }
444+ for ( const update of updates ) {
445+ await a . publishAndWait ( NAME , update )
446+ }
447+
448+ await vi . waitFor (
449+ ( ) => {
450+ const stream = state . backing ! . streams . get ( streamKey ) !
451+ expect ( stream . length ) . toBeLessThan ( COMPACT_THRESHOLD_ENTRIES )
452+ expect ( stream . some ( ( entry ) => entry . message . s === '1' ) ) . toBe ( true )
453+ } ,
454+ { timeout : 5000 }
455+ )
456+
457+ // Compaction must be lossless: the whole document is still reconstructable from what remains.
458+ const rebuilt = new Y . Doc ( )
459+ Y . applyUpdate ( rebuilt , ( await a . getStreamState ( NAME ) ) ! )
460+ expect ( rebuilt . getText ( 'body' ) . length ) . toBe ( 4 * 3 * 1024 * 1024 )
461+ rebuilt . destroy ( )
462+ doc . destroy ( )
463+ } )
464+
465+ it ( 'does not re-compact on every publish once the document itself exceeds the byte ceiling' , async ( ) => {
466+ const streamKey = `filedoc:stream:${ NAME } `
467+ const a = await newStore ( )
468+ const doc = new Y . Doc ( )
469+ await a . attachRoom ( NAME , doc )
470+
471+ const updates : Uint8Array [ ] = [ ]
472+ doc . on ( 'update' , ( u : Uint8Array ) => updates . push ( u ) )
473+ // Grow the document past the byte ceiling so its own snapshot exceeds it, then keep editing.
474+ // Counting the snapshot as appended bytes would leave the threshold permanently breached and
475+ // force a full snapshot append per keystroke — the amplification the threshold exists to stop.
476+ doc . getText ( 'body' ) . insert ( 0 , 'x' . repeat ( 12 * 1024 * 1024 ) )
477+ for ( let i = 0 ; i < 30 ; i ++ ) doc . getText ( 'body' ) . insert ( 0 , 'tiny' )
478+ for ( const update of updates ) {
479+ await a . publishAndWait ( NAME , update )
480+ }
481+ await vi . waitFor ( ( ) => {
482+ const stream = state . backing ! . streams . get ( streamKey ) !
483+ expect ( stream . some ( ( entry ) => entry . message . s === '1' ) ) . toBe ( true )
484+ } )
485+
486+ const snapshots = state
487+ . backing ! . streams . get ( streamKey ) !
488+ . filter ( ( entry ) => entry . message . s === '1' ) . length
489+ expect ( snapshots ) . toBeLessThanOrEqual ( 2 )
490+
491+ const rebuilt = new Y . Doc ( )
492+ Y . applyUpdate ( rebuilt , ( await a . getStreamState ( NAME ) ) ! )
493+ expect ( rebuilt . getText ( 'body' ) . toString ( ) . startsWith ( 'tiny' ) ) . toBe ( true )
494+ expect ( rebuilt . getText ( 'body' ) . length ) . toBe ( 12 * 1024 * 1024 + 30 * 4 )
495+ rebuilt . destroy ( )
496+ doc . destroy ( )
497+ } )
498+
499+ it ( 'keeps the byte trigger armed when compaction fails' , async ( ) => {
500+ const a = await newStore ( )
501+ const doc = new Y . Doc ( )
502+ await a . attachRoom ( NAME , doc )
503+ const room = internals ( a ) . rooms . get ( NAME ) !
504+ room . pendingDeltas = new Map ( [ [ '1-0' , 9 * 1024 * 1024 ] ] )
505+ room . realEdited = true
506+
507+ const write = internals ( a ) . write
508+ const original = write . xTrim . bind ( write )
509+ write . xTrim = async ( ) => {
510+ throw new Error ( 'redis blip' )
511+ }
512+ await internals ( a ) . maybeCompact ( NAME , true )
513+
514+ // A failed fold must not disarm the trigger — otherwise the stream stays oversized until
515+ // this task happens to append another full threshold's worth of deltas.
516+ expect ( [ ...room . pendingDeltas ] ) . toEqual ( [ [ '1-0' , 9 * 1024 * 1024 ] ] )
517+
518+ // But it must not retry immediately either: the snapshot XADD lands before the XTRIM, so a
519+ // persistent trim failure would append a full-document snapshot on every attempt.
520+ const snapshotsAfterFailure = state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ?. length ?? 0
521+ await internals ( a ) . maybeCompact ( NAME , true )
522+ await internals ( a ) . maybeCompact ( NAME , true )
523+ expect ( state . backing ! . streams . get ( `filedoc:stream:${ NAME } ` ) ?. length ?? 0 ) . toBe (
524+ snapshotsAfterFailure
525+ )
526+
527+ write . xTrim = original
528+ doc . destroy ( )
529+ } )
530+
531+ it ( 'keeps counting deltas the trim retained because they sit past the fold boundary' , async ( ) => {
532+ const a = await newStore ( )
533+ const doc = new Y . Doc ( )
534+ await a . attachRoom ( NAME , doc )
535+ const room = internals ( a ) . rooms . get ( NAME ) !
536+ room . realEdited = true
537+ // The tailer has integrated up to 5-0, so `MINID 5-0` retains both 5-0 (the boundary is
538+ // INCLUSIVE) and 9-0. Their bytes are still in Redis, and dropping them would disarm the
539+ // byte trigger while the stream kept growing.
540+ room . lastId = '5-0'
541+ room . pendingDeltas = new Map ( [
542+ [ '3-0' , 4 * 1024 * 1024 ] ,
543+ [ '5-0' , 6 * 1024 * 1024 ] ,
544+ [ '9-0' , 7 * 1024 * 1024 ] ,
545+ ] )
546+
547+ await internals ( a ) . maybeCompact ( NAME , true )
548+
549+ expect ( [ ...room . pendingDeltas ] ) . toEqual ( [
550+ [ '5-0' , 6 * 1024 * 1024 ] ,
551+ [ '9-0' , 7 * 1024 * 1024 ] ,
552+ ] )
553+ doc . destroy ( )
554+ } )
555+
556+ it ( 'adopts accounting for a stream it takes over, and folds it if already over the ceiling' , async ( ) => {
557+ const streamKey = `filedoc:stream:${ NAME } `
558+ // A stream left behind by a previous task: two entries, so far under the entry threshold, and
559+ // far over the byte ceiling. A fresh room starting from an empty ledger would never fold it,
560+ // while its own heartbeat kept refreshing the TTL.
561+ const seedDoc = new Y . Doc ( )
562+ const updates : Uint8Array [ ] = [ ]
563+ seedDoc . on ( 'update' , ( u : Uint8Array ) => updates . push ( u ) )
564+ seedDoc . getText ( 'body' ) . insert ( 0 , 'x' . repeat ( 9 * 1024 * 1024 ) )
565+ seedDoc . getText ( 'body' ) . insert ( 0 , 'tail' )
566+ state . backing ! . streams . set (
567+ streamKey ,
568+ updates . map ( ( update , index ) => ( {
569+ id : `${ index + 1 } -0` ,
570+ message : { u : Buffer . from ( update ) . toString ( 'base64' ) } ,
571+ } ) )
572+ )
573+ state . backing ! . seq = updates . length
574+
575+ const a = await newStore ( )
576+ const doc = new Y . Doc ( )
577+ await a . attachRoom ( NAME , doc )
578+
579+ // Either marker counts as a fold: this room only ever replayed entries, so it never observed
580+ // a real edit and its snapshot is stamped as an agent frame (the no-persist guarantee).
581+ await vi . waitFor ( ( ) => {
582+ const stream = state . backing ! . streams . get ( streamKey ) !
583+ expect ( stream . some ( ( entry ) => entry . message . s === '1' || entry . message . a === '1' ) ) . toBe ( true )
584+ } )
585+
586+ // Lossless: the adopted content survives the fold it triggered.
587+ const rebuilt = new Y . Doc ( )
588+ Y . applyUpdate ( rebuilt , ( await a . getStreamState ( NAME ) ) ! )
589+ expect ( rebuilt . getText ( 'body' ) . length ) . toBe ( 9 * 1024 * 1024 + 4 )
590+ rebuilt . destroy ( )
591+ doc . destroy ( )
592+ seedDoc . destroy ( )
593+ } )
594+
595+ it ( 'counts agent preview deltas, which share a marker with an agent-only snapshot' , async ( ) => {
596+ const streamKey = `filedoc:stream:${ NAME } `
597+ // Agent preview frames are the LARGE ones — a copilot file edit re-serialising a document is
598+ // what filled Redis. They carry the same marker as a fold of an agent-only stream, so keying
599+ // exclusion on that marker would drop exactly the payloads this bound exists for.
600+ const seedDoc = new Y . Doc ( )
601+ const updates : Uint8Array [ ] = [ ]
602+ seedDoc . on ( 'update' , ( u : Uint8Array ) => updates . push ( u ) )
603+ seedDoc . getText ( 'body' ) . insert ( 0 , 'x' . repeat ( 9 * 1024 * 1024 ) )
604+ seedDoc . getText ( 'body' ) . insert ( 0 , 'tail' )
605+ state . backing ! . streams . set (
606+ streamKey ,
607+ updates . map ( ( update , index ) => ( {
608+ id : `${ index + 1 } -0` ,
609+ message : { u : Buffer . from ( update ) . toString ( 'base64' ) , a : '1' } ,
610+ } ) )
611+ )
612+ state . backing ! . seq = updates . length
613+
614+ const a = await newStore ( )
615+ const doc = new Y . Doc ( )
616+ await a . attachRoom ( NAME , doc )
617+
618+ await vi . waitFor ( ( ) => {
619+ const stream = state . backing ! . streams . get ( streamKey ) !
620+ expect ( stream . some ( ( entry ) => entry . message . c === '1' ) ) . toBe ( true )
621+ } )
622+
623+ const rebuilt = new Y . Doc ( )
624+ Y . applyUpdate ( rebuilt , ( await a . getStreamState ( NAME ) ) ! )
625+ expect ( rebuilt . getText ( 'body' ) . length ) . toBe ( 9 * 1024 * 1024 + 4 )
626+ rebuilt . destroy ( )
627+ doc . destroy ( )
628+ seedDoc . destroy ( )
629+ } )
630+
631+ it ( "never counts a fold's own output, so a large document cannot arm the trigger against itself" , async ( ) => {
632+ const a = await newStore ( )
633+ const doc = new Y . Doc ( )
634+ await a . attachRoom ( NAME , doc )
635+ const room = internals ( a ) . rooms . get ( NAME ) !
636+
637+ internals ( a ) . applyEntry ( room , '7-0' , { u : 'x' . repeat ( 9 * 1024 * 1024 ) , a : '1' , c : '1' } )
638+
639+ expect ( room . pendingDeltas . has ( '7-0' ) ) . toBe ( false )
640+ doc . destroy ( )
641+ } )
642+
643+ it ( 'counts a delta published by a peer task, which this room only ever tails' , async ( ) => {
644+ const a = await newStore ( )
645+ const doc = new Y . Doc ( )
646+ await a . attachRoom ( NAME , doc )
647+ const room = internals ( a ) . rooms . get ( NAME ) !
648+
649+ // Never published locally, so publish-side accounting would miss it entirely.
650+ internals ( a ) . applyEntry ( room , '4-0' , { u : 'x' . repeat ( 1024 ) } )
651+
652+ expect ( room . pendingDeltas . get ( '4-0' ) ) . toBe ( 1024 )
653+ doc . destroy ( )
654+ } )
655+
404656 it ( 'stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)' , async ( ) => {
405657 const streamKey = `filedoc:stream:${ NAME } `
406658 const noop = Buffer . from ( Y . encodeStateAsUpdate ( new Y . Doc ( ) ) ) . toString ( 'base64' )
@@ -414,14 +666,16 @@ describe('FileDocStore', () => {
414666 state . backing ! . seq = 400
415667
416668 const a = await newStore ( )
417- ; ( a as any ) . rooms . set ( NAME , {
669+ internals ( a ) . rooms . set ( NAME , {
418670 doc : agentDoc ,
419671 lastId : '400-0' ,
420672 publishes : 0 ,
673+ compactRetryAfter : 0 ,
674+ pendingDeltas : new Map ( ) ,
421675 seededObserved : true ,
422676 realEdited : false ,
423677 } )
424- await ( a as any ) . maybeCompact ( NAME )
678+ await internals ( a ) . maybeCompact ( NAME )
425679
426680 // The snapshot must carry the AGENT marker, NOT the snapshot marker, so a peer catch-up applies it as
427681 // REDIS_AGENT_ORIGIN and never marks the doc edited — the no-persist guarantee survives compaction.
@@ -580,21 +834,25 @@ describe('FileDocStore', () => {
580834 const b = await newStore ( )
581835 const docA = new Y . Doc ( )
582836 Y . applyUpdate ( docA , peerUpdates [ 0 ] ) // A integrated up to 401
583- ; ( a as any ) . rooms . set ( NAME , {
837+ internals ( a ) . rooms . set ( NAME , {
584838 doc : docA ,
585839 lastId : '401-0' ,
586840 publishes : 0 ,
841+ compactRetryAfter : 0 ,
842+ pendingDeltas : new Map ( ) ,
587843 seededObserved : true ,
588844 realEdited : true ,
589845 } )
590- ; ( b as any ) . rooms . set ( NAME , {
846+ internals ( b ) . rooms . set ( NAME , {
591847 doc : new Y . Doc ( ) ,
592848 lastId : '400-0' ,
593849 publishes : 0 ,
850+ compactRetryAfter : 0 ,
851+ pendingDeltas : new Map ( ) ,
594852 seededObserved : true ,
595853 realEdited : true ,
596854 } )
597- await Promise . all ( [ ( a as any ) . maybeCompact ( NAME ) , ( b as any ) . maybeCompact ( NAME ) ] )
855+ await Promise . all ( [ internals ( a ) . maybeCompact ( NAME ) , internals ( b ) . maybeCompact ( NAME ) ] )
598856
599857 const doc = new Y . Doc ( )
600858 Y . applyUpdate ( doc , ( await a . getStreamState ( NAME ) ) ! )
0 commit comments