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 }