// file: ks-store/src/contracts/pagination.rs // version: 5 //! Backend-neutral pagination contracts for repository operations. /// Default page size for repository list operations. pub const DEFAULT_PAGE_SIZE: u16 = 100; /// Maximum page size for repository list operations. pub const MAX_PAGE_SIZE: u16 = 500; /// Maximum serialized opaque cursor length accepted by repository list operations. pub const MAX_PAGE_CURSOR_LENGTH: usize = 512; /// Sort direction for repository list operations. #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub enum SortDirection { /// Sort values in ascending order. Asc, /// Sort values in descending order. Desc, } /// Stable cursor request contract for repository list operations. #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct PageRequest { /// Maximum number of rows to return. pub limit: u16, /// Optional opaque cursor returned by the previous page. pub cursor: std::option::Option, } impl crate::PageRequest { /// Builds a page request after validating the limit and opaque cursor bounds. pub fn new( limit: u16, cursor: std::option::Option, ) -> ks_core::Result { if limit == 0 { return std::result::Result::Err(ks_core::Error::db( "page limit must be greater than zero", )); } if limit > crate::MAX_PAGE_SIZE { return std::result::Result::Err(ks_core::Error::db( "page limit exceeds maximum page size", )); } let cursor_value = cursor.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { return std::option::Option::None; } return std::option::Option::Some(trimmed.to_string()); }); if cursor_value .as_deref() .is_some_and(|value| return value.len() > crate::MAX_PAGE_CURSOR_LENGTH) { return std::result::Result::Err(ks_core::Error::db( "page cursor exceeds maximum encoded length", )); } return std::result::Result::Ok(Self { limit, cursor: cursor_value }); } /// Builds the default first page request. pub fn first_page() -> Self { return Self { limit: crate::DEFAULT_PAGE_SIZE, cursor: std::option::Option::None, }; } /// Builds a request continuing after an opaque cursor returned by a previous page. pub fn after( limit: u16, cursor: impl std::convert::Into, ) -> ks_core::Result { return Self::new(limit, std::option::Option::Some(cursor.into())); } } /// One bounded page of rows and its continuation cursor. #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct PageSlice { /// Rows returned for this page. pub rows: std::vec::Vec, /// Opaque cursor for the next page when additional rows exist. pub next_cursor: std::option::Option, } impl crate::PageSlice { /// Builds one page from already bounded rows and an optional continuation cursor. pub fn new( rows: std::vec::Vec, next_cursor: std::option::Option, ) -> Self { return Self { rows, next_cursor }; } /// Returns true when this page has no continuation cursor. pub fn is_last_page(&self) -> bool { return self.next_cursor.is_none(); } } /// One bounded page with a refreshed total row count for the same filter. #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct CountedPageSlice { /// Rows returned for this page. pub rows: std::vec::Vec, /// Opaque cursor for the next page when additional rows exist. pub next_cursor: std::option::Option, /// Total number of rows matching the filter at query time. pub total_rows: u64, } impl crate::CountedPageSlice { /// Builds one counted page. pub fn new( rows: std::vec::Vec, next_cursor: std::option::Option, total_rows: u64, ) -> Self { return Self { rows, next_cursor, total_rows }; } /// Returns true when this page has no continuation cursor. pub fn is_last_page(&self) -> bool { return self.next_cursor.is_none(); } } #[cfg(test)] mod tests { #[test] fn page_request_rejects_zero_limit() { let result = crate::PageRequest::new(0, std::option::Option::None); assert!(result.is_err()); } #[test] fn page_request_rejects_limit_above_maximum() { let result = crate::PageRequest::new(crate::MAX_PAGE_SIZE + 1, std::option::Option::None); assert!(result.is_err()); } #[test] fn page_request_rejects_oversized_cursor() { let result = crate::PageRequest::after(10, "x".repeat(crate::MAX_PAGE_CURSOR_LENGTH + 1)); assert!(result.is_err()); } #[test] fn first_page_uses_default_limit_without_cursor() { let request = crate::PageRequest::first_page(); assert_eq!(request.limit, crate::DEFAULT_PAGE_SIZE); assert!(request.cursor.is_none()); } #[test] fn page_slice_reports_last_page_from_cursor_presence() { let page = crate::PageSlice::new(vec![1_u32], std::option::Option::None); assert!(page.is_last_page()); } #[test] fn counted_page_preserves_total_rows_and_cursor_state() { let page = crate::CountedPageSlice::new( vec![1_u32, 2_u32], std::option::Option::Some("cursor".to_string()), 501, ); assert_eq!(page.total_rows, 501); assert!(!page.is_last_page()); } }