lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

cursor.rs (3275B)


      1 //! Deterministic relay event cursor shared by paging and reconnect catch-up.
      2 
      3 use crate::Error;
      4 
      5 /// Stable total-order position for one Nostr event.
      6 ///
      7 /// Relay timestamps are only second-granular. The canonical lowercase event id
      8 /// provides a deterministic tie-breaker for descending fetch pages and a
      9 /// non-regressing checkpoint position for inclusive subscription reconnects.
     10 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     11 pub struct RelayCursor {
     12     created_at_unix_s: u64,
     13     event_id: String,
     14 }
     15 
     16 impl RelayCursor {
     17     /// Creates a cursor from an event timestamp and canonical lowercase id.
     18     pub fn new(created_at_unix_s: u64, event_id: impl Into<String>) -> Result<Self, Error> {
     19         let event_id = event_id.into();
     20         if event_id.len() != 64
     21             || !event_id
     22                 .bytes()
     23                 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
     24         {
     25             return Err(Error::InvalidRelayCursor);
     26         }
     27         Ok(Self {
     28             created_at_unix_s,
     29             event_id,
     30         })
     31     }
     32 
     33     /// Returns the second-granular Nostr timestamp.
     34     #[must_use]
     35     pub const fn created_at_unix_s(&self) -> u64 {
     36         self.created_at_unix_s
     37     }
     38 
     39     /// Returns the canonical event-id tie breaker.
     40     #[must_use]
     41     pub fn event_id(&self) -> &str {
     42         self.event_id.as_str()
     43     }
     44 
     45     /// Returns whether a candidate follows this cursor in ascending total
     46     /// order. Equal timestamps are resolved by event id. Live subscriptions
     47     /// additionally admit out-of-order peers from the checkpoint second so
     48     /// this total-order helper is not itself used as a lossless admission gate.
     49     #[must_use]
     50     pub fn precedes(&self, created_at_unix_s: u64, event_id: &str) -> bool {
     51         created_at_unix_s > self.created_at_unix_s
     52             || created_at_unix_s == self.created_at_unix_s && event_id > self.event_id.as_str()
     53     }
     54 
     55     /// Returns whether a candidate follows this cursor in descending page
     56     /// order. Equal timestamps are resolved by the inverse event-id order.
     57     #[must_use]
     58     pub(crate) fn page_precedes(&self, created_at_unix_s: u64, event_id: &str) -> bool {
     59         created_at_unix_s < self.created_at_unix_s
     60             || created_at_unix_s == self.created_at_unix_s && event_id < self.event_id.as_str()
     61     }
     62 }
     63 
     64 #[cfg(test)]
     65 mod tests {
     66     use super::*;
     67 
     68     #[test]
     69     fn equal_timestamp_ties_are_lossless_in_both_directions() {
     70         let cursor = RelayCursor::new(10, "b".repeat(64)).expect("cursor");
     71         assert!(cursor.precedes(11, &"0".repeat(64)));
     72         assert!(cursor.precedes(10, &"c".repeat(64)));
     73         assert!(!cursor.precedes(9, &"f".repeat(64)));
     74         assert!(!cursor.precedes(10, &"b".repeat(64)));
     75         assert!(cursor.page_precedes(9, &"f".repeat(64)));
     76         assert!(cursor.page_precedes(10, &"a".repeat(64)));
     77         assert!(!cursor.page_precedes(10, &"c".repeat(64)));
     78         assert_eq!(cursor.created_at_unix_s(), 10);
     79         assert_eq!(cursor.event_id(), "b".repeat(64));
     80     }
     81 
     82     #[test]
     83     fn cursor_rejects_noncanonical_event_ids() {
     84         for invalid in ["a".repeat(63), "A".repeat(64), "g".repeat(64)] {
     85             assert!(RelayCursor::new(1, invalid).is_err());
     86         }
     87     }
     88 }