Page type
pub type Page[T] = {items: List[T], total: Int, limit: Int, offset: Int, next: Opt[Int]} derive Eq, ShowA page of items out of total, starting at offset; next is the offset of the next page.
Package
Offset, page-number and keyset pagination: clamped requests, signed cursors, page ranges for UIs, Link headers and JSON envelopes
0.2.0 MIT
sspur add pagination@^0.2.0or in sspur.toml:
pagination = { version = "^0.2.0" }Pagination for SSPUR list endpoints: offset and page-number pagination with clamped (or strictly checked) requests, keyset pagination with opaque, tamper-evident cursors signed with HMAC-SHA-256, page facts (total pages, has next/previous, first/last), page-number ranges for UIs with elided middles, slicing of in-memory lists, RFC 8288 Link headers (written and parsed) and JSON envelopes. Everything is pure: no effects, no clock, no randomness.
use pagination.{request_str, paginate, link_header, range_label, page_numbers_for, page_numbers_text, after, keyset, seek, sign_page, decode_cursor}
fn by_id(id: Int, keys: List[Str]) -> Int
= id - keys.first.map(k => k.to_int.or(0)).or(0)
fn main() -> Unit ! log
= do
ids = (1..96).map(i => i)
p = paginate(ids, request_str(some("20"), some("40")))
log(range_label(p))
log(page_numbers_text(page_numbers_for(p, 1, 1)))
log(link_header("/items", p))
key = secret("change me: 32 random bytes".to_bytes)
rows = seek(ids, some(after(["90"])), 3, by_id)
cp = sign_page(key, "items?sort=id", keyset(rows, 3, some(after(["90"])), k => [k.str]), none)
log("{cp.items} next={cp.next_cursor.is_some} prev={cp.prev_cursor.is_some}")
log(cp.prev_cursor.map(t => decode_cursor(key, "items?sort=id", t)).str)
log(cp.prev_cursor.map(t => decode_cursor(key, "items?sort=name", t)).str)
prints (natively and with --interp):
41-60 of 95
1 2 [3] 4 5
</items?limit=20&offset=0>; rel="first", </items?limit=20&offset=20>; rel="prev", </items?limit=20&offset=60>; rel="next", </items?limit=20&offset=80>; rel="last"
[91, 92, 93] next=true prev=true
some(ok(Cursor{keys: ["91"], dir: Backward, expires_ms: none}))
some(err(BadSignature))
In a real service the key comes from configuration (crypto.key() once, stored as a secret), and seek is your SQL: WHERE id > ? ORDER BY id LIMIT ? with fetch_limit(limit) rows.
| Input | Rule |
|---|---|
| limit / page size | default 20 (or Limits.default_limit), kept within 1 to 100 (or Limits.max_limit) |
| offset | default 0, never negative, no upper bound (a page past the end is empty) |
| page number | default 1, never below 1 |
| query text | trimmed, read as an integer; anything else counts as missing (request_str), or is refused (strict_request, strict_numbered) |
a Page built by hand | a limit below 1 counts as 1, a negative offset or total as 0 |
| arithmetic | offsets saturate at the largest Int instead of overflowing |
Refinements guard the rest: count_pages, last_offset and bounds need total >= 0 and limit >= 1; page_numbers needs pages >= 0 and a window and edge count of 0 to 1000; keyset limits are 1 to a million. A violation stops the program with the clause that failed.
Offset requests
PageReq a checked request: limit 1 to 100, offset at least 0Limits your own default and maximum page size (within 1 to 100)request(limit, offset) clamped request from optional integersrequest_in(lim, limit, offset) the same with your limitsrequest_str(limit, offset) the same from raw query stringsstrict_request(lim, limit, offset) refuses bad values with a ParamErr instead of clampingParamErr NotANumber{param, text} or OutOfRange{param, value, low, high}Pages
Page[T] items, total, limit, offset and the offset of the next pagepage(items, total, r) the page for rows your query returnedpaginate(xs, r) the page of an in-memory listmap_items(p, f) transform the items, keep the restbounds(r, total) the [start, end) rows a request covers, clampedclamp_req(r, total) move a request past the end back to the last pagelast_offset(total, limit), count_pages(total, limit)Page facts and neighbours
page_count(p), page_number(p) (1-based)has_next(p), has_prev(p), is_first(p), is_last(p), is_empty_page(p), out_of_range(p)prev(p) the offset of the previous pagenext_req(p), prev_req(p), first_req(p), last_req(p)shown_range(p) 1-based first and last item, range_label(p) such as 21-40 of 95Page numbers
NumReq a checked page number and sizenumbered(number, size), numbered_in(lim, number, size), numbered_str(number, size), strict_numbered(lim, number, size)offset_req(n) and num_req(r) convert between page numbers and offsetsPageItem PageNo{number, current} or Ellipsispage_numbers(current, pages, window, edges) the elided range for a UI: page 8 of 20 with window 1 and edges 1 is 1 … 7 [8] 9 … 20; a gap of one page shows that page instead of an ellipsispage_numbers_for(p, window, edges) the same for a page, page_numbers_text(items) as textCursors (keyset pagination)
Cursor the sort keys of a row (as text), a Dir (Forward or Backward) and an optional expiryafter(keys), before(keys), is_expired(c, now_ms)encode_cursor(key, scope, c) an opaque token: base64url JSON, ., and a 128-bit HMAC-SHA-256 tag over the payload and the scopedecode_cursor(key, scope, token), decode_cursor_at(key, scope, token, now_ms) (also checks expiry), decode_cursor_any(keys, scope, token, now_ms) (key rotation)CursorErr Malformed{reason}, BadSignature, Expired{at_ms}, Unsupported{version}fetch_limit(limit) rows to fetch: one more than the limitseek(sorted, req, limit, cmp) the rows a database would return, for in-memory listskeyset(rows, limit, req, key_of) the page and its neighbours' cursors (KeysetPage[T]), from rows fetched ascending after the cursor or descending before itsign_page(key, scope, kp, expires_ms) sign the cursors (CursorPage[T]), map_cursor_items(cp, f)The scope binds a token to one query: sign with the endpoint, filters and sort order (say "orders?status=open&sort=date") and a cursor from another query is refused as BadSignature. Tokens are tamper-evident, not encrypted: anyone holding one can read its keys.
HTTP
set_query(url, params) set query parameters, percent-encoded, keeping the others and the fragmentoffset_url(url, r), page_url(url, number, size, page_param, size_param)link_header(url, p), link_header_numbered(url, p, page_param, size_param), cursor_link_header(url, param, cp) RFC 8288 Link values with first, prev, next and lastparse_link_header(s) the links of a Link value (WebLink{uri, rel, params}), rel_uri(links, rel)total_count_header(p) the X-Total-Count headerJSON
Envelope[T] {"data": [...], "meta": {...}, "links": {...}} with Meta (total, limit, offset, number, pages, next and previous offsets) and Links (self, first, prev, next, last URLs)envelope(url, p), page_meta(p), page_link_set(url, p)CursorEnvelope[T] and cursor_envelope(url, param, cp) for cursor pagesPage[T] itself encodes as {"items": [...], "total": 95, "limit": 20, "offset": 40, "next": 60}Everything in 0.1 keeps its signature. Two fixes in behaviour: page no longer sets next on an empty page (a client reading past the end would otherwise ask for the same offset forever), and prev of a page past the end points at the last page instead of limit rows back.
seek shows what to fetch, your SQL does the fetching (db.query with WHERE (a, b) > (?, ?) ORDER BY a, b LIMIT ?).crypto.seal yourself if the keys must stay private.PageReq's range is part of its type since 0.1; Limits can lower it.sspur add pagination
MIT licensed.
pub type Page[T] = {items: List[T], total: Int, limit: Int, offset: Int, next: Opt[Int]} derive Eq, ShowA page of items out of total, starting at offset; next is the offset of the next page.
pub type PageReq = {limit: Int where _ >= 1 and _ <= 100, offset: Int where _ >= 0} derive Eq, ShowA checked page request: a page size of 1 to 100 and an offset of at least 0.
fn request(limit: Opt[Int], offset: Opt[Int]) -> PageReqThe page request for query parameters that may be missing or out of range: limit defaults to 20 and is kept within 1 to 100, offset defaults to 0 and is never negative.
fn page[T](items: List[T], total: Int, r: PageReq) -> Page[T]The page of items (already limited by the query) for request r out of total rows. next is the offset after the last item while rows remain; an empty page has no next, so a client that reads past the end (rows deleted meanwhile) stops instead of asking for the same offset.
fn prev[T](p: Page[T]) -> Opt[Int]The offset of the page before p, if there is one: none on the first page, the start of the last page when p lies past the end, else limit rows back (never below 0).
pub type Limits = {default_limit: Int where _ >= 1 and _ <= 100, max_limit: Int where _ >= 1 and _ <= 100} derive Eq, ShowSize limits for requests: the default page size and the largest one allowed, both within 1 to 100 (the range of PageReq).
fn request_in(lim: Limits, limit: Opt[Int], offset: Opt[Int]) -> PageReqrequest with your own limits: limit defaults to lim.default_limit and is kept within 1 to lim.max_limit; offset defaults to 0 and is never negative.
fn request_str(limit: Opt[Str], offset: Opt[Str]) -> PageReqrequest for raw query strings: each value is trimmed and read as an integer, and text that isn't one ("abc", "", "1.5") counts as missing, so it gets the default.
pub type ParamErr = NotANumber{param: Str, text: Str} | OutOfRange{param: Str, value: Int, low: Int, high: Int} derive Eq, ShowWhy a strict request was refused: a parameter that isn't an integer, or one outside its range (low to high, inclusive).
fn strict_request(lim: Limits, limit: Opt[Str], offset: Opt[Str]) -> Res[PageReq, ParamErr]The page request for raw query strings, refusing bad values instead of clamping them: limit must be within 1 to lim.max_limit and offset at least 0; a missing value gets its default. The limit is checked first.
fn paginate[T](xs: List[T], r: PageReq) -> Page[T]The page of the in-memory list xs for request r: its slice, with total the length of xs.
fn bounds(r: PageReq, total: Int where _ >= 0) -> (Int, Int)The rows [start, end) that request r covers in a list of total rows, clamped to the list: both are total when the offset lies past the end.
fn clamp_req(r: PageReq, total: Int where _ >= 0) -> PageReqThe request moved back onto the list when its offset lies past the end of total rows: the start of the last page (0 for an empty list). Other requests are returned unchanged.
fn last_offset(total: Int where _ >= 0, limit: Int where _ >= 1) -> IntThe offset of the last page of total rows in pages of limit: 0 for an empty list.
fn count_pages(total: Int where _ >= 0, limit: Int where _ >= 1) -> IntThe number of pages total rows fill in pages of limit: 0 for an empty list.
fn page_count[T](p: Page[T]) -> IntThe number of pages in p's list: 0 when it is empty.
fn page_number[T](p: Page[T]) -> IntThe 1-based number of p: offset / limit + 1, so an offset that isn't a multiple of the limit counts as the page it starts in. It can exceed page_count for a page past the end.
fn has_next[T](p: Page[T]) -> BoolWhether a page follows p (its next is set).
fn has_prev[T](p: Page[T]) -> BoolWhether a page comes before p (its offset is above 0).
fn is_first[T](p: Page[T]) -> BoolWhether p starts at offset 0.
fn is_last[T](p: Page[T]) -> BoolWhether no page follows p.
fn is_empty_page[T](p: Page[T]) -> BoolWhether p has no items.
fn out_of_range[T](p: Page[T]) -> BoolWhether p lies past the end of its list: an offset above 0 that is at least the total.
fn next_req[T](p: Page[T]) -> Opt[PageReq]The request for the page after p, if there is one.
fn prev_req[T](p: Page[T]) -> Opt[PageReq]The request for the page before p, if there is one.
fn first_req[T](p: Page[T]) -> PageReqThe request for the first page of p's list.
fn last_req[T](p: Page[T]) -> PageReqThe request for the last page of p's list (offset 0 when it is empty).
fn shown_range[T](p: Page[T]) -> Opt[(Int, Int)]The 1-based positions of the first and last item of p in its list, none when p is empty: rows 21 to 40 of a list are some((21, 40)).
fn range_label[T](p: Page[T]) -> StrA label for p such as 21-40 of 95, 0 of 95 for an empty or out-of-range page, 0 of 0 for an empty list.
fn map_items[A, B, e](p: Page[A], f: A -> B ! e) -> Page[B] ! ep with f applied to each item; everything else is kept.
pub type NumReq = {number: Int where _ >= 1, size: Int where _ >= 1 and _ <= 100} derive Eq, ShowA checked page-number request: a 1-based page number and a page size of 1 to 100.
fn numbered(number: Opt[Int], size: Opt[Int]) -> NumReqThe page-number request for parameters that may be missing or out of range: number defaults to 1 and is never below 1, size defaults to 20 and is kept within 1 to 100.
fn numbered_in(lim: Limits, number: Opt[Int], size: Opt[Int]) -> NumReqnumbered with your own limits for the page size.
fn numbered_str(number: Opt[Str], size: Opt[Str]) -> NumReqnumbered for raw query strings; text that isn't an integer counts as missing.
fn strict_numbered(lim: Limits, number: Opt[Str], size: Opt[Str]) -> Res[NumReq, ParamErr]The page-number request for raw query strings, refusing bad values: number must be at least 1 and size within 1 to lim.max_limit; a missing value gets its default. The number is checked first.
fn offset_req(n: NumReq) -> PageReqThe offset request for page-number request n: offset (number - 1) * size, saturating at the largest Int.
fn num_req(r: PageReq) -> NumReqThe page-number request for offset request r: the page its offset starts in.
pub type PageItem = PageNo{number: Int, current: Bool} | Ellipsis derive Eq, ShowOne entry of a page-number range for a UI: a page (marked when it is the current one), or an ellipsis standing for two or more hidden pages.
fn page_numbers(current: Int, pages: Int where _ >= 0, window: Int where _ >= 0 and _ <= 1000, edges: Int where _ >= 0 and _ <= 1000) -> List[PageItem]The page numbers to show for page current of pages: the first and last edges pages, and window pages on each side of the current one, with an ellipsis for each run of two or more hidden pages (a gap of one page shows that page instead). current is clamped into 1 to pages; no pages give []. Page 8 of 20 with window 1 and edges 1 is 1 … 7 [8] 9 … 20.
fn page_numbers_for[T](p: Page[T], window: Int where _ >= 0 and _ <= 1000, edges: Int where _ >= 0 and _ <= 1000) -> List[PageItem]page_numbers for page p.
fn page_numbers_text(items: List[PageItem]) -> StrA page-number range as text: pages by number, the current one in brackets, and … for an ellipsis, separated by spaces: 1 … 7 [8] 9 … 20.
pub type Dir = Forward | Backward derive Eq, ShowThe direction a cursor reads in: Forward gives the rows after its keys, Backward the rows before them.
pub type Cursor = {keys: List[Str], dir: Dir, expires_ms: Opt[Int]} derive Eq, ShowA position in a keyset-ordered list: the sort-key values of a row (as text, in the ORDER BY's order), the direction to read in, and an optional expiry in Unix milliseconds.
pub type CursorErr = Malformed{reason: Str} | BadSignature | Expired{at_ms: Int} | Unsupported{version: Int} derive Eq, ShowWhy a cursor token was refused: it isn't a cursor (reason says why), its signature doesn't check (tampered with, another key, or another scope), it expired at at_ms, or it was written by a newer version of the format.
fn after(keys: List[Str]) -> CursorThe cursor for the rows after keys.
fn before(keys: List[Str]) -> CursorThe cursor for the rows before keys.
fn is_expired(c: Cursor, now_ms: Int) -> BoolWhether c has expired at now_ms (Unix milliseconds): its expiry is at or before now_ms.
fn encode_cursor(key: Secret[Bytes], scope: Str, c: Cursor) -> StrThe opaque token for cursor c: base64url JSON, a ., and a 128-bit HMAC-SHA-256 tag over the payload and scope. The scope ties a token to one query (say "orders?status=open&sort=date"), so a cursor from another query or sort order is refused. Tokens are tamper-evident, not secret: anyone can read the keys, so don't put secrets in them.
fn decode_cursor(key: Secret[Bytes], scope: Str, token: Str) -> Res[Cursor, CursorErr]The cursor in token token, checked against key and scope; expiry is not checked (see decode_cursor_at). Tokens over 4096 characters are Malformed.
fn decode_cursor_at(key: Secret[Bytes], scope: Str, token: Str, now_ms: Int) -> Res[Cursor, CursorErr]decode_cursor, also refusing a cursor that has expired at now_ms (Unix milliseconds).
fn decode_cursor_any(keys: List[Secret[Bytes]], scope: Str, token: Str, now_ms: Opt[Int]) -> Res[Cursor, CursorErr]decode_cursor for key rotation: the token is accepted when its tag checks under any of keys (newest first, say), and, when now_ms is given, it must not have expired. An empty keys list refuses every token as BadSignature.
fn fetch_limit(limit: Int where _ >= 1 and _ <= 1000000) -> IntThe rows to fetch for a keyset page of limit (1 to a million): one more than the limit, so the extra row tells whether another page follows.
pub type KeysetPage[T] = {items: List[T], next_page: Opt[Cursor], prev_page: Opt[Cursor]} derive Eq, ShowA keyset page before signing: its items in list order and the cursors of the next and previous pages.
fn keyset[T, e](rows: List[T], limit: Int where _ >= 1 and _ <= 1000000, req: Opt[Cursor], key_of: T -> List[Str] ! e) -> KeysetPage[T] ! eThe keyset page for rows fetched for request req (none for the first page) with fetch_limit(limit) rows: ascending after the cursor's keys for Forward (and the first page), descending before them for Backward, as the database returns them. key_of gives a row's sort keys. The extra row, if fetched, is dropped and only says another page follows; the items come back in ascending order either way. A page reached by a cursor always has a way back, and an empty page reached by a cursor points back at that cursor's keys.
fn seek[T, e](sorted: List[T], req: Opt[Cursor], limit: Int where _ >= 1 and _ <= 1000000, cmp: (T, List[Str]) -> Int ! e) -> List[T] ! eThe rows a database would return for req from the in-memory list sorted (ascending by sort key): up to fetch_limit(limit) rows after the cursor's keys, or before them in descending order for Backward. cmp(row, keys) is negative, 0 or positive as the row sorts before, at or after the keys. Pass the result to keyset.
pub type CursorPage[T] = {items: List[T], next_cursor: Opt[Str], prev_cursor: Opt[Str]} derive Eq, ShowA keyset page with signed cursor tokens, ready to send.
fn sign_page[T](key: Secret[Bytes], scope: Str, kp: KeysetPage[T], expires_ms: Opt[Int]) -> CursorPage[T]kp with its cursors signed by encode_cursor under key and scope, each expiring at expires_ms (Unix milliseconds) when given.
fn map_cursor_items[A, B, e](cp: CursorPage[A], f: A -> B ! e) -> CursorPage[B] ! ecp with f applied to each item; the cursors are kept.
fn set_query(url: Str, params: List[(Str, Str)]) -> Strurl with the query parameters params set: earlier values of those names are removed, the others keep their order, the new ones are appended in order, and a fragment stays at the end. Names and values are percent-encoded (RFC 3986 unreserved characters are kept).
fn offset_url(url: Str, r: PageReq) -> StrThe URL of request r on url, with limit and offset query parameters.
fn page_url(url: Str, number: Int, size: Int, page_param: Str, size_param: Str) -> StrThe URL of page number of size on url, with the query parameters named page_param and size_param.
fn link_header[T](url: Str, p: Page[T]) -> StrThe RFC 8288 Link header for p on url, with limit and offset parameters: first, prev (when there is one), next (when there is one) and last, as <url>; rel="next" joined by , .
fn link_header_numbered[T](url: Str, p: Page[T], page_param: Str, size_param: Str) -> StrThe Link header for p with page-number parameters named page_param and size_param (say page and per_page).
fn cursor_link_header[T](url: Str, param: Str, cp: CursorPage[T]) -> StrThe Link header for a cursor page: prev and next links that carry the cursor tokens in the query parameter param; "" when there is neither.
pub type WebLink = {uri: Str, rel: Str, params: List[(Str, Str)]} derive Eq, ShowOne link of a parsed Link header: its target, one relation type (lowercase), and its other parameters (names lowercase, quotes removed).
fn parse_link_header(s: Str) -> List[WebLink]The links in an RFC 8288 Link header value, in order. A link with several relation types (rel="next last") gives one entry per type; links without rel or without <...> are skipped, and commas and semicolons inside quoted strings are kept.
fn rel_uri(links: List[WebLink], rel: Str) -> Opt[Str]The target of the first link with relation rel (compared case-insensitively).
fn total_count_header[T](p: Page[T]) -> (Str, Str)The X-Total-Count header for p.
pub type Meta = {total: Int, limit: Int, offset: Int, number: Int, pages: Int, next_offset: Opt[Int], prev_offset: Opt[Int]} derive Eq, ShowThe numbers of an offset page for a JSON body: total rows, page size, offset, the 1-based page number, the number of pages, and the offsets of the neighbouring pages.
pub type Links = {self_url: Str, first_url: Str, prev_url: Opt[Str], next_url: Opt[Str], last_url: Str} derive Eq, ShowThe URLs of an offset page and its neighbours for a JSON body.
pub type Envelope[T] = {data: List[T], meta: Meta, links: Links} derive Eq, ShowA JSON body for an offset page: {"data": [...], "meta": {...}, "links": {...}}.
fn page_meta[T](p: Page[T]) -> MetaThe Meta of p.
fn page_link_set[T](url: Str, p: Page[T]) -> LinksThe Links of p on url, with limit and offset parameters.
fn envelope[T](url: Str, p: Page[T]) -> Envelope[T]The JSON envelope of p on url; json.encode it for the response body.
pub type CursorEnvelope[T] = {data: List[T], next_cursor: Opt[Str], prev_cursor: Opt[Str], next_url: Opt[Str], prev_url: Opt[Str]} derive Eq, ShowA JSON body for a cursor page: the items, the cursor tokens, and the URLs that carry them.
fn cursor_envelope[T](url: Str, param: Str, cp: CursorPage[T]) -> CursorEnvelope[T]The JSON envelope of cursor page cp on url, with the tokens in the query parameter param.
| Version | Published | Commit | Source | Effects |
|---|---|---|---|---|
| 0.2.0 | 2026-10-16 | 3f0a08f18d | snapshot | |
| 0.1.1 | 2026-10-16 | cfa722a8f4 | snapshot | |
| 0.1.0 | 2026-10-16 | 027aefbf57 | snapshot |