//! Cursor-based pagination helpers for repository queries. //! //! UUID v7 IDs are time-ordered, so `WHERE id < $cursor ORDER BY id DESC` //! naturally yields reverse-chronological pages without OFFSET. use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Default number of items per page. pub const DEFAULT_PAGE_SIZE: i64 = 50; /// Hard upper bound on page size to prevent abuse. pub const MAX_PAGE_SIZE: i64 = 100; /// Generic cursor-based page response. /// /// Returned by list operations in the repo layer. The `next_cursor` /// is the last item's UUID — pass it as `before` in the next request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CursorPage { /// Items in this page (ordered by `id DESC`). pub items: Vec, /// Opaque cursor for the next page. `None` when no more results exist. pub next_cursor: Option, /// Whether there are more results beyond this page. pub has_more: bool, } impl CursorPage { /// Build a page from a raw result set that may contain one extra row. /// /// If `raw_items.len() > limit`, the extra row is dropped and /// `has_more` is set to `true`. pub fn from_raw(mut raw_items: Vec, limit: i64, get_id: impl Fn(&T) -> Uuid) -> Self { let has_more = raw_items.len() > limit as usize; if has_more { raw_items.truncate(limit as usize); } let next_cursor = if has_more { raw_items.last().map(get_id) } else { None }; Self { items: raw_items, next_cursor, has_more, } } /// Empty page (no results). pub fn empty() -> Self { Self { items: Vec::new(), next_cursor: None, has_more: false, } } } /// Clamp a caller-requested limit to `[1, MAX_PAGE_SIZE]`, defaulting to `DEFAULT_PAGE_SIZE`. pub fn clamp_limit(limit: Option) -> i64 { match limit { Some(n) if n < 1 => DEFAULT_PAGE_SIZE, Some(n) if n > MAX_PAGE_SIZE => MAX_PAGE_SIZE, Some(n) => n, None => DEFAULT_PAGE_SIZE, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_clamp_limit_none() { assert_eq!(clamp_limit(None), DEFAULT_PAGE_SIZE); } #[test] fn test_clamp_limit_zero() { assert_eq!(clamp_limit(Some(0)), DEFAULT_PAGE_SIZE); } #[test] fn test_clamp_limit_negative() { assert_eq!(clamp_limit(Some(-5)), DEFAULT_PAGE_SIZE); } #[test] fn test_clamp_limit_over_max() { assert_eq!(clamp_limit(Some(200)), MAX_PAGE_SIZE); } #[test] fn test_clamp_limit_valid() { assert_eq!(clamp_limit(Some(25)), 25); } #[test] fn test_cursor_page_empty() { let page: CursorPage = CursorPage::empty(); assert!(page.items.is_empty()); assert!(!page.has_more); assert!(page.next_cursor.is_none()); } #[test] fn test_cursor_page_from_raw_no_overflow() { let items = vec!["a".to_string(), "b".to_string()]; let page = CursorPage::from_raw(items, 5, |_| Uuid::nil()); assert_eq!(page.items.len(), 2); assert!(!page.has_more); } }