@@ -56,6 +56,40 @@ impl CrdtState {
5656 . map_err ( |e| CrdtError :: Loro ( format ! ( "delta export: {e}" ) ) )
5757 }
5858
59+ /// Next op counter this document's own peer will author.
60+ ///
61+ /// Equivalently, how many ops this peer has authored so far. Looked up
62+ /// directly in the oplog version vector rather than tracked separately,
63+ /// so it can never drift from what `doc.oplog_vv()` actually reports.
64+ pub fn local_op_counter ( & self ) -> i32 {
65+ self . doc . oplog_vv ( ) . get ( & self . peer_id ) . copied ( ) . unwrap_or ( 0 )
66+ }
67+
68+ /// Export exactly the ops this document's own peer authored in
69+ /// `[from_counter, to_counter)`.
70+ ///
71+ /// A deferred batch of local writes must be split into one
72+ /// self-contained delta per row so each can be committed independently
73+ /// downstream. Because each row's ops occupy a contiguous range in this
74+ /// peer's own op sequence (interleaving never happens within a single
75+ /// author), a bounded range export over that peer's `IdSpan` yields
76+ /// exactly that row's operations — nothing from before, nothing from
77+ /// after, and nothing from any other peer. Unlike `export_updates_since`,
78+ /// whose cost is proportional to everything after a version, this is
79+ /// bounded by the width of the requested span.
80+ ///
81+ /// An empty or inverted range (`to_counter <= from_counter`) is not an
82+ /// error — it simply exports nothing.
83+ pub fn export_local_range ( & self , from_counter : i32 , to_counter : i32 ) -> Result < Vec < u8 > > {
84+ if to_counter <= from_counter {
85+ return Ok ( Vec :: new ( ) ) ;
86+ }
87+ let span = loro:: IdSpan :: new ( self . peer_id , from_counter, to_counter) ;
88+ self . doc
89+ . export ( loro:: ExportMode :: updates_in_range ( vec ! [ span] ) )
90+ . map_err ( |e| CrdtError :: Loro ( format ! ( "bounded range export failed: {e}" ) ) )
91+ }
92+
5993 /// Compact history at a specific version (not just current frontiers).
6094 ///
6195 /// Discards oplog entries before the target version. Current state and
@@ -303,4 +337,73 @@ mod tests {
303337 . is_empty( )
304338 ) ;
305339 }
340+
341+ #[ test]
342+ fn local_op_counter_starts_at_zero_and_advances ( ) {
343+ let state = CrdtState :: new ( 1 ) . expect ( "state" ) ;
344+ assert_eq ! ( state. local_op_counter( ) , 0 ) ;
345+
346+ state
347+ . upsert ( "docs" , "a" , & [ ( "v" , LoroValue :: I64 ( 1 ) ) ] )
348+ . expect ( "write a" ) ;
349+ state. doc . commit ( ) ;
350+ let after_a = state. local_op_counter ( ) ;
351+ assert ! ( after_a > 0 ) ;
352+
353+ state
354+ . upsert ( "docs" , "b" , & [ ( "v" , LoroValue :: I64 ( 2 ) ) ] )
355+ . expect ( "write b" ) ;
356+ state. doc . commit ( ) ;
357+ let after_b = state. local_op_counter ( ) ;
358+ assert ! ( after_b > after_a) ;
359+ }
360+
361+ #[ test]
362+ fn export_local_range_round_trips_one_row ( ) {
363+ let state = CrdtState :: new ( 1 ) . expect ( "state" ) ;
364+
365+ let start_a = state. local_op_counter ( ) ;
366+ state
367+ . upsert ( "docs" , "a" , & [ ( "v" , LoroValue :: I64 ( 1 ) ) ] )
368+ . expect ( "write a" ) ;
369+ state. doc . commit ( ) ;
370+ let end_a = state. local_op_counter ( ) ;
371+
372+ state
373+ . upsert ( "docs" , "b" , & [ ( "v" , LoroValue :: I64 ( 2 ) ) ] )
374+ . expect ( "write b" ) ;
375+ state. doc . commit ( ) ;
376+
377+ let row_a_delta = state
378+ . export_local_range ( start_a, end_a)
379+ . expect ( "export row a range" ) ;
380+
381+ let target = CrdtState :: new ( 2 ) . expect ( "target state" ) ;
382+ target. import ( & row_a_delta) . expect ( "import row a delta" ) ;
383+
384+ assert ! ( target. row_exists( "docs" , "a" ) ) ;
385+ assert ! ( !target. row_exists( "docs" , "b" ) ) ;
386+ }
387+
388+ #[ test]
389+ fn empty_range_exports_nothing ( ) {
390+ let state = CrdtState :: new ( 1 ) . expect ( "state" ) ;
391+ state
392+ . upsert ( "docs" , "a" , & [ ( "v" , LoroValue :: I64 ( 1 ) ) ] )
393+ . expect ( "write a" ) ;
394+ state. doc . commit ( ) ;
395+
396+ let delta = state. export_local_range ( 5 , 5 ) . expect ( "empty range export" ) ;
397+ assert ! ( delta. is_empty( ) ) ;
398+
399+ // An empty range yields no bytes at all — there is nothing to send, and
400+ // an empty blob is NOT importable (it carries no header). Callers must
401+ // skip an empty export rather than enqueue or import it.
402+ let target = CrdtState :: new ( 2 ) . expect ( "target state" ) ;
403+ assert ! (
404+ target. import( & delta) . is_err( ) ,
405+ "an empty export is not a valid delta; callers must skip it"
406+ ) ;
407+ assert ! ( !target. row_exists( "docs" , "a" ) ) ;
408+ }
306409}
0 commit comments