//! Opaque pagination cursor for `GET /api/timeline/home`. //! //! The cursor is a base64url-encoded `:` //! pair. The format is intentionally not stable across releases — it's //! an implementation detail of the API. The client must treat it as //! an opaque string and pass it back unchanged. //! //! `decode` returns [`CursorState`] which the route uses to build a //! `WHERE (indexed_at, uri) < ($1, $2)` predicate for stable //! keyset pagination (avoids `OFFSET` drift when new posts land //! between page fetches). use base64::Engine; use chrono::{DateTime, TimeZone, Utc}; /// Internal decoded representation of a timeline cursor. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CursorState { /// Microseconds since the unix epoch of the last seen post. pub ts: i64, /// URI of the last seen post. pub uri: String, } /// Encode `(indexed_at, uri)` into the wire-format cursor string. pub fn encode(indexed_at: DateTime, uri: &str) -> String { let ts = indexed_at.timestamp_micros(); let raw = format!("{ts}:{uri}"); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes()) } /// Decode a wire-format cursor string back into a [`CursorState`]. /// /// Returns an error string (not a typed error) so the route can put it /// directly into the 400 response body. The `Result` type is local. pub fn decode(s: &str) -> Result { let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(s.as_bytes()) .map_err(|e| format!("invalid cursor: {e}"))?; let text = std::str::from_utf8(&bytes).map_err(|e| format!("invalid cursor utf8: {e}"))?; let (ts_s, uri) = text .split_once(':') .ok_or_else(|| "invalid cursor: missing ':'".to_string())?; let ts: i64 = ts_s .parse() .map_err(|e| format!("invalid cursor ts: {e}"))?; Ok(CursorState { ts, uri: uri.to_string(), }) } /// Helper for tests / callers that want a `DateTime` back from a /// [`CursorState`]. The route doesn't need it (it uses the raw /// micros), but exposing it keeps the API symmetric. #[allow(dead_code)] pub fn ts_to_datetime(ts: i64) -> Option> { Utc.timestamp_micros(ts).single() } #[cfg(test)] mod tests { use super::*; #[test] fn encode_decode_round_trip() { let dt = Utc .timestamp_micros(1_700_000_000_123_456) .single() .expect("valid ts"); let uri = "at://did:plc:abc/app.twi.post/3k2"; let encoded = encode(dt, uri); // Encoded form is base64url (no '+' / '/') and unpadded. assert!(!encoded.contains('=')); assert!(!encoded.contains('+')); assert!(!encoded.contains('/')); let decoded = decode(&encoded).unwrap(); assert_eq!(decoded.ts, dt.timestamp_micros()); assert_eq!(decoded.uri, uri); } #[test] fn decode_rejects_garbage() { assert!(decode("!!!not-base64!!!").is_err()); assert!(decode("").is_err()); // Valid base64 but missing colon let no_colon = base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(b"1234567890"); assert!(decode(&no_colon).is_err()); } #[test] fn decode_rejects_non_numeric_ts() { let bad = base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(b"notanumber:at://x"); assert!(decode(&bad).is_err()); } #[test] fn ts_round_trip_through_datetime() { let dt = Utc .timestamp_micros(1_700_000_000_000_001) .single() .expect("valid ts"); let encoded = encode(dt, "at://x/y/z"); let decoded = decode(&encoded).unwrap(); let back = ts_to_datetime(decoded.ts).unwrap(); assert_eq!(back, dt); } }