Skip to main content

sui_rpc_api/ledger_history/
query_options.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::ops::{Bound, Range};
5
6use bytes::Bytes;
7use sui_inverted_index::ScanDirection;
8use sui_rpc::proto::sui::rpc::v2::Ordering as ProtoOrdering;
9use sui_rpc::proto::sui::rpc::v2::QueryEndReason;
10use sui_rpc::proto::sui::rpc::v2::QueryOptions as ProtoQueryOptions;
11use sui_rpc_cursor::CursorToken;
12use sui_rpc_cursor::Position;
13
14use crate::ErrorReason;
15use crate::RpcError;
16use crate::proto::google::rpc::bad_request::FieldViolation;
17
18const ORDERING_ASCENDING: i32 = ProtoOrdering::Ascending as i32;
19const ORDERING_DESCENDING: i32 = ProtoOrdering::Descending as i32;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
22pub enum Ordering {
23    Ascending,
24    Descending,
25}
26
27/// Intra-transaction coordinate: a transaction and an index within it
28/// (events today; any per-transaction-indexed lane). Boundary cursors may
29/// point at slots with no item.
30#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
31pub struct IntraTxCoordinate {
32    pub tx_seq: u64,
33    pub index: u32,
34}
35
36/// Why a resolved scan interval is exhausted. Fixed at range-resolution time,
37/// carried through scan state, and rendered by [`ScanTerminal`].
38///
39/// [`ScanTerminal`]: crate::ledger_history::watermark::ScanTerminal
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum RangeExhaustion {
42    /// Reached the currently indexed ledger tip.
43    LedgerTip,
44    /// Reached the requested (or implicit-genesis) checkpoint range bound.
45    CheckpointBound,
46    /// Truncated by a client `after`/`before` cursor. `kind` is the cursor
47    /// kind the terminal cursor must carry so resume neither repeats nor
48    /// skips an item (Item is preserved only for an ascending event interval
49    /// made empty by an `after` Item cursor; every other site uses Boundary).
50    CursorBound { kind: sui_rpc_cursor::CursorKind },
51}
52
53/// Validated, normalized form of `QueryOptions` (the proto wire type).
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct QueryOptions {
56    pub limit_items: usize,
57    pub ordering: Ordering,
58    after: Option<CursorToken>,
59    before: Option<CursorToken>,
60}
61
62/// A request's checkpoint bounds resolved into the checkpoint-sequence
63/// interval to scan. Unlike [`ResolvedScan<u64>`]/[`ResolvedScan<IntraTxCoordinate>`], the
64/// scan domain here *is* checkpoint space, so the entry and terminal
65/// checkpoints derive from `range` directly and need no extra fields.
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct ResolvedCheckpointRange {
68    /// Half-open checkpoint-sequence interval to scan (numeric order; a
69    /// descending scan walks it from `end - 1` down to `start`).
70    pub range: Range<u64>,
71    /// Why the interval is exhausted once the scan drains it.
72    pub exhaustion: RangeExhaustion,
73}
74
75/// Semantic scan bounds over explicit scan positions (cursor trims need
76/// exclusive bounds on either side).
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct ScanBounds<P> {
79    pub lo: Bound<P>,
80    pub hi: Bound<P>,
81}
82
83pub type IntraTxScanBounds = ScanBounds<IntraTxCoordinate>;
84
85/// A request's checkpoint bounds resolved into the scan-position interval to scan, along with the
86/// checkpoint-space facts for watermark rendering.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct ResolvedScan<P> {
89    /// Scan-position interval to scan, expressed as explicit lo/hi
90    /// [`Bound`]s.
91    pub bounds: ScanBounds<P>,
92    /// Checkpoint containing the interval's first position in scan direction.
93    pub entry_checkpoint: u64,
94    /// Checkpoint containing the `end_position`.
95    pub end_checkpoint: u64,
96    /// Scan-direction terminal edge of the interval.
97    pub end_position: P,
98    /// Why the interval is exhausted once the scan drains it.
99    pub exhaustion: RangeExhaustion,
100}
101
102/// How a lane reads wire cursors: the token's coordinate in the lane's
103/// position space.
104pub trait ScanCursor<P> {
105    fn coordinate(&self) -> P;
106}
107
108impl IntraTxCoordinate {
109    /// Fencepost at the first event slot of `tx_seq`; valid as a boundary even
110    /// if the transaction has no events.
111    pub fn start_of_tx(tx_seq: u64) -> Self {
112        Self { tx_seq, index: 0 }
113    }
114
115    /// The event-coordinate window spanning whole transactions `[start, end)`.
116    pub fn tx_window(range: Range<u64>) -> Range<Self> {
117        Self::start_of_tx(range.start)..Self::start_of_tx(range.end)
118    }
119}
120
121impl RangeExhaustion {
122    pub fn reason(self) -> QueryEndReason {
123        match self {
124            Self::LedgerTip => QueryEndReason::LedgerTip,
125            Self::CheckpointBound => QueryEndReason::CheckpointBound,
126            Self::CursorBound { .. } => QueryEndReason::CursorBound,
127        }
128    }
129}
130
131impl QueryOptions {
132    pub fn checkpoints_from_proto(
133        request: Option<&ProtoQueryOptions>,
134        default_limit_items: u32,
135        max_limit_items: u32,
136    ) -> Result<Self, RpcError> {
137        Self::from_proto_with_position(request, default_limit_items, max_limit_items, |position| {
138            matches!(position, Position::Checkpoints { .. })
139        })
140    }
141
142    pub fn transactions_from_proto(
143        request: Option<&ProtoQueryOptions>,
144        default_limit_items: u32,
145        max_limit_items: u32,
146    ) -> Result<Self, RpcError> {
147        Self::from_proto_with_position(request, default_limit_items, max_limit_items, |position| {
148            matches!(position, Position::Transactions { .. })
149        })
150    }
151
152    pub fn events_from_proto(
153        request: Option<&ProtoQueryOptions>,
154        default_limit_items: u32,
155        max_limit_items: u32,
156    ) -> Result<Self, RpcError> {
157        Self::from_proto_with_position(request, default_limit_items, max_limit_items, |position| {
158            matches!(position, Position::Events { .. })
159        })
160    }
161
162    fn from_proto_with_position(
163        request: Option<&ProtoQueryOptions>,
164        default_limit_items: u32,
165        max_limit_items: u32,
166        position_matches: fn(&Position) -> bool,
167    ) -> Result<Self, RpcError> {
168        let limit_items = request
169            .and_then(|options| options.limit)
170            .unwrap_or(default_limit_items)
171            .clamp(1, max_limit_items) as usize;
172
173        let ordering = match request.and_then(|options| options.ordering) {
174            None | Some(ORDERING_ASCENDING) => Ordering::Ascending,
175            Some(ORDERING_DESCENDING) => Ordering::Descending,
176            Some(_) => {
177                return Err(FieldViolation::new("options.ordering")
178                    .with_description("invalid ordering")
179                    .with_reason(ErrorReason::FieldInvalid)
180                    .into());
181            }
182        };
183
184        let after = parse_cursor(
185            "options.after",
186            request.and_then(|options| options.after.as_ref()),
187            position_matches,
188        )?;
189        let before = parse_cursor(
190            "options.before",
191            request.and_then(|options| options.before.as_ref()),
192            position_matches,
193        )?;
194
195        Ok(Self {
196            limit_items,
197            ordering,
198            after,
199            before,
200        })
201    }
202
203    /// Options for a subscription stream: an unbounded ascending scan with no
204    /// cursor bounds. The ascending direction is consulted by the watermark
205    /// builders; `limit_items` is irrelevant.
206    pub fn subscription() -> Self {
207        Self {
208            limit_items: usize::MAX,
209            ordering: Ordering::Ascending,
210            after: None,
211            before: None,
212        }
213    }
214
215    pub fn scan_direction(&self) -> ScanDirection {
216        match self.ordering {
217            Ordering::Ascending => ScanDirection::Ascending,
218            Ordering::Descending => ScanDirection::Descending,
219        }
220    }
221
222    pub fn is_ascending(&self) -> bool {
223        matches!(self.ordering, Ordering::Ascending)
224    }
225
226    /// Whether the request explicitly positioned the low end of the scan via an
227    /// `after` cursor. `apply_cursor_bounds` only ever raises `range.start` from
228    /// `after` (in both orderings); `before` bounds the high end. Together with an
229    /// explicit `start_checkpoint`, this lets the pruning-floor check distinguish
230    /// "resume/start from here" (error if below the floor — the data is gone) from
231    /// an open-ended low end (clamp up to the floor).
232    pub fn has_after_cursor(&self) -> bool {
233        self.after.is_some()
234    }
235}
236
237impl ResolvedCheckpointRange {
238    pub fn empty_at(checkpoint: u64, exhaustion: RangeExhaustion) -> Self {
239        Self {
240            range: checkpoint..checkpoint,
241            exhaustion,
242        }
243    }
244
245    pub fn is_empty(&self) -> bool {
246        self.range.is_empty()
247    }
248
249    pub fn terminal_checkpoint(&self, ordering: Ordering) -> u64 {
250        match ordering {
251            Ordering::Ascending => self.range.end,
252            Ordering::Descending => self.range.start,
253        }
254    }
255}
256
257impl ResolvedScan<u64> {
258    /// Collapse the symbolic bounds to the half-open store range.
259    pub fn range(&self) -> Range<u64> {
260        self.bounds.to_range()
261    }
262}
263
264impl<P: Copy + Ord> ScanBounds<P> {
265    pub fn from_range(range: Range<P>) -> Self {
266        Self {
267            lo: Bound::Included(range.start),
268            hi: Bound::Excluded(range.end),
269        }
270    }
271
272    pub fn empty_at(position: P) -> Self {
273        Self {
274            lo: Bound::Included(position),
275            hi: Bound::Excluded(position),
276        }
277    }
278
279    pub fn is_empty(&self) -> bool {
280        match (self.lo, self.hi) {
281            (Bound::Included(a), Bound::Excluded(b))
282            | (Bound::Excluded(a), Bound::Excluded(b))
283            | (Bound::Excluded(a), Bound::Included(b)) => a >= b,
284            (Bound::Included(a), Bound::Included(b)) => a > b,
285            (Bound::Unbounded, _) | (_, Bound::Unbounded) => false,
286        }
287    }
288
289    pub fn contains(&self, position: P) -> bool {
290        let above_lo = match self.lo {
291            Bound::Included(lo) => position >= lo,
292            Bound::Excluded(lo) => position > lo,
293            Bound::Unbounded => true,
294        };
295        let below_hi = match self.hi {
296            Bound::Included(hi) => position <= hi,
297            Bound::Excluded(hi) => position < hi,
298            Bound::Unbounded => true,
299        };
300        above_lo && below_hi
301    }
302}
303
304impl ScanBounds<u64> {
305    pub fn to_range(&self) -> Range<u64> {
306        let start = match self.lo {
307            Bound::Included(position) => position,
308            Bound::Excluded(position) => position.saturating_add(1),
309            Bound::Unbounded => 0,
310        };
311        let end = match self.hi {
312            Bound::Included(position) => position.saturating_add(1),
313            Bound::Excluded(position) => position,
314            Bound::Unbounded => u64::MAX,
315        };
316        start..end
317    }
318}
319
320impl ScanBounds<IntraTxCoordinate> {
321    /// Smallest half-open tx range covering every position these bounds could
322    /// admit. An exclusive `hi` at the start of tx N excludes tx N entirely;
323    /// any other bounded endpoint keeps its transaction, since earlier events
324    /// of that tx may still be in bounds. `None` when no tx can qualify.
325    pub fn tx_range(&self) -> Option<Range<u64>> {
326        let start_tx = match self.lo {
327            Bound::Included(position) | Bound::Excluded(position) => position.tx_seq,
328            Bound::Unbounded => 0,
329        };
330        let end_tx = match self.hi {
331            Bound::Excluded(position) if position.index == 0 => position.tx_seq,
332            Bound::Included(position) | Bound::Excluded(position) => {
333                position.tx_seq.saturating_add(1)
334            }
335            Bound::Unbounded => u64::MAX,
336        };
337        (start_tx < end_tx).then_some(start_tx..end_tx)
338    }
339}
340
341impl<P: Copy + Ord> ResolvedScan<P>
342where
343    CursorToken: ScanCursor<P>,
344{
345    /// Record the request's checkpoint bounds, then apply cursor bounds to tighten the `range`
346    /// scanning interval. If the backend data source has a serving floor, `apply_serving_floor`
347    /// must be called to reconcile the scanning interval.
348    pub fn resolve(
349        cp_range: ResolvedCheckpointRange,
350        range: Range<P>,
351        options: &QueryOptions,
352    ) -> Self {
353        let entry_checkpoint = if cp_range.is_empty() {
354            // No checkpoint entered, pin entry to terminal boundary
355            cp_range.range.end
356        } else if options.is_ascending() {
357            cp_range.range.start
358        } else {
359            cp_range.range.end.saturating_sub(1)
360        };
361
362        Self {
363            bounds: ScanBounds::from_range(range.start..range.end),
364            entry_checkpoint,
365            end_checkpoint: cp_range.terminal_checkpoint(options.ordering),
366            end_position: match options.ordering {
367                Ordering::Ascending => range.end,
368                Ordering::Descending => range.start,
369            },
370            exhaustion: cp_range.exhaustion,
371        }
372        .apply_cursor_bounds(options)
373    }
374
375    pub fn is_empty(&self) -> bool {
376        self.bounds.is_empty()
377    }
378
379    fn apply_cursor_bounds(mut self, options: &QueryOptions) -> Self {
380        if self.is_empty() {
381            return self;
382        }
383
384        let mut cursor_terminal = self.apply_after_cursor(options);
385
386        // Either zero, one, or two cursors emptied the bounds. If the bound is empty, before's
387        // position is provably the lower coordinate. Report this minimum of the crossed positions,
388        // otherwise we will skip an interval in the ascending case, or rollback scan progress in
389        // the descending case.
390        if let Some(recording) = self.apply_before_cursor(options) {
391            cursor_terminal = Some(recording);
392        }
393
394        if let Some((checkpoint, position, kind)) = cursor_terminal {
395            self.set_terminal_record(checkpoint, position, RangeExhaustion::CursorBound { kind });
396
397            self.bounds = ScanBounds::empty_at(self.end_position);
398        }
399
400        self
401    }
402
403    fn set_terminal_record(&mut self, checkpoint: u64, position: P, exhaustion: RangeExhaustion) {
404        self.end_checkpoint = checkpoint;
405        self.end_position = position;
406        self.exhaustion = exhaustion;
407    }
408
409    /// Tightens the low bound, and returns the terminal record if the cursor empties the interval.
410    fn apply_after_cursor(
411        &mut self,
412        options: &QueryOptions,
413    ) -> Option<(u64, P, sui_rpc_cursor::CursorKind)> {
414        let cursor = options.after.as_ref()?;
415        let checkpoint = cursor.position.checkpoint();
416        let position: P = cursor.coordinate();
417
418        if options.is_ascending() {
419            self.entry_checkpoint = self.entry_checkpoint.max(checkpoint);
420        }
421
422        // Symbolic resume in every lane: an Item admits strictly-after, a
423        // Boundary admits from itself; successor arithmetic exists only at
424        // the store edge.
425        let candidate = match cursor.kind {
426            sui_rpc_cursor::CursorKind::Item => Bound::Excluded(position),
427            sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position),
428        };
429
430        if !lower_bound_gte(candidate, self.bounds.lo) {
431            return None;
432        }
433
434        // The after cursor is the terminal bound for a descending scan.
435        if !options.is_ascending() {
436            self.set_terminal_record(
437                checkpoint,
438                position,
439                RangeExhaustion::CursorBound {
440                    kind: sui_rpc_cursor::CursorKind::Boundary,
441                },
442            );
443        }
444
445        self.bounds.lo = candidate;
446
447        if !self.bounds.is_empty() {
448            return None;
449        }
450
451        // Report the cursor that emptied the bound.
452        //
453        // An ascending interval made empty by an `after` Item cursor must retain Item kind.
454        let kind = if options.is_ascending() {
455            cursor.kind
456        } else {
457            sui_rpc_cursor::CursorKind::Boundary
458        };
459        Some((checkpoint, position, kind))
460    }
461
462    /// Tightens the high bound, and returns the terminal record if the cursor empties the interval.
463    fn apply_before_cursor(
464        &mut self,
465        options: &QueryOptions,
466    ) -> Option<(u64, P, sui_rpc_cursor::CursorKind)> {
467        let cursor = options.before.as_ref()?;
468        let checkpoint = cursor.position.checkpoint();
469        let position: P = cursor.coordinate();
470
471        if !options.is_ascending() {
472            self.entry_checkpoint = self.entry_checkpoint.min(checkpoint);
473        }
474
475        if !hi_admits_upper_bound(self.bounds.hi, position) {
476            return None;
477        }
478
479        if options.is_ascending() {
480            self.set_terminal_record(
481                checkpoint,
482                position,
483                RangeExhaustion::CursorBound {
484                    kind: sui_rpc_cursor::CursorKind::Boundary,
485                },
486            );
487        }
488
489        self.bounds.hi = Bound::Excluded(position);
490
491        if !self.bounds.is_empty() {
492            return None;
493        }
494
495        Some((checkpoint, position, sui_rpc_cursor::CursorKind::Boundary))
496    }
497
498    pub fn apply_serving_floor(&mut self, floor: P, floor_checkpoint: u64, options: &QueryOptions) {
499        let floored_lo = Bound::Included(floor);
500        let floored = ScanBounds {
501            lo: floored_lo,
502            hi: self.bounds.hi,
503        };
504        if floored.is_empty() {
505            // Canonical empty form everywhere in this module: the interval
506            // collapses onto its reported terminal bound.
507            self.bounds = ScanBounds::empty_at(self.end_position);
508            return;
509        }
510        self.bounds.lo = floored_lo;
511        if options.is_ascending() {
512            self.entry_checkpoint = self.entry_checkpoint.max(floor_checkpoint);
513        } else {
514            self.end_checkpoint = floor_checkpoint;
515            self.end_position = floor;
516        }
517    }
518}
519
520/// The requested checkpoint window's validity check, standalone so backends
521/// can sequence it independently of options parsing (kv validates bounds
522/// before the read mask and options; the fused resolution re-runs it
523/// harmlessly).
524pub fn validate_checkpoint_bounds(
525    start_checkpoint: Option<u64>,
526    end_checkpoint: Option<u64>,
527) -> Result<(), RpcError> {
528    let start = start_checkpoint.unwrap_or(0);
529    if let Some(end) = end_checkpoint
530        && end < start
531    {
532        return Err(FieldViolation::new("end_checkpoint")
533            .with_description("end_checkpoint must be greater than or equal to start_checkpoint")
534            .with_reason(ErrorReason::FieldInvalid)
535            .into());
536    }
537    Ok(())
538}
539
540impl ResolvedCheckpointRange {
541    /// Validate the requested checkpoint window, clamp it to the indexed tip,
542    /// tighten it by the request's cursors, and attribute the exhaustion
543    /// reason the scan will report once it drains it — request to resolved
544    /// window in one call.
545    pub fn from_request(
546        start_checkpoint: Option<u64>,
547        end_checkpoint: Option<u64>,
548        checkpoint_hi_exclusive: u64,
549        options: &QueryOptions,
550    ) -> Result<Self, RpcError> {
551        validate_checkpoint_bounds(start_checkpoint, end_checkpoint)?;
552        let start = start_checkpoint.unwrap_or(0);
553
554        let requested_end = end_checkpoint.unwrap_or(checkpoint_hi_exclusive);
555        let mut high_exhaustion =
556            if end_checkpoint.is_none() || requested_end > checkpoint_hi_exclusive {
557                RangeExhaustion::LedgerTip
558            } else {
559                RangeExhaustion::CheckpointBound
560            };
561        let mut start = start;
562        let mut end = requested_end.min(checkpoint_hi_exclusive);
563        let mut low_exhaustion = RangeExhaustion::CheckpointBound;
564        let mut cursor_bound = false;
565
566        if let Some(cursor) = &options.after
567            && cursor.position.checkpoint() >= start
568        {
569            start = cursor.position.checkpoint();
570            cursor_bound = true;
571            if matches!(options.ordering, Ordering::Descending) {
572                low_exhaustion = RangeExhaustion::CursorBound {
573                    kind: sui_rpc_cursor::CursorKind::Boundary,
574                };
575            }
576        }
577
578        if let Some(cursor) = &options.before
579            && let Some(upper) = match cursor.kind {
580                sui_rpc_cursor::CursorKind::Item => cursor.position.checkpoint().checked_add(1),
581                sui_rpc_cursor::CursorKind::Boundary => Some(cursor.position.checkpoint()),
582            }
583            && upper <= end
584        {
585            end = upper;
586            cursor_bound = true;
587            if matches!(options.ordering, Ordering::Ascending) {
588                high_exhaustion = RangeExhaustion::CursorBound {
589                    kind: sui_rpc_cursor::CursorKind::Boundary,
590                };
591            }
592        }
593
594        if start >= checkpoint_hi_exclusive {
595            return Ok(Self::empty_at(
596                checkpoint_hi_exclusive,
597                RangeExhaustion::LedgerTip,
598            ));
599        }
600
601        if start >= end {
602            let exhaustion = if cursor_bound {
603                RangeExhaustion::CursorBound {
604                    kind: sui_rpc_cursor::CursorKind::Boundary,
605                }
606            } else {
607                match options.ordering {
608                    Ordering::Ascending => high_exhaustion,
609                    Ordering::Descending => low_exhaustion,
610                }
611            };
612            let checkpoint = match options.ordering {
613                Ordering::Ascending => end,
614                Ordering::Descending => start,
615            };
616            return Ok(Self::empty_at(checkpoint, exhaustion));
617        }
618
619        let exhaustion = match options.ordering {
620            Ordering::Ascending => high_exhaustion,
621            Ordering::Descending => low_exhaustion,
622        };
623        Ok(Self {
624            range: start..end,
625            exhaustion,
626        })
627    }
628}
629
630impl ScanCursor<u64> for CursorToken {
631    fn coordinate(&self) -> u64 {
632        match self.position {
633            Position::Checkpoints { checkpoint } => checkpoint,
634            Position::Transactions { tx_seq, .. } => tx_seq,
635            Position::Events { .. } => unreachable!("validated at decode"),
636        }
637    }
638}
639
640impl ScanCursor<IntraTxCoordinate> for CursorToken {
641    fn coordinate(&self) -> IntraTxCoordinate {
642        match self.position {
643            Position::Events {
644                tx_seq,
645                event_index,
646                ..
647            } => IntraTxCoordinate {
648                tx_seq,
649                index: event_index,
650            },
651            _ => unreachable!("validated at decode"),
652        }
653    }
654}
655
656impl From<IntraTxCoordinate> for (u64, u32) {
657    fn from(position: IntraTxCoordinate) -> Self {
658        (position.tx_seq, position.index)
659    }
660}
661
662impl From<(u64, u32)> for IntraTxCoordinate {
663    fn from((tx_seq, index): (u64, u32)) -> Self {
664        Self { tx_seq, index }
665    }
666}
667
668fn lower_bound_gte<P: Ord + Copy>(candidate: Bound<P>, current: Bound<P>) -> bool {
669    let Some(candidate) = lower_bound_key(candidate) else {
670        return false;
671    };
672    match lower_bound_key(current) {
673        Some(current) => candidate >= current,
674        None => true,
675    }
676}
677
678fn lower_bound_key<P: Ord + Copy>(bound: Bound<P>) -> Option<(P, u8)> {
679    match bound {
680        Bound::Included(position) => Some((position, 0)),
681        Bound::Excluded(position) => Some((position, 1)),
682        Bound::Unbounded => None,
683    }
684}
685
686fn hi_admits_upper_bound<P: Ord + Copy>(current: Bound<P>, candidate: P) -> bool {
687    match current {
688        Bound::Included(position) | Bound::Excluded(position) => candidate <= position,
689        Bound::Unbounded => true,
690    }
691}
692
693fn parse_cursor(
694    field: &'static str,
695    cursor: Option<&Bytes>,
696    position_matches: fn(&Position) -> bool,
697) -> Result<Option<CursorToken>, RpcError> {
698    cursor
699        .map(|cursor| {
700            CursorToken::decode(cursor).map_err(|_| invalid_cursor(field, "invalid cursor"))
701        })
702        .transpose()?
703        .map(|token| {
704            if position_matches(&token.position) {
705                Ok(token)
706            } else {
707                Err(invalid_cursor(field, "invalid cursor"))
708            }
709        })
710        .transpose()
711}
712
713fn invalid_cursor(field: &'static str, description: impl Into<String>) -> RpcError {
714    FieldViolation::new(field)
715        .with_description(description)
716        .with_reason(ErrorReason::FieldInvalid)
717        .into()
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn query_options_from_proto(
725        request: Option<&ProtoQueryOptions>,
726    ) -> Result<QueryOptions, RpcError> {
727        QueryOptions::transactions_from_proto(request, 100, 1_000)
728    }
729
730    fn resolved_range(range: Range<u64>) -> ResolvedScan<u64> {
731        ResolvedScan {
732            bounds: ScanBounds::from_range(range),
733            end_checkpoint: 20,
734            end_position: 20,
735            exhaustion: RangeExhaustion::CheckpointBound,
736            entry_checkpoint: 0,
737        }
738    }
739
740    fn empty_resolved_range(
741        end_checkpoint: u64,
742        end_position: u64,
743        exhaustion: RangeExhaustion,
744    ) -> ResolvedScan<u64> {
745        ResolvedScan {
746            bounds: ScanBounds::empty_at(end_position),
747            end_checkpoint,
748            end_position,
749            exhaustion,
750            entry_checkpoint: end_checkpoint,
751        }
752    }
753
754    fn tx_item(checkpoint: u64, tx_seq: u64) -> CursorToken {
755        CursorToken::item(Position::Transactions { checkpoint, tx_seq })
756    }
757
758    fn tx_boundary(checkpoint: u64, tx_seq: u64) -> CursorToken {
759        CursorToken::boundary(Position::Transactions { checkpoint, tx_seq })
760    }
761
762    fn cp_item(checkpoint: u64) -> CursorToken {
763        CursorToken::item(Position::Checkpoints { checkpoint })
764    }
765
766    fn ev_item(checkpoint: u64, tx_seq: u64, index: u32) -> CursorToken {
767        CursorToken::item(Position::Events {
768            checkpoint,
769            tx_seq,
770            event_index: index,
771        })
772    }
773
774    fn ev_boundary(checkpoint: u64, tx_seq: u64, index: u32) -> CursorToken {
775        CursorToken::boundary(Position::Events {
776            checkpoint,
777            tx_seq,
778            event_index: index,
779        })
780    }
781
782    fn resolved_intra_tx() -> ResolvedScan<IntraTxCoordinate> {
783        ResolvedScan {
784            bounds: IntraTxScanBounds::from_range(
785                IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(10),
786            ),
787            entry_checkpoint: 2,
788            end_checkpoint: 9,
789            end_position: IntraTxCoordinate::start_of_tx(10),
790            exhaustion: RangeExhaustion::CheckpointBound,
791        }
792    }
793
794    fn after_options(ordering: Ordering, cursor: CursorToken) -> QueryOptions {
795        QueryOptions {
796            limit_items: 100,
797            ordering,
798            after: Some(cursor),
799            before: None,
800        }
801    }
802
803    fn before_options(ordering: Ordering, cursor: CursorToken) -> QueryOptions {
804        QueryOptions {
805            limit_items: 100,
806            ordering,
807            after: None,
808            before: Some(cursor),
809        }
810    }
811
812    fn directional_options(ascending: bool) -> QueryOptions {
813        let mut request = ProtoQueryOptions::default();
814        if !ascending {
815            request.ordering = Some(ProtoOrdering::Descending as i32);
816        }
817        query_options_from_proto(Some(&request)).unwrap()
818    }
819
820    /// Serving-floor reconciliation for tx intervals: a floor inside the
821    /// interval moves the scan start and the direction-relevant watermark
822    /// metadata; a floor at/past the high end empties the intersection, and
823    /// the terminal metadata stays at the requested boundary instead of
824    /// moving to a floor outside the requested interval.
825    #[test]
826    fn tx_serving_floor_reconciles_or_canonicalizes_empty() {
827        let asc = directional_options(true);
828        let desc = directional_options(false);
829
830        // Ascending, floor inside 0..100: scan starts at the floor; the
831        // entry claim rises to the floor checkpoint; terminal untouched.
832        let mut resolved = ResolvedScan {
833            bounds: ScanBounds::from_range(0..100),
834            end_checkpoint: 20,
835            end_position: 100,
836            exhaustion: RangeExhaustion::CheckpointBound,
837            entry_checkpoint: 0,
838        };
839        resolved.apply_serving_floor(50, 10, &asc);
840        assert_eq!(resolved.range(), 50..100);
841        assert_eq!(resolved.entry_checkpoint, 10);
842        assert_eq!(resolved.end_checkpoint, 20);
843        assert_eq!(resolved.end_position, 100);
844
845        // Descending, floor inside: entry (the high edge) untouched; the
846        // terminal pins to the floor.
847        let mut resolved = ResolvedScan {
848            bounds: ScanBounds::from_range(0..100),
849            end_checkpoint: 0,
850            end_position: 0,
851            exhaustion: RangeExhaustion::CheckpointBound,
852            entry_checkpoint: 20,
853        };
854        resolved.apply_serving_floor(50, 10, &desc);
855        assert_eq!(resolved.range(), 50..100);
856        assert_eq!(resolved.entry_checkpoint, 20);
857        assert_eq!(resolved.end_checkpoint, 10);
858        assert_eq!(resolved.end_position, 50);
859
860        // Floor at/past the high end (covers the == boundary), both
861        // directions: empty intersection, canonicalized at the reported
862        // terminal bound; no metadata moves. Descending, the terminal must
863        // NOT move to the floor (checkpoint 10 lies outside the requested
864        // interval).
865        for floor_tx in [40, 50] {
866            let mut resolved = ResolvedScan {
867                bounds: ScanBounds::from_range(0..40),
868                end_checkpoint: 8,
869                end_position: 40,
870                exhaustion: RangeExhaustion::CheckpointBound,
871                entry_checkpoint: 0,
872            };
873            resolved.apply_serving_floor(floor_tx, 10, &asc);
874            assert!(resolved.is_empty());
875            assert_eq!(resolved.range(), 40..40);
876            assert_eq!(resolved.entry_checkpoint, 0);
877            assert_eq!(resolved.end_checkpoint, 8);
878            assert_eq!(resolved.end_position, 40);
879
880            let mut resolved = ResolvedScan {
881                bounds: ScanBounds::from_range(0..40),
882                end_checkpoint: 0,
883                end_position: 0,
884                exhaustion: RangeExhaustion::CheckpointBound,
885                entry_checkpoint: 8,
886            };
887            resolved.apply_serving_floor(floor_tx, 10, &desc);
888            assert!(resolved.is_empty());
889            assert_eq!(resolved.range(), 0..0);
890            assert_eq!(resolved.entry_checkpoint, 8);
891            assert_eq!(resolved.end_checkpoint, 0);
892            assert_eq!(resolved.end_position, 0);
893        }
894    }
895
896    /// [`ResolvedScan::<IntraTxCoordinate>::apply_serving_floor`] mirrors the tx behavior in
897    /// event coordinates.
898    #[test]
899    fn event_serving_floor_reconciles_or_canonicalizes_empty() {
900        let asc = directional_options(true);
901        let desc = directional_options(false);
902
903        // Ascending, floor inside: low bound moves, entry rises, terminal
904        // untouched.
905        let mut resolved = ResolvedScan {
906            bounds: IntraTxScanBounds::from_range(
907                IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(100),
908            ),
909            end_checkpoint: 20,
910            end_position: IntraTxCoordinate::start_of_tx(100),
911            exhaustion: RangeExhaustion::CheckpointBound,
912            entry_checkpoint: 0,
913        };
914        resolved.apply_serving_floor(IntraTxCoordinate::start_of_tx(50), 10, &asc);
915        assert_eq!(
916            resolved.bounds.lo,
917            Bound::Included(IntraTxCoordinate::start_of_tx(50))
918        );
919        assert_eq!(resolved.entry_checkpoint, 10);
920        assert_eq!(resolved.end_checkpoint, 20);
921        assert_eq!(resolved.end_position, IntraTxCoordinate::start_of_tx(100));
922
923        // Descending, floor inside: terminal pins to the floor.
924        let mut resolved = ResolvedScan {
925            bounds: IntraTxScanBounds::from_range(
926                IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(100),
927            ),
928            end_checkpoint: 0,
929            end_position: IntraTxCoordinate::start_of_tx(0),
930            exhaustion: RangeExhaustion::CheckpointBound,
931            entry_checkpoint: 20,
932        };
933        resolved.apply_serving_floor(IntraTxCoordinate::start_of_tx(50), 10, &desc);
934        assert_eq!(
935            resolved.bounds.lo,
936            Bound::Included(IntraTxCoordinate::start_of_tx(50))
937        );
938        assert_eq!(resolved.entry_checkpoint, 20);
939        assert_eq!(resolved.end_checkpoint, 10);
940        assert_eq!(resolved.end_position, IntraTxCoordinate::start_of_tx(50));
941
942        // Floor that empties the bounds (covers the == boundary), both
943        // directions: canonical empty at the reported terminal bound, no
944        // metadata moves.
945        for floor_tx in [40, 50] {
946            let mut resolved = ResolvedScan {
947                bounds: IntraTxScanBounds::from_range(
948                    IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(40),
949                ),
950                end_checkpoint: 8,
951                end_position: IntraTxCoordinate::start_of_tx(40),
952                exhaustion: RangeExhaustion::CheckpointBound,
953                entry_checkpoint: 0,
954            };
955            resolved.apply_serving_floor(IntraTxCoordinate::start_of_tx(floor_tx), 10, &asc);
956            assert!(resolved.is_empty());
957            assert_eq!(
958                resolved.bounds,
959                IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(40))
960            );
961            assert_eq!(resolved.entry_checkpoint, 0);
962            assert_eq!(resolved.end_checkpoint, 8);
963            assert_eq!(resolved.end_position, IntraTxCoordinate::start_of_tx(40));
964
965            let mut resolved = ResolvedScan {
966                bounds: IntraTxScanBounds::from_range(
967                    IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(40),
968                ),
969                end_checkpoint: 0,
970                end_position: IntraTxCoordinate::start_of_tx(0),
971                exhaustion: RangeExhaustion::CheckpointBound,
972                entry_checkpoint: 8,
973            };
974            resolved.apply_serving_floor(IntraTxCoordinate::start_of_tx(floor_tx), 10, &desc);
975            assert!(resolved.is_empty());
976            assert_eq!(
977                resolved.bounds,
978                IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(0))
979            );
980            assert_eq!(resolved.entry_checkpoint, 8);
981            assert_eq!(resolved.end_checkpoint, 0);
982            assert_eq!(resolved.end_position, IntraTxCoordinate::start_of_tx(0));
983        }
984    }
985
986    #[test]
987    fn tx_range_covers_partial_endpoint_transactions() {
988        let bounds = IntraTxScanBounds {
989            lo: Bound::Included(IntraTxCoordinate {
990                tx_seq: 10,
991                index: 2,
992            }),
993            hi: Bound::Excluded(IntraTxCoordinate::start_of_tx(13)),
994        };
995
996        assert_eq!(bounds.tx_range(), Some(10..13));
997    }
998
999    #[test]
1000    fn tx_range_keeps_tx_of_nonzero_exclusive_hi() {
1001        let bounds = IntraTxScanBounds {
1002            lo: Bound::Unbounded,
1003            hi: Bound::Excluded(IntraTxCoordinate {
1004                tx_seq: 13,
1005                index: 1,
1006            }),
1007        };
1008
1009        assert_eq!(bounds.tx_range(), Some(0..14));
1010    }
1011
1012    #[test]
1013    fn tx_range_empty_bounds_yield_none() {
1014        let bounds = IntraTxScanBounds::from_range(
1015            IntraTxCoordinate::start_of_tx(10)..IntraTxCoordinate::start_of_tx(10),
1016        );
1017        assert_eq!(bounds.tx_range(), None);
1018    }
1019
1020    /// Every after-Item cursor now resumes as an Excluded lo; the successor
1021    /// arithmetic and its `u64::MAX` saturation live here at the store edge.
1022    #[test]
1023    fn to_range_collapses_excluded_lo_at_successor() {
1024        let bounds = ScanBounds {
1025            lo: Bound::Excluded(14u64),
1026            hi: Bound::Excluded(20u64),
1027        };
1028        assert_eq!(bounds.to_range(), 15..20);
1029
1030        // The saturated successor at the unoccupiable sentinel admits
1031        // nothing, through the ordinary emptiness of MAX..MAX.
1032        let bounds = ScanBounds {
1033            lo: Bound::Excluded(u64::MAX),
1034            hi: Bound::Unbounded,
1035        };
1036        assert!(bounds.to_range().is_empty());
1037    }
1038
1039    #[test]
1040    fn parses_cursors_and_ordering() {
1041        let after = tx_item(2, 20).encode();
1042        let before = tx_item(3, 30).encode();
1043        let mut request = ProtoQueryOptions::default();
1044        request.limit = Some(500);
1045        request.after = Some(after);
1046        request.before = Some(before);
1047        request.ordering = Some(ProtoOrdering::Descending as i32);
1048
1049        let options = query_options_from_proto(Some(&request)).unwrap();
1050
1051        assert_eq!(options.limit_items, 500);
1052        assert_eq!(options.ordering, Ordering::Descending);
1053        assert_eq!(options.scan_direction(), ScanDirection::Descending);
1054        assert_eq!(
1055            resolved_range(0..100).apply_cursor_bounds(&options).range(),
1056            21..30
1057        );
1058    }
1059
1060    #[test]
1061    fn has_after_cursor_reflects_only_the_after_field() {
1062        // No cursors → open-ended low end.
1063        let options = query_options_from_proto(Some(&ProtoQueryOptions::default())).unwrap();
1064        assert!(!options.has_after_cursor());
1065
1066        // `before` bounds the high end, so it must not count as an explicit low end.
1067        let mut request = ProtoQueryOptions::default();
1068        request.before = Some(tx_item(3, 30).encode());
1069        let options = query_options_from_proto(Some(&request)).unwrap();
1070        assert!(!options.has_after_cursor());
1071
1072        // `after` raises the low end → explicit.
1073        let mut request = ProtoQueryOptions::default();
1074        request.after = Some(tx_item(2, 20).encode());
1075        let options = query_options_from_proto(Some(&request)).unwrap();
1076        assert!(options.has_after_cursor());
1077    }
1078
1079    #[test]
1080    fn clamps_limit_items_and_defaults_to_ascending() {
1081        let mut request = ProtoQueryOptions::default();
1082        request.limit = Some(5_000);
1083
1084        let options = query_options_from_proto(Some(&request)).unwrap();
1085
1086        assert_eq!(options.limit_items, 1_000);
1087        assert_eq!(options.ordering, Ordering::Ascending);
1088        assert_eq!(options.scan_direction(), ScanDirection::Ascending);
1089    }
1090
1091    #[test]
1092    fn rejects_malformed_cursors_and_unknown_ordering() {
1093        let mut request = ProtoQueryOptions::default();
1094        request.after = Some(Bytes::from_static(b"short"));
1095        assert!(query_options_from_proto(Some(&request)).is_err());
1096
1097        let mut request = ProtoQueryOptions::default();
1098        request.before = Some(Bytes::from_static(b"short"));
1099        assert!(query_options_from_proto(Some(&request)).is_err());
1100
1101        let mut request = ProtoQueryOptions::default();
1102        request.ordering = Some(99);
1103        assert!(query_options_from_proto(Some(&request)).is_err());
1104    }
1105
1106    #[test]
1107    fn rejects_cursor_for_different_position_variant() {
1108        let token = cp_item(9).encode();
1109        let mut request = ProtoQueryOptions::default();
1110        request.after = Some(token);
1111        assert!(query_options_from_proto(Some(&request)).is_err());
1112    }
1113
1114    #[test]
1115    fn accepts_cursor_regardless_of_filter_scope() {
1116        // Cursors are portable across filters: a Transactions cursor must be
1117        // accepted by a Transactions query even though `query_options_from_proto`
1118        // applies no filter. Position is an absolute, filter-independent
1119        // coordinate, so resuming under a different filter is correct.
1120        let after = tx_item(1, 9).encode();
1121        let before = tx_item(3, 30).encode();
1122        let mut request = ProtoQueryOptions::default();
1123        request.after = Some(after);
1124        request.before = Some(before);
1125        assert!(query_options_from_proto(Some(&request)).is_ok());
1126    }
1127
1128    #[test]
1129    fn accepts_cursors_for_different_checkpoint_range_and_ordering() {
1130        let token = tx_item(9, 9).encode();
1131        let mut request = ProtoQueryOptions::default();
1132        request.after = Some(token);
1133        request.ordering = Some(ProtoOrdering::Descending as i32);
1134
1135        let options = query_options_from_proto(Some(&request)).unwrap();
1136        let range =
1137            ResolvedCheckpointRange::from_request(Some(1_000), Some(1_100), 2_000, &options)
1138                .unwrap();
1139
1140        assert_eq!(range.range, 1_000..1_100);
1141    }
1142
1143    #[test]
1144    fn applies_canonical_cursor_bounds() {
1145        let options = QueryOptions {
1146            limit_items: 2,
1147            ordering: Ordering::Ascending,
1148            after: Some(tx_item(1, 11)),
1149            before: None,
1150        };
1151        assert_eq!(
1152            resolved_range(10..20).apply_cursor_bounds(&options).range(),
1153            12..20
1154        );
1155
1156        let options = QueryOptions {
1157            after: Some(tx_item(1, u64::MAX)),
1158            ..options
1159        };
1160        assert_eq!(
1161            resolved_range(10..20).apply_cursor_bounds(&options),
1162            empty_resolved_range(
1163                1,
1164                u64::MAX,
1165                RangeExhaustion::CursorBound {
1166                    kind: sui_rpc_cursor::CursorKind::Item,
1167                },
1168            )
1169        );
1170
1171        let options = QueryOptions {
1172            ordering: Ordering::Descending,
1173            after: Some(tx_item(1, 11)),
1174            before: Some(tx_item(1, 19)),
1175            ..options
1176        };
1177        let bounded = resolved_range(10..20).apply_cursor_bounds(&options);
1178        assert_eq!(bounded.range(), 12..19);
1179        assert_eq!(
1180            bounded.exhaustion,
1181            RangeExhaustion::CursorBound {
1182                kind: sui_rpc_cursor::CursorKind::Boundary,
1183            }
1184        );
1185        assert_eq!(bounded.end_position, 11);
1186    }
1187
1188    // (descending, after = Item 11, before = Item 12): the cursors leave the
1189    // empty gap (11, 12), so the fetch serves nothing and the CursorBound
1190    // frame comes from the descending stop side (the after cursor, at 11).
1191    #[test]
1192    fn descending_before_adjacent_to_after_empties_at_after_cursor() {
1193        let options = QueryOptions {
1194            limit_items: 2,
1195            ordering: Ordering::Descending,
1196            after: Some(tx_item(1, 11)),
1197            before: Some(tx_item(1, 12)),
1198        };
1199        let crossed = resolved_range(10..20).apply_cursor_bounds(&options);
1200        assert_eq!(
1201            crossed,
1202            ResolvedScan {
1203                bounds: ScanBounds {
1204                    lo: Bound::Excluded(11),
1205                    hi: Bound::Excluded(12),
1206                },
1207                entry_checkpoint: 0,
1208                end_checkpoint: 1,
1209                end_position: 11,
1210                exhaustion: RangeExhaustion::CursorBound {
1211                    kind: sui_rpc_cursor::CursorKind::Boundary,
1212                },
1213            }
1214        );
1215        assert!(crossed.range().is_empty());
1216    }
1217
1218    #[test]
1219    fn applies_boundary_cursor_bounds_without_item_offset() {
1220        let options = QueryOptions {
1221            limit_items: 2,
1222            ordering: Ordering::Ascending,
1223            after: Some(tx_boundary(2, 20)),
1224            before: None,
1225        };
1226        assert_eq!(
1227            resolved_range(10..30).apply_cursor_bounds(&options).range(),
1228            20..30
1229        );
1230
1231        let options = QueryOptions {
1232            ordering: Ordering::Descending,
1233            after: None,
1234            before: Some(tx_boundary(2, 20)),
1235            ..options
1236        };
1237        assert_eq!(
1238            resolved_range(10..30).apply_cursor_bounds(&options).range(),
1239            10..20
1240        );
1241    }
1242
1243    #[test]
1244    fn resolves_checkpoint_range_with_terminal_reason() {
1245        let options = query_options_from_proto(None).unwrap();
1246        assert_eq!(
1247            ResolvedCheckpointRange::from_request(None, None, 20, &options)
1248                .unwrap()
1249                .exhaustion,
1250            RangeExhaustion::LedgerTip
1251        );
1252        assert!(ResolvedCheckpointRange::from_request(Some(10), Some(9), 20, &options).is_err());
1253
1254        let resolved = ResolvedCheckpointRange::from_request(Some(10), None, 20, &options).unwrap();
1255        assert_eq!(resolved.range, 10..20);
1256        assert_eq!(resolved.exhaustion, RangeExhaustion::LedgerTip);
1257
1258        assert_eq!(
1259            ResolvedCheckpointRange::from_request(Some(30), None, 20, &options).unwrap(),
1260            ResolvedCheckpointRange::empty_at(20, RangeExhaustion::LedgerTip)
1261        );
1262    }
1263
1264    /// The cp-width clamp was removed when scan limiting moved to the runtime
1265    /// bucket-budget path. Whatever range the request asks for is honored at
1266    /// resolve time; the bitmap layer terminates scans on budget exhaustion.
1267    #[test]
1268    fn resolves_checkpoint_range_no_longer_clamped_by_width() {
1269        let options = query_options_from_proto(None).unwrap();
1270        let resolved =
1271            ResolvedCheckpointRange::from_request(Some(10), Some(10_000_000), 10_000_000, &options)
1272                .unwrap();
1273        assert_eq!(resolved.range, 10..10_000_000);
1274        assert_eq!(resolved.exhaustion, RangeExhaustion::CheckpointBound);
1275    }
1276
1277    /// (start_checkpoint = 30, tip = 20, both orderings): resolves empty at the tip —
1278    /// entry and terminal checkpoints 20, LedgerTip.
1279    #[test]
1280    fn resolve_window_past_tip_empties_with_ledger_tip() {
1281        for ascending in [true, false] {
1282            let mut request = ProtoQueryOptions::default();
1283            if !ascending {
1284                request.ordering = Some(ProtoOrdering::Descending as i32);
1285            }
1286            let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1287            let cp_range =
1288                ResolvedCheckpointRange::from_request(Some(30), None, 20, &options).unwrap();
1289            assert!(cp_range.is_empty());
1290            assert_eq!(
1291                ResolvedScan::<IntraTxCoordinate>::resolve(
1292                    cp_range,
1293                    IntraTxCoordinate::tx_window(100..100),
1294                    &options
1295                ),
1296                ResolvedScan {
1297                    // Bounds are based on the requested checkpoint range and
1298                    // checkpoint_hi_exclusive or end_checkpoint
1299                    bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(100)),
1300                    // Entry and end checkpoint take on the terminal checkpoint
1301                    entry_checkpoint: 20,
1302                    end_checkpoint: 20,
1303                    end_position: IntraTxCoordinate::start_of_tx(100),
1304                    exhaustion: RangeExhaustion::LedgerTip,
1305                }
1306            );
1307        }
1308    }
1309
1310    /// When the resolved checkpoint range is below checkpoint_hi_exclusive and empties, the
1311    /// exhaustion reason is CheckpointBound, not LedgerTip.
1312    #[test]
1313    fn resolve_zero_width_window_empties_with_checkpoint_bound() {
1314        for ascending in [true, false] {
1315            let mut request = ProtoQueryOptions::default();
1316            if !ascending {
1317                request.ordering = Some(ProtoOrdering::Descending as i32);
1318            }
1319            let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1320            let cp_range =
1321                ResolvedCheckpointRange::from_request(Some(10), Some(10), 20, &options).unwrap();
1322            assert!(cp_range.is_empty());
1323            assert_eq!(
1324                ResolvedScan::<IntraTxCoordinate>::resolve(
1325                    cp_range,
1326                    IntraTxCoordinate::tx_window(100..100),
1327                    &options
1328                ),
1329                ResolvedScan {
1330                    bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(100)),
1331                    entry_checkpoint: 10,
1332                    end_checkpoint: 10,
1333                    end_position: IntraTxCoordinate::start_of_tx(100),
1334                    exhaustion: RangeExhaustion::CheckpointBound,
1335                }
1336            );
1337        }
1338    }
1339
1340    /// If the bounds are already empty, cursors do not apply.
1341    #[test]
1342    fn cursor_bounds_pass_empty_resolution_through_unchanged() {
1343        let position = Position::Events {
1344            checkpoint: 4,
1345            tx_seq: 50,
1346            event_index: 2,
1347        };
1348        for ascending in [true, false] {
1349            let mut request = ProtoQueryOptions::default();
1350            if !ascending {
1351                request.ordering = Some(ProtoOrdering::Descending as i32);
1352            }
1353            request.after = Some(CursorToken::item(position).encode());
1354            let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1355            let cp_range =
1356                ResolvedCheckpointRange::from_request(Some(30), None, 20, &options).unwrap();
1357            let resolved = ResolvedScan::<IntraTxCoordinate>::resolve(
1358                cp_range,
1359                IntraTxCoordinate::tx_window(100..100),
1360                &options,
1361            );
1362            assert_eq!(resolved.clone().apply_cursor_bounds(&options), resolved);
1363        }
1364    }
1365
1366    /// resolve sets entry_checkpoint to the scan's first checkpoint and the terminal
1367    /// (end_checkpoint, end_position) to its last, per ordering.
1368    #[test]
1369    fn resolve_orients_entry_and_terminal_by_ordering() {
1370        let options = directional_options(true);
1371        let cp_range =
1372            ResolvedCheckpointRange::from_request(Some(3), Some(10), 20, &options).unwrap();
1373        assert_eq!(cp_range.range, 3..10);
1374        let resolved = ResolvedScan::<IntraTxCoordinate>::resolve(
1375            cp_range.clone(),
1376            IntraTxCoordinate::tx_window(100..200),
1377            &options,
1378        );
1379        assert_eq!(
1380            resolved.bounds,
1381            IntraTxScanBounds::from_range(
1382                IntraTxCoordinate::start_of_tx(100)..IntraTxCoordinate::start_of_tx(200)
1383            )
1384        );
1385        assert_eq!(resolved.entry_checkpoint, 3);
1386        assert_eq!(resolved.end_checkpoint, 10);
1387        assert_eq!(resolved.end_position, IntraTxCoordinate::start_of_tx(200));
1388
1389        let options = directional_options(false);
1390        let resolved = ResolvedScan::<IntraTxCoordinate>::resolve(
1391            cp_range,
1392            IntraTxCoordinate::tx_window(100..200),
1393            &options,
1394        );
1395        assert_eq!(resolved.entry_checkpoint, 9);
1396        assert_eq!(resolved.end_checkpoint, 3);
1397        assert_eq!(resolved.end_position, IntraTxCoordinate::start_of_tx(100));
1398    }
1399
1400    /// (ascending, after at the window's end coordinate): empties the bounds at the
1401    /// cursor, and the terminal keeps the cursor's kind — an after-Item echo must stay
1402    /// Item so resume doesn't re-serve it.
1403    #[test]
1404    fn event_after_item_empty_interval_retains_item_kind() {
1405        let position = Position::Events {
1406            checkpoint: 1,
1407            tx_seq: 3,
1408            event_index: 0,
1409        };
1410        let resolved = ResolvedScan {
1411            bounds: IntraTxScanBounds::from_range(
1412                IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(3),
1413            ),
1414            end_checkpoint: 1,
1415            end_position: IntraTxCoordinate::start_of_tx(3),
1416            exhaustion: RangeExhaustion::CheckpointBound,
1417            entry_checkpoint: 0,
1418        };
1419
1420        let mut request = ProtoQueryOptions::default();
1421        request.after = Some(CursorToken::item(position).encode());
1422        let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1423        let item_bounded = resolved.clone().apply_cursor_bounds(&options);
1424
1425        assert!(item_bounded.is_empty());
1426        assert_eq!(
1427            item_bounded.end_position,
1428            IntraTxCoordinate {
1429                tx_seq: 3,
1430                index: 0,
1431            }
1432        );
1433        assert_eq!(
1434            item_bounded.exhaustion,
1435            RangeExhaustion::CursorBound {
1436                kind: sui_rpc_cursor::CursorKind::Item,
1437            }
1438        );
1439
1440        request.after = Some(CursorToken::boundary(position).encode());
1441        let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1442        let boundary_bounded = resolved.apply_cursor_bounds(&options);
1443
1444        assert!(boundary_bounded.is_empty());
1445        assert_eq!(
1446            boundary_bounded.end_position,
1447            IntraTxCoordinate {
1448                tx_seq: 3,
1449                index: 0,
1450            }
1451        );
1452        assert_eq!(
1453            boundary_bounded.exhaustion,
1454            RangeExhaustion::CursorBound {
1455                kind: sui_rpc_cursor::CursorKind::Boundary,
1456            }
1457        );
1458    }
1459
1460    /// `before` cursor that empties the interval sets end_position, and exhaustion to CursorBound.
1461    #[test]
1462    fn event_before_cursor_empties_descending_interval() {
1463        // Start descending over tx range [100, 200) with entry at checkpoint 9 terminating at checkpoint 3
1464        let resolved = ResolvedScan {
1465            // always in ascending order
1466            bounds: IntraTxScanBounds::from_range(
1467                IntraTxCoordinate::start_of_tx(100)..IntraTxCoordinate::start_of_tx(200),
1468            ),
1469            // checkpoints reflect ordering
1470            entry_checkpoint: 9,
1471            end_checkpoint: 3,
1472            end_position: IntraTxCoordinate::start_of_tx(100),
1473            exhaustion: RangeExhaustion::CheckpointBound,
1474        };
1475
1476        // This cursor will empty the interval as it will pull entry_checkpoint to 2
1477        let cursor = Position::Events {
1478            checkpoint: 2,
1479            tx_seq: 90,
1480            event_index: 0,
1481        };
1482
1483        for token in [CursorToken::item(cursor), CursorToken::boundary(cursor)] {
1484            let mut request = ProtoQueryOptions::default();
1485            request.ordering = Some(ProtoOrdering::Descending as i32);
1486            request.before = Some(token.encode());
1487            let options = QueryOptions::events_from_proto(Some(&request), 100, 100).unwrap();
1488
1489            let new_coordinate = IntraTxCoordinate::start_of_tx(90);
1490            assert_eq!(
1491                resolved.clone().apply_cursor_bounds(&options),
1492                ResolvedScan {
1493                    bounds: IntraTxScanBounds::empty_at(new_coordinate),
1494                    entry_checkpoint: 2,
1495                    end_checkpoint: 2,
1496                    // takes on the cursor position that emptied the range
1497                    end_position: new_coordinate,
1498                    exhaustion: RangeExhaustion::CursorBound {
1499                        kind: sui_rpc_cursor::CursorKind::Boundary,
1500                    },
1501                }
1502            );
1503        }
1504    }
1505
1506    /// (ascending, after inside the window): Item admits strictly after its
1507    /// coordinate, Boundary from it; entry checkpoint rises; terminal untouched.
1508    #[test]
1509    fn after_cursor_tightens_ascending_lower_bound() {
1510        let options = after_options(Ordering::Ascending, ev_item(3, 5, 1));
1511        assert_eq!(
1512            resolved_intra_tx().apply_cursor_bounds(&options),
1513            ResolvedScan {
1514                bounds: IntraTxScanBounds {
1515                    lo: Bound::Excluded(IntraTxCoordinate {
1516                        tx_seq: 5,
1517                        index: 1,
1518                    }),
1519                    hi: Bound::Excluded(IntraTxCoordinate::start_of_tx(10)),
1520                },
1521                entry_checkpoint: 3,
1522                ..resolved_intra_tx()
1523            }
1524        );
1525
1526        let options = after_options(Ordering::Ascending, ev_boundary(3, 5, 1));
1527        assert_eq!(
1528            resolved_intra_tx().apply_cursor_bounds(&options),
1529            ResolvedScan {
1530                bounds: IntraTxScanBounds {
1531                    lo: Bound::Included(IntraTxCoordinate {
1532                        tx_seq: 5,
1533                        index: 1,
1534                    }),
1535                    hi: Bound::Excluded(IntraTxCoordinate::start_of_tx(10)),
1536                },
1537                entry_checkpoint: 3,
1538                ..resolved_intra_tx()
1539            }
1540        );
1541    }
1542
1543    #[test]
1544    fn after_cursor_below_lower_bound_only_affects_entry_checkpoint() {
1545        let seed = ResolvedScan {
1546            bounds: IntraTxScanBounds {
1547                lo: Bound::Included(IntraTxCoordinate::start_of_tx(5)),
1548                hi: Bound::Excluded(IntraTxCoordinate::start_of_tx(10)),
1549            },
1550            ..resolved_intra_tx()
1551        };
1552        let expected = ResolvedScan {
1553            entry_checkpoint: 4,
1554            ..seed.clone()
1555        };
1556        let options = after_options(Ordering::Ascending, ev_boundary(4, 2, 0));
1557        assert_eq!(seed.clone().apply_cursor_bounds(&options), expected);
1558        let options = after_options(Ordering::Ascending, ev_item(4, 2, 0));
1559        assert_eq!(seed.apply_cursor_bounds(&options), expected);
1560    }
1561
1562    /// Tightens the lower bound, and the terminal becomes the cursor's, because
1563    /// the after cursor is where a descending scan stops.
1564    #[test]
1565    fn descending_after_cursor_sets_terminal_and_tightens() {
1566        let options = after_options(Ordering::Descending, ev_item(3, 5, 1));
1567        assert_eq!(
1568            resolved_intra_tx().apply_cursor_bounds(&options),
1569            ResolvedScan {
1570                bounds: IntraTxScanBounds {
1571                    lo: Bound::Excluded(IntraTxCoordinate {
1572                        tx_seq: 5,
1573                        index: 1,
1574                    }),
1575                    hi: Bound::Excluded(IntraTxCoordinate::start_of_tx(10)),
1576                },
1577                end_checkpoint: 3,
1578                end_position: IntraTxCoordinate {
1579                    tx_seq: 5,
1580                    index: 1,
1581                },
1582                exhaustion: RangeExhaustion::CursorBound {
1583                    kind: sui_rpc_cursor::CursorKind::Boundary,
1584                },
1585                ..resolved_intra_tx()
1586            }
1587        );
1588
1589        let options = after_options(Ordering::Descending, ev_boundary(3, 5, 1));
1590        assert_eq!(
1591            resolved_intra_tx().apply_cursor_bounds(&options),
1592            ResolvedScan {
1593                bounds: IntraTxScanBounds {
1594                    lo: Bound::Included(IntraTxCoordinate {
1595                        tx_seq: 5,
1596                        index: 1,
1597                    }),
1598                    hi: Bound::Excluded(IntraTxCoordinate::start_of_tx(10)),
1599                },
1600                end_checkpoint: 3,
1601                end_position: IntraTxCoordinate {
1602                    tx_seq: 5,
1603                    index: 1,
1604                },
1605                exhaustion: RangeExhaustion::CursorBound {
1606                    kind: sui_rpc_cursor::CursorKind::Boundary,
1607                },
1608                ..resolved_intra_tx()
1609            }
1610        );
1611    }
1612
1613    /// When the after cursor empties the window, the record empties at the
1614    /// cursor's coordinate and checkpoint; either cursor kind (Item or Boundary)
1615    /// normalizes to Boundary.
1616    #[test]
1617    fn after_cursor_empties_descending_interval() {
1618        let seed = ResolvedScan {
1619            bounds: IntraTxScanBounds::from_range(
1620                IntraTxCoordinate::start_of_tx(3)..IntraTxCoordinate::start_of_tx(9),
1621            ),
1622            entry_checkpoint: 9,
1623            end_checkpoint: 3,
1624            end_position: IntraTxCoordinate::start_of_tx(3),
1625            exhaustion: RangeExhaustion::CheckpointBound,
1626        };
1627
1628        // Either cursor kind in: the descending recording must normalize the
1629        // terminal kind to Boundary.
1630        let expected_coordinate = IntraTxCoordinate {
1631            tx_seq: 100,
1632            index: 10,
1633        };
1634        let expected = ResolvedScan {
1635            bounds: IntraTxScanBounds::empty_at(expected_coordinate),
1636            entry_checkpoint: 9,
1637            end_checkpoint: 100,
1638            end_position: expected_coordinate,
1639            exhaustion: RangeExhaustion::CursorBound {
1640                kind: sui_rpc_cursor::CursorKind::Boundary,
1641            },
1642        };
1643
1644        let options = after_options(Ordering::Descending, ev_item(100, 100, 10));
1645        assert_eq!(seed.clone().apply_cursor_bounds(&options), expected);
1646        let options = after_options(Ordering::Descending, ev_boundary(100, 100, 10));
1647        assert_eq!(seed.apply_cursor_bounds(&options), expected);
1648    }
1649
1650    /// (descending, before inside the window, either cursor kind (Item or
1651    /// Boundary)): hi becomes exclusive at the cursor; entry checkpoint folds
1652    /// down; terminal untouched.
1653    #[test]
1654    fn before_cursor_tightens_descending_upper_bound() {
1655        let seed = ResolvedScan {
1656            entry_checkpoint: 8,
1657            ..resolved_intra_tx()
1658        };
1659        // The before-arm is kind-agnostic: Item and Boundary cursors produce
1660        // identical records.
1661        let expected = ResolvedScan {
1662            bounds: IntraTxScanBounds {
1663                lo: Bound::Included(IntraTxCoordinate::start_of_tx(0)),
1664                hi: Bound::Excluded(IntraTxCoordinate {
1665                    tx_seq: 5,
1666                    index: 1,
1667                }),
1668            },
1669            entry_checkpoint: 6,
1670            ..seed.clone()
1671        };
1672        let options = before_options(Ordering::Descending, ev_item(6, 5, 1));
1673        assert_eq!(seed.clone().apply_cursor_bounds(&options), expected);
1674        let options = before_options(Ordering::Descending, ev_boundary(6, 5, 1));
1675        assert_eq!(seed.apply_cursor_bounds(&options), expected);
1676    }
1677
1678    #[test]
1679    fn before_cursor_above_window_never_tightens() {
1680        let expected = ResolvedScan {
1681            entry_checkpoint: 1,
1682            ..resolved_intra_tx()
1683        };
1684        for cursor in [ev_boundary(1, 12, 0), ev_item(1, 12, 0)] {
1685            let options = before_options(Ordering::Descending, cursor.clone());
1686            assert_eq!(resolved_intra_tx().apply_cursor_bounds(&options), expected);
1687
1688            let options = before_options(Ordering::Ascending, cursor);
1689            assert_eq!(
1690                resolved_intra_tx().apply_cursor_bounds(&options),
1691                resolved_intra_tx()
1692            );
1693        }
1694    }
1695
1696    #[test]
1697    fn ascending_before_cursor_sets_terminal_and_tightens() {
1698        let expected = ResolvedScan {
1699            bounds: IntraTxScanBounds {
1700                lo: Bound::Included(IntraTxCoordinate::start_of_tx(0)),
1701                hi: Bound::Excluded(IntraTxCoordinate {
1702                    tx_seq: 5,
1703                    index: 1,
1704                }),
1705            },
1706            entry_checkpoint: 2,
1707            end_checkpoint: 6,
1708            end_position: IntraTxCoordinate {
1709                tx_seq: 5,
1710                index: 1,
1711            },
1712            exhaustion: RangeExhaustion::CursorBound {
1713                kind: sui_rpc_cursor::CursorKind::Boundary,
1714            },
1715        };
1716        let options = before_options(Ordering::Ascending, ev_boundary(6, 5, 1));
1717        assert_eq!(resolved_intra_tx().apply_cursor_bounds(&options), expected);
1718        let options = before_options(Ordering::Ascending, ev_item(6, 5, 1));
1719        assert_eq!(resolved_intra_tx().apply_cursor_bounds(&options), expected);
1720    }
1721
1722    /// When a before cursor empties an ascending interval, the bounds are emptied at the cursor,
1723    /// and end_position and end_checkpoint are set to the cursor's.
1724    #[test]
1725    fn before_cursor_empties_ascending_interval() {
1726        let seed = ResolvedScan {
1727            bounds: IntraTxScanBounds::from_range(
1728                IntraTxCoordinate::start_of_tx(3)..IntraTxCoordinate::start_of_tx(10),
1729            ),
1730            entry_checkpoint: 2,
1731            end_checkpoint: 9,
1732            ..resolved_intra_tx()
1733        };
1734        let expected = ResolvedScan {
1735            bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(3)),
1736            end_checkpoint: 1,
1737            end_position: IntraTxCoordinate::start_of_tx(3),
1738            exhaustion: RangeExhaustion::CursorBound {
1739                kind: sui_rpc_cursor::CursorKind::Boundary,
1740            },
1741            ..seed.clone()
1742        };
1743        let options = before_options(Ordering::Ascending, ev_boundary(1, 3, 0));
1744        assert_eq!(seed.clone().apply_cursor_bounds(&options), expected);
1745        let options = before_options(Ordering::Ascending, ev_item(1, 3, 0));
1746        assert_eq!(seed.apply_cursor_bounds(&options), expected);
1747    }
1748
1749    /// (ascending, after and before both present): if only the after files, its
1750    /// frame stands (Item kind retained); if both file, the before wins at the min
1751    /// position.
1752    #[test]
1753    fn ascending_with_both_cursors_picks_the_terminal_owner() {
1754        // The before cursor falls out of the range so it is rejected. The after cursor empties the
1755        // range, and is attributed to the exhaustion reason.
1756        let options = QueryOptions {
1757            limit_items: 100,
1758            ordering: Ordering::Ascending,
1759            after: Some(ev_item(12, 12, 0)),
1760            before: Some(ev_boundary(14, 14, 0)),
1761        };
1762        assert_eq!(
1763            resolved_intra_tx().apply_cursor_bounds(&options),
1764            ResolvedScan {
1765                bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(12)),
1766                entry_checkpoint: 12,
1767                end_checkpoint: 12,
1768                end_position: IntraTxCoordinate::start_of_tx(12),
1769                exhaustion: RangeExhaustion::CursorBound {
1770                    kind: sui_rpc_cursor::CursorKind::Item,
1771                },
1772            }
1773        );
1774
1775        // When both cursors would empty, `before` cursor attribution remains.
1776        let options = QueryOptions {
1777            limit_items: 100,
1778            ordering: Ordering::Ascending,
1779            after: Some(ev_boundary(12, 12, 0)),
1780            before: Some(ev_boundary(3, 3, 0)),
1781        };
1782        assert_eq!(
1783            resolved_intra_tx().apply_cursor_bounds(&options),
1784            ResolvedScan {
1785                bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(3)),
1786                entry_checkpoint: 12,
1787                end_checkpoint: 3,
1788                end_position: IntraTxCoordinate::start_of_tx(3),
1789                exhaustion: RangeExhaustion::CursorBound {
1790                    kind: sui_rpc_cursor::CursorKind::Boundary,
1791                },
1792            }
1793        );
1794    }
1795
1796    /// (descending, after and before both present): the after's frame stands when
1797    /// only it files; when both file, the min position wins; a before that empties
1798    /// overrides the after's earlier terminal.
1799    #[test]
1800    fn descending_with_both_cursors_picks_the_terminal_owner() {
1801        // After empties, before rejected: the after-claim survives, and the
1802        // rejected before-cursor still folds entry down.
1803        let options = QueryOptions {
1804            limit_items: 100,
1805            ordering: Ordering::Descending,
1806            after: Some(ev_boundary(9, 12, 0)),
1807            before: Some(ev_boundary(1, 15, 0)),
1808        };
1809        assert_eq!(
1810            resolved_intra_tx().apply_cursor_bounds(&options),
1811            ResolvedScan {
1812                bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(12)),
1813                entry_checkpoint: 1,
1814                end_checkpoint: 9,
1815                end_position: IntraTxCoordinate::start_of_tx(12),
1816                exhaustion: RangeExhaustion::CursorBound {
1817                    kind: sui_rpc_cursor::CursorKind::Boundary,
1818                },
1819            }
1820        );
1821
1822        // After empties, before also files: before wins at the min position.
1823        let options = QueryOptions {
1824            limit_items: 100,
1825            ordering: Ordering::Descending,
1826            after: Some(ev_boundary(9, 12, 0)),
1827            before: Some(ev_item(1, 5, 2)),
1828        };
1829        assert_eq!(
1830            resolved_intra_tx().apply_cursor_bounds(&options),
1831            ResolvedScan {
1832                bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate {
1833                    tx_seq: 5,
1834                    index: 2,
1835                }),
1836                entry_checkpoint: 1,
1837                end_checkpoint: 1,
1838                end_position: IntraTxCoordinate {
1839                    tx_seq: 5,
1840                    index: 2,
1841                },
1842                exhaustion: RangeExhaustion::CursorBound {
1843                    kind: sui_rpc_cursor::CursorKind::Boundary,
1844                },
1845            }
1846        );
1847
1848        // After tightens without emptying, before empties: the before-claim
1849        // overrides the after-cursor's eagerly written terminal.
1850        let options = QueryOptions {
1851            limit_items: 100,
1852            ordering: Ordering::Descending,
1853            after: Some(ev_item(8, 5, 0)),
1854            before: Some(ev_item(1, 5, 0)),
1855        };
1856        assert_eq!(
1857            resolved_intra_tx().apply_cursor_bounds(&options),
1858            ResolvedScan {
1859                bounds: IntraTxScanBounds::empty_at(IntraTxCoordinate::start_of_tx(5)),
1860                entry_checkpoint: 1,
1861                end_checkpoint: 1,
1862                end_position: IntraTxCoordinate::start_of_tx(5),
1863                exhaustion: RangeExhaustion::CursorBound {
1864                    kind: sui_rpc_cursor::CursorKind::Boundary,
1865                },
1866            }
1867        );
1868    }
1869
1870    /// (descending, after below the window): complete noop — even entry_checkpoint
1871    /// is untouched, since a descending scan doesn't enter from the after side.
1872    #[test]
1873    fn after_cursor_rejected_descending_is_noop() {
1874        let seed = ResolvedScan {
1875            bounds: IntraTxScanBounds {
1876                lo: Bound::Included(IntraTxCoordinate::start_of_tx(5)),
1877                hi: Bound::Excluded(IntraTxCoordinate::start_of_tx(10)),
1878            },
1879            ..resolved_intra_tx()
1880        };
1881        let options = after_options(Ordering::Descending, ev_boundary(4, 2, 0));
1882        assert_eq!(seed.clone().apply_cursor_bounds(&options), seed);
1883    }
1884
1885    /// A cursor whose bound coincides with the window's own is a no-op tighten, but
1886    /// the terminal still becomes the cursor's — CursorBound on an unchanged,
1887    /// non-empty record.
1888    #[test]
1889    fn cursor_at_the_window_bound_still_takes_the_terminal() {
1890        // Admission at the exact bound is a no-op tighten, but the stop-side
1891        // cursor still takes over the terminal: exhaustion flips to
1892        // CursorBound on a non-empty result whose bounds did not change.
1893        let options = before_options(Ordering::Ascending, ev_boundary(6, 10, 0));
1894        assert_eq!(
1895            resolved_intra_tx().apply_cursor_bounds(&options),
1896            ResolvedScan {
1897                bounds: IntraTxScanBounds::from_range(
1898                    IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(10)
1899                ),
1900                entry_checkpoint: 2,
1901                end_checkpoint: 6,
1902                end_position: IntraTxCoordinate::start_of_tx(10),
1903                exhaustion: RangeExhaustion::CursorBound {
1904                    kind: sui_rpc_cursor::CursorKind::Boundary,
1905                },
1906            }
1907        );
1908
1909        let options = after_options(Ordering::Descending, ev_boundary(6, 0, 0));
1910        assert_eq!(
1911            resolved_intra_tx().apply_cursor_bounds(&options),
1912            ResolvedScan {
1913                bounds: IntraTxScanBounds::from_range(
1914                    IntraTxCoordinate::start_of_tx(0)..IntraTxCoordinate::start_of_tx(10)
1915                ),
1916                entry_checkpoint: 2,
1917                end_checkpoint: 6,
1918                end_position: IntraTxCoordinate::start_of_tx(0),
1919                exhaustion: RangeExhaustion::CursorBound {
1920                    kind: sui_rpc_cursor::CursorKind::Boundary,
1921                },
1922            }
1923        );
1924    }
1925
1926    // An after-Item cursor makes the exclusive lower bound one past the cursor, while an
1927    // after-Boundary cursor makes the lower bound equal to the cursor.
1928    #[test]
1929    fn tx_after_cursor_tightens_ascending_lower_bound() {
1930        // Item at n admits strictly-after symbolically; the successor lives
1931        // at the store edge, so both forms fetch from n + 1.
1932        let options = after_options(Ordering::Ascending, tx_item(3, 12));
1933        // Resolves to `(12, 20)`
1934        let tightened = resolved_range(10..20).apply_cursor_bounds(&options);
1935        assert_eq!(
1936            tightened,
1937            ResolvedScan {
1938                bounds: ScanBounds {
1939                    lo: Bound::Excluded(12),
1940                    hi: Bound::Excluded(20),
1941                },
1942                entry_checkpoint: 3,
1943                ..resolved_range(10..20)
1944            }
1945        );
1946        // Projection into Range<u64> sets the lower bound as n + 1
1947        assert_eq!(tightened.range(), 13..20);
1948
1949        let options = after_options(Ordering::Ascending, tx_boundary(3, 12));
1950        assert_eq!(
1951            resolved_range(10..20).apply_cursor_bounds(&options),
1952            ResolvedScan {
1953                bounds: ScanBounds {
1954                    lo: Bound::Included(12),
1955                    hi: Bound::Excluded(20),
1956                },
1957                entry_checkpoint: 3,
1958                ..resolved_range(10..20)
1959            }
1960        );
1961    }
1962
1963    /// Tightens the lower bound, and the terminal becomes the cursor's, because
1964    /// the after cursor is where a descending scan stops.
1965    #[test]
1966    fn tx_descending_after_cursor_sets_terminal_and_tightens() {
1967        // Item at 12 and Boundary at 13 scan the same exclusive edge (13..20),
1968        // but each terminal echoes its own raw cursor coordinate.
1969        let options = after_options(Ordering::Descending, tx_item(3, 12));
1970        assert_eq!(
1971            resolved_range(10..20).apply_cursor_bounds(&options),
1972            ResolvedScan {
1973                bounds: ScanBounds {
1974                    lo: Bound::Excluded(12),
1975                    hi: Bound::Excluded(20),
1976                },
1977                entry_checkpoint: 0,
1978                end_checkpoint: 3,
1979                end_position: 12,
1980                exhaustion: RangeExhaustion::CursorBound {
1981                    kind: sui_rpc_cursor::CursorKind::Boundary,
1982                },
1983            }
1984        );
1985        let options = after_options(Ordering::Descending, tx_boundary(3, 13));
1986        assert_eq!(
1987            resolved_range(10..20).apply_cursor_bounds(&options),
1988            ResolvedScan {
1989                bounds: ScanBounds::from_range(13..20),
1990                entry_checkpoint: 0,
1991                end_checkpoint: 3,
1992                end_position: 13,
1993                exhaustion: RangeExhaustion::CursorBound {
1994                    kind: sui_rpc_cursor::CursorKind::Boundary,
1995                },
1996            }
1997        );
1998    }
1999
2000    /// When the after cursor empties the window, the record empties at the
2001    /// cursor's coordinate and checkpoint; either cursor kind (Item or Boundary)
2002    /// normalizes to Boundary.
2003    #[test]
2004    fn tx_after_cursor_empties_descending_interval() {
2005        // Item at 24 and Boundary at 25 scan the same exclusive edge, but the
2006        // terminal echoes each cursor's raw coordinate.
2007        let options = after_options(Ordering::Descending, tx_boundary(9, 25));
2008        assert_eq!(
2009            resolved_range(10..20).apply_cursor_bounds(&options),
2010            ResolvedScan {
2011                bounds: ScanBounds::from_range(25..25),
2012                entry_checkpoint: 0,
2013                end_checkpoint: 9,
2014                end_position: 25,
2015                exhaustion: RangeExhaustion::CursorBound {
2016                    kind: sui_rpc_cursor::CursorKind::Boundary,
2017                },
2018            }
2019        );
2020        let options = after_options(Ordering::Descending, tx_item(9, 24));
2021        assert_eq!(
2022            resolved_range(10..20).apply_cursor_bounds(&options),
2023            ResolvedScan {
2024                bounds: ScanBounds::from_range(24..24),
2025                entry_checkpoint: 0,
2026                end_checkpoint: 9,
2027                end_position: 24,
2028                exhaustion: RangeExhaustion::CursorBound {
2029                    kind: sui_rpc_cursor::CursorKind::Boundary,
2030                },
2031            }
2032        );
2033    }
2034
2035    /// after-Item at n and after-Boundary at n + 1 admit the same interval
2036    /// (here both empty the window); each terminal echoes its own raw
2037    /// coordinate and kind.
2038    #[test]
2039    fn tx_after_item_and_successor_boundary_admit_the_same_interval() {
2040        // Item at 24 and Boundary at 25 scan the same exclusive edge, but the
2041        // terminal echoes each cursor's raw coordinate and kind: the Item echo
2042        // keeps Item so resume does not re-serve.
2043        let options = after_options(Ordering::Ascending, tx_item(5, 24));
2044        assert_eq!(
2045            resolved_range(10..20).apply_cursor_bounds(&options),
2046            ResolvedScan {
2047                bounds: ScanBounds::from_range(24..24),
2048                entry_checkpoint: 5,
2049                end_checkpoint: 5,
2050                end_position: 24,
2051                exhaustion: RangeExhaustion::CursorBound {
2052                    kind: sui_rpc_cursor::CursorKind::Item,
2053                },
2054            }
2055        );
2056        let options = after_options(Ordering::Ascending, tx_boundary(5, 25));
2057        assert_eq!(
2058            resolved_range(10..20).apply_cursor_bounds(&options),
2059            ResolvedScan {
2060                bounds: ScanBounds::from_range(25..25),
2061                entry_checkpoint: 5,
2062                end_checkpoint: 5,
2063                end_position: 25,
2064                exhaustion: RangeExhaustion::CursorBound {
2065                    kind: sui_rpc_cursor::CursorKind::Boundary,
2066                },
2067            }
2068        );
2069    }
2070
2071    /// (ascending, after-Boundary at u64::MAX): nothing can lie at MAX; empties at
2072    /// (cursor checkpoint, MAX), Boundary.
2073    #[test]
2074    fn tx_after_boundary_at_max_empties_interval() {
2075        let options = after_options(Ordering::Ascending, tx_boundary(5, u64::MAX));
2076        assert_eq!(
2077            resolved_range(10..20).apply_cursor_bounds(&options),
2078            empty_resolved_range(
2079                5,
2080                u64::MAX,
2081                RangeExhaustion::CursorBound {
2082                    kind: sui_rpc_cursor::CursorKind::Boundary,
2083                },
2084            )
2085        );
2086    }
2087
2088    /// If the bounds are already empty, cursors do not apply.
2089    #[test]
2090    fn tx_after_item_at_max_lets_before_cursor_win() {
2091        // An after-Item at u64::MAX is an ordinary emptying admission (the
2092        // saturated successor), not a short-circuit: a crossed before-cursor
2093        // still participates and wins the attribution at the min position.
2094        let options = QueryOptions {
2095            limit_items: 100,
2096            ordering: Ordering::Ascending,
2097            after: Some(tx_item(5, u64::MAX)),
2098            before: Some(tx_boundary(3, 15)),
2099        };
2100        assert_eq!(
2101            resolved_range(10..20).apply_cursor_bounds(&options),
2102            ResolvedScan {
2103                bounds: ScanBounds::from_range(15..15),
2104                entry_checkpoint: 5,
2105                end_checkpoint: 3,
2106                end_position: 15,
2107                exhaustion: RangeExhaustion::CursorBound {
2108                    kind: sui_rpc_cursor::CursorKind::Boundary,
2109                },
2110            }
2111        );
2112
2113        // Descending: the before-cursor's entry fold also runs now.
2114        let seed = ResolvedScan {
2115            entry_checkpoint: 7,
2116            ..resolved_range(10..20)
2117        };
2118        let options = QueryOptions {
2119            limit_items: 100,
2120            ordering: Ordering::Descending,
2121            after: Some(tx_item(5, u64::MAX)),
2122            before: Some(tx_boundary(3, 15)),
2123        };
2124        assert_eq!(
2125            seed.apply_cursor_bounds(&options),
2126            ResolvedScan {
2127                bounds: ScanBounds::from_range(15..15),
2128                entry_checkpoint: 3,
2129                end_checkpoint: 3,
2130                end_position: 15,
2131                exhaustion: RangeExhaustion::CursorBound {
2132                    kind: sui_rpc_cursor::CursorKind::Boundary,
2133                },
2134            }
2135        );
2136    }
2137
2138    /// Resume-equivalence witness for the raw cursor echo: an after-Item at n
2139    /// admits exactly what the successor-form echo it replaced (Boundary at
2140    /// n + 1) admits, so feeding either token back scans the same interval.
2141    /// The terminals differ by design (each echoes its own raw coordinate);
2142    /// the descending pair is pinned by
2143    /// [`tx_descending_after_cursor_sets_terminal_and_tightens`].
2144    #[test]
2145    fn raw_after_item_echo_resumes_like_successor_boundary() {
2146        let item = after_options(Ordering::Ascending, tx_item(3, 24));
2147        let successor = after_options(Ordering::Ascending, tx_boundary(3, 25));
2148
2149        // Item(3, 24) results in the same range as Boundary(3, 25)
2150        assert_eq!(
2151            resolved_range(10..30).apply_cursor_bounds(&item).range(),
2152            resolved_range(10..30)
2153                .apply_cursor_bounds(&successor)
2154                .range(),
2155        );
2156
2157        // Drained window: both resumptions admit nothing.
2158        assert!(resolved_range(10..20).apply_cursor_bounds(&item).is_empty());
2159        assert!(
2160            resolved_range(10..20)
2161                .apply_cursor_bounds(&successor)
2162                .is_empty()
2163        );
2164    }
2165
2166    /// For some ResolvedScan<u64>, if its bounds are Excluded(A) and Excluded(A + 1), its bounds
2167    /// report non-empty, but its range is empty.
2168    /// `test_list_transactions_after_last_item_ends_at_window_bound` checks that the result set is
2169    /// empty.
2170    #[test]
2171    fn tx_after_item_at_window_edge_defers_to_drain() {
2172        let options = after_options(Ordering::Ascending, tx_item(5, 19));
2173        let initial_range = resolved_range(10..20);
2174        let resolved = initial_range.clone().apply_cursor_bounds(&options);
2175        assert_eq!(
2176            resolved,
2177            ResolvedScan {
2178                bounds: ScanBounds {
2179                    lo: Bound::Excluded(19),
2180                    hi: Bound::Excluded(20),
2181                },
2182                entry_checkpoint: 5,
2183                ..initial_range
2184            }
2185        );
2186        assert!(!resolved.is_empty());
2187        assert!(resolved.range().is_empty());
2188    }
2189
2190    /// Exclusive after on descending bumps the bounds, and sets the end_checkpoint, end_position,
2191    /// and exhaustion to the after cursor.
2192    #[test]
2193    fn tx_after_item_at_window_edge_descending_keeps_stamped_terminal() {
2194        let options = after_options(Ordering::Descending, tx_item(5, 19));
2195        let initial_range = resolved_range(10..20);
2196        let resolved = initial_range.clone().apply_cursor_bounds(&options);
2197        assert_eq!(
2198            resolved,
2199            ResolvedScan {
2200                bounds: ScanBounds {
2201                    lo: Bound::Excluded(19),
2202                    hi: Bound::Excluded(20),
2203                },
2204                end_checkpoint: 5,
2205                end_position: 19,
2206                exhaustion: RangeExhaustion::CursorBound {
2207                    kind: sui_rpc_cursor::CursorKind::Boundary,
2208                },
2209                ..initial_range
2210            }
2211        );
2212        assert!(resolved.range().is_empty());
2213    }
2214
2215    /// On a descending scan, when the after-Item cursor is exactly on the terminating window, the
2216    /// terminal values remain unchanged; the cursor does not overwrite the original bounds as it is
2217    /// not additionally restrictive.
2218    #[test]
2219    fn tx_after_item_below_window_start_descending_is_noop() {
2220        let options = after_options(Ordering::Descending, tx_item(5, 9));
2221        assert_eq!(
2222            resolved_range(10..20).apply_cursor_bounds(&options),
2223            resolved_range(10..20)
2224        );
2225    }
2226
2227    #[test]
2228    fn item_cursor_can_be_used_as_after_or_before() {
2229        let token = CursorToken::item(Position::Transactions {
2230            checkpoint: 1,
2231            tx_seq: 11,
2232        })
2233        .encode();
2234
2235        let mut request = ProtoQueryOptions::default();
2236        request.after = Some(token.clone());
2237        let options = query_options_from_proto(Some(&request)).unwrap();
2238        assert_eq!(
2239            resolved_range(10..20).apply_cursor_bounds(&options).range(),
2240            12..20
2241        );
2242
2243        request.after = None;
2244        request.before = Some(token);
2245        let options = query_options_from_proto(Some(&request)).unwrap();
2246        assert_eq!(
2247            resolved_range(10..20).apply_cursor_bounds(&options).range(),
2248            10..11
2249        );
2250    }
2251}