//// Cursor pagination over an already-sorted, in-memory list. //// //// Cursor convention: an opaque token, base64url(namespace <> ":" <> last //// id) - scoped to a namespace (e.g. a shelf view) so switching filters can //// never resume into the wrong slice, and never surfaced as an error: a //// token that fails to decode, is scoped to another namespace, or names an //// id no longer present in the list, all just restart the page from the //// top. base64url (not standard base64) so the token is safe to drop //// straight into a query string with no percent-encoding. import gleam/bit_array import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string /// The page size used when a cursor is given without an explicit `limit`, /// and the ceiling any supplied `limit` is clamped to. pub const default_limit = 100 /// Encode the opaque "resume after `last_id`" cursor for `namespace`. pub fn encode_cursor(namespace: String, last_id: String) -> String { bit_array.base64_url_encode( bit_array.from_string(namespace <> ":" <> last_id), False, ) } /// Decode a cursor scoped to `namespace`. Anything unparseable, malformed, /// or scoped to a different namespace decodes to `None`. pub fn decode_cursor(namespace: String, cursor: String) -> Option(String) { case bit_array.base64_url_decode(cursor) { Error(_) -> None Ok(bytes) -> case bit_array.to_string(bytes) { Error(_) -> None Ok(decoded) -> case string.split_once(decoded, ":") { Ok(#(ns, last_id)) if ns == namespace -> Some(last_id) _ -> None } } } } /// Slice a page out of `items` (already sorted by the caller in the order /// pagination should walk). /// /// No cursor and no limit means "return everything": the pre-pagination /// behavior, so a caller that never adopts paging sees no change. Otherwise /// the cursor resumes just after the matching id (an unrecognized or stale /// cursor restarts from the top), and the page is capped at `limit` /// (defaulting to, and ceilinged at, `default_limit`). Returns the page plus /// the next cursor, `None` once there's nothing left to fetch. pub fn page( items: List(a), id_of: fn(a) -> String, namespace: String, cursor: Option(String), limit: Option(Int), ) -> #(List(a), Option(String)) { case cursor, limit { None, None -> #(items, None) _, _ -> { let remaining = case cursor { None -> items Some(token) -> resume_after(items, id_of, namespace, token) } let capped = clamp_limit(limit) let taken = list.take(remaining, capped) let next_cursor = case list.length(remaining) > capped { False -> None True -> taken |> list.last |> result.map(fn(last) { encode_cursor(namespace, id_of(last)) }) |> option.from_result } #(taken, next_cursor) } } } /// Everything in `items` after the element whose id matches the decoded /// cursor; the full list when the cursor doesn't decode or its id isn't /// found (stale/garbage cursors restart rather than error). fn resume_after( items: List(a), id_of: fn(a) -> String, namespace: String, token: String, ) -> List(a) { case decode_cursor(namespace, token) { None -> items Some(last_id) -> case list.split_while(items, fn(i) { id_of(i) != last_id }) { #(_, [_found, ..rest]) -> rest #(_, []) -> items } } } fn clamp_limit(limit: Option(Int)) -> Int { case limit { Some(n) if n >= 1 && n <= default_limit -> n _ -> default_limit } }