use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crate::error::{Result, ShoreError};
use crate::model::EventId;
use crate::session::identity::instant::parse_event_instant;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HistoryCursor {
pub occurred_at: String,
pub event_id: EventId,
}
impl HistoryCursor {
pub fn encode(&self) -> String {
let raw = format!("{}\n{}", self.occurred_at, self.event_id.as_str());
URL_SAFE_NO_PAD.encode(raw.as_bytes())
}
pub fn decode(token: &str) -> Result<HistoryCursor> {
let bytes = URL_SAFE_NO_PAD
.decode(token.as_bytes())
.map_err(|_| invalid_cursor())?;
let raw = String::from_utf8(bytes).map_err(|_| invalid_cursor())?;
let (occurred_at, event_id) = raw.split_once('\n').ok_or_else(invalid_cursor)?;
Ok(HistoryCursor {
occurred_at: occurred_at.to_owned(),
event_id: EventId::new(event_id),
})
}
}
fn invalid_cursor() -> ShoreError {
ShoreError::Message("invalid history cursor".to_owned())
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) enum EventInstantKey<'a> {
Unparseable(&'a str),
Parsed(i64),
}
pub(super) fn cmp_key<'a>(
occurred_at: &'a str,
event_id: &'a str,
) -> (EventInstantKey<'a>, &'a str) {
let instant = parse_event_instant(occurred_at)
.map(EventInstantKey::Parsed)
.unwrap_or(EventInstantKey::Unparseable(occurred_at));
(instant, event_id)
}
pub(super) fn next_cursor_for(
keys: &[HistoryCursor],
range: &std::ops::Range<usize>,
) -> Option<HistoryCursor> {
(range.end > range.start && range.end < keys.len()).then(|| keys[range.end - 1].clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::EventId;
#[test]
fn cursor_round_trips() {
let cursor = HistoryCursor {
occurred_at: "unix-ms:1782800000000".into(),
event_id: EventId::new("evt:sha256:abc"),
};
let token = cursor.encode();
assert_eq!(HistoryCursor::decode(&token).unwrap(), cursor);
}
#[test]
fn cursor_encode_is_deterministic() {
let cursor = HistoryCursor {
occurred_at: "unix-ms:1".into(),
event_id: EventId::new("evt:sha256:x"),
};
assert_eq!(cursor.encode(), cursor.encode());
}
#[test]
fn cursor_decode_rejects_malformed() {
assert!(HistoryCursor::decode("not-base64!!").is_err());
assert!(HistoryCursor::decode("").is_err());
}
}