From a990cc999cbb99ceb9d3ed85e29b9d010e7a2fa8 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 15:50:17 +0200 Subject: [PATCH] docs: trim the pagination cursor comments to the non-obvious parts --- server/src/crate_server/pagination.gleam | 86 ++++++------------------ server/test/pagination_test.gleam | 19 +----- 2 files changed, 22 insertions(+), 83 deletions(-) diff --git a/server/src/crate_server/pagination.gleam b/server/src/crate_server/pagination.gleam index e249ee4..1f28542 100644 --- a/server/src/crate_server/pagination.gleam +++ b/server/src/crate_server/pagination.gleam @@ -1,28 +1,11 @@ //// Cursor pagination over an already-sorted, in-memory list. //// -//// Cursor convention: an opaque token, base64url(namespace <> ":" <> -//// last_id [<> "|" <> tail_length]) - 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, or is scoped -//// to another namespace, just restarts 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. -//// -//// `tail_length` is how many items followed the anchor when the cursor was -//// minted. It's what makes resuming safe on a *live* list, where a row can -//// vanish between two requests (an adoption deleted or superseded): if the -//// anchor id is gone, dropping the caller's current items down to its last -//// `tail_length` elements resumes right where the client left off instead -//// of replaying the page it already has. Older cursors minted before this -//// field existed carry no `tail_length` (no "|" segment); those still -//// decode fine, they just fall back to the pre-existing behavior of -//// restarting from the top when the anchor id can't be found - degrade -//// gracefully rather than break cursors already held by real clients. -//// -//// Insertions ahead of the anchor's original position (not just at the -//// head of the list) can still confuse the tail-length fallback; this is a -//// best-effort resume, not a linearizable one, matching the rest of this -//// module's philosophy of never erroring on a cursor. +//// A cursor is base64url(namespace <> ":" <> last_id [<> "|" <> tail_length]). +//// The namespace stops a filter switch resuming into the wrong slice, and no +//// cursor is ever an error: unparseable, foreign or stale ones restart from +//// the top. `tail_length` (items following the anchor when it was minted) +//// resumes by position once the anchor row itself is gone, which holds only +//// while nothing is inserted after the anchor. import gleam/bit_array import gleam/int @@ -31,30 +14,19 @@ 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. +/// Default page size, and the ceiling on any supplied `limit`. pub const default_limit = 100 -/// A decoded cursor: the id to resume after, plus (when the cursor was -/// minted by this module rather than hand-built) how many items followed it -/// at mint time, used to resume by position if the id itself is gone. +/// The id to resume after, plus the tail length recorded at mint time. pub type ResumePoint { ResumePoint(last_id: String, tail_length: Option(Int)) } -/// Encode the opaque "resume after `last_id`" cursor for `namespace`, with -/// no recorded tail length. This is the pre-existing, position-less cursor -/// format: still produced on purpose (e.g. to build a cursor by hand in -/// tests) and still decodes and resumes correctly, just without the -/// stale-anchor fallback `page` gets from cursors it mints itself. +/// Encode a cursor with no tail length: resumes by id, restarts once it is gone. pub fn encode_cursor(namespace: String, last_id: String) -> String { encode_bytes(namespace <> ":" <> last_id) } -/// Encode the opaque "resume after `last_id`" cursor for `namespace`, -/// recording that `tail_length` items followed it in the list it was cut -/// from. Used internally by `page` to mint cursors that can resume by -/// position when the anchor id disappears. fn encode_cursor_at( namespace: String, last_id: String, @@ -67,8 +39,8 @@ fn encode_bytes(payload: String) -> String { bit_array.base64_url_encode(bit_array.from_string(payload), False) } -/// Decode a cursor scoped to `namespace`. Anything unparseable, malformed, -/// or scoped to a different namespace decodes to `None`. +/// Decode a cursor scoped to `namespace`. Unparseable, malformed or foreign +/// tokens decode to `None`. pub fn decode_cursor(namespace: String, cursor: String) -> Option(ResumePoint) { case bit_array.base64_url_decode(cursor) { Error(_) -> None @@ -84,10 +56,8 @@ pub fn decode_cursor(namespace: String, cursor: String) -> Option(ResumePoint) { } } -/// `rest` is `last_id` alone (older cursors) or `last_id <> "|" <> -/// tail_length` (cursors minted by `page`). An unparseable tail length is -/// treated the same as a missing one, degrading rather than rejecting the -/// whole cursor - the id half is still trustworthy. +// An unparseable tail length degrades rather than rejecting the cursor: the +// id half is still trustworthy. fn parse_resume_point(rest: String) -> ResumePoint { case string.split_once(rest, "|") { Error(_) -> ResumePoint(rest, None) @@ -99,18 +69,9 @@ fn parse_resume_point(rest: String) -> ResumePoint { } } -/// 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 when it's still present; -/// when it's gone (row deleted between requests on a live list) but the -/// cursor carries a recorded tail length, it resumes by position instead of -/// replaying the page the client already has. An unrecognized, foreign, or -/// position-less cursor still restarts from the top. 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. +/// Slice a page out of `items`, already sorted by the caller, capped at +/// `limit`. No cursor and no limit returns everything. Returns the page plus +/// the cursor for the next one, `None` once nothing is left. pub fn page( items: List(a), id_of: fn(a) -> String, @@ -143,11 +104,6 @@ pub fn page( } } -/// Everything in `items` after the element whose id matches the decoded -/// cursor. When that id isn't found, falls back to the cursor's recorded -/// tail length if it has one (see the module doc); otherwise, and when the -/// cursor doesn't decode at all, the full list (stale/garbage cursors -/// restart rather than error). fn resume_after( items: List(a), id_of: fn(a) -> String, @@ -164,13 +120,9 @@ fn resume_after( } } -/// Resume by "the last `tail_length` items of the current list" when the -/// anchor id itself has vanished. This stays correct across deletions -/// anywhere in the list (the recorded count only ever over-covers what's -/// left, never under-covers it) as long as nothing gets inserted ahead of -/// where the anchor used to sit; new items appearing only at the head of a -/// live feed is the case this is built for. A cursor with no recorded -/// length (pre-existing cursors, see module doc) restarts from the top. +// Keeping the last `tail_length` items over-covers once rows are deleted (a +// row is reseen) and under-covers once rows are inserted after the anchor (a +// row is lost). fn resume_by_tail_length(items: List(a), tail_length: Option(Int)) -> List(a) { case tail_length { None -> items diff --git a/server/test/pagination_test.gleam b/server/test/pagination_test.gleam index 44e2f83..c17684a 100644 --- a/server/test/pagination_test.gleam +++ b/server/test/pagination_test.gleam @@ -12,8 +12,7 @@ fn ids(count: Int) -> List(String) { |> list.index_map(fn(_, i) { "item-" <> zero_padded(i + 1) }) } -// Zero-padded so string.compare sorts the same as numeric order, matching -// how entry ids (TIDs) sort lexically. +// Zero-padded so string.compare sorts numerically, as TID entry ids do. fn zero_padded(n: Int) -> String { string.pad_start(int.to_string(n), 3, "0") } @@ -75,9 +74,6 @@ pub fn page_walks_across_pages_by_cursor_test() { let #(page, next) = pagination.page(items, identity, "owned", cursor, Some(2)) assert page == expected_page - // The minted cursor carries a resume position (how many items followed - // the anchor at mint time) alongside the id now, so compare on the - // decoded anchor rather than the raw token. assert option.map(next, pagination.decode_cursor("owned", _)) == option.map(expected_next, fn(anchor) { let #(id, tail_length) = anchor @@ -114,9 +110,6 @@ pub fn a_cursor_minted_by_page_resumes_by_position_when_its_anchor_vanishes_test pagination.page(items, identity, "owned", None, Some(2)) as "cursor minted for a live list should carry a resume position" - // The anchor (item-002) is gone by the next request, same as a deleted - // or superseded row on a live feed. Resuming must neither replay - // item-001/item-002 (already seen) nor skip item-003 (never seen). let items_without_anchor = list.filter(items, fn(id) { id != "item-002" }) let #(page, next) = pagination.page( @@ -136,9 +129,7 @@ pub fn resuming_by_position_never_truncates_the_tail_even_if_more_than_the_ancho pagination.page(items, identity, "owned", None, Some(2)) as "cursor minted for a live list should carry a resume position" - // Everything up to and including the recorded tail length disappeared - // too. There's nothing safe left to skip, so the fallback must hand back - // what remains rather than guess past it. + // Fewer rows survive than the recorded tail length: nothing safe to skip. let heavily_pruned = ["item-004", "item-005", "item-006"] let #(page, _next) = pagination.page(heavily_pruned, identity, "owned", Some(cursor), Some(2)) @@ -146,11 +137,7 @@ pub fn resuming_by_position_never_truncates_the_tail_even_if_more_than_the_ancho } pub fn an_old_format_cursor_with_a_missing_anchor_still_restarts_from_the_top_test() { - // Cursors minted before this module recorded a resume position carry no - // "|"-separated tail length; `encode_cursor` (with no position argument) - // still produces that format on purpose, so real cursors already held by - // clients keep decoding and keep degrading to a restart rather than - // erroring or crashing. + // Cursors already held by clients carry no tail length. let items = ids(5) let old_style = pagination.encode_cursor("owned", "item-002") let items_without_anchor = list.filter(items, fn(id) { id != "item-002" }) -- 2.51.2