Package

pagination

Offset, page-number and keyset pagination: clamped requests, signed cursors, page ranges for UIs, Link headers and JSON envelopes

0.2.0 MIT

Install

sspur add pagination@^0.2.0

or in sspur.toml:

pagination = { version = "^0.2.0" }

README

pagination

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.

Example

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.

Clamping rules

InputRule
limit / page sizedefault 20 (or Limits.default_limit), kept within 1 to 100 (or Limits.max_limit)
offsetdefault 0, never negative, no upper bound (a page past the end is empty)
page numberdefault 1, never below 1
query texttrimmed, read as an integer; anything else counts as missing (request_str), or is refused (strict_request, strict_numbered)
a Page built by handa limit below 1 counts as 1, a negative offset or total as 0
arithmeticoffsets 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.

API

Offset requests

  • PageReq a checked request: limit 1 to 100, offset at least 0
  • Limits your own default and maximum page size (within 1 to 100)
  • request(limit, offset) clamped request from optional integers
  • request_in(lim, limit, offset) the same with your limits
  • request_str(limit, offset) the same from raw query strings
  • strict_request(lim, limit, offset) refuses bad values with a ParamErr instead of clamping
  • ParamErr NotANumber{param, text} or OutOfRange{param, value, low, high}

Pages

  • Page[T] items, total, limit, offset and the offset of the next page
  • page(items, total, r) the page for rows your query returned
  • paginate(xs, r) the page of an in-memory list
  • map_items(p, f) transform the items, keep the rest
  • bounds(r, total) the [start, end) rows a request covers, clamped
  • clamp_req(r, total) move a request past the end back to the last page
  • last_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 page
  • next_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 95

Page numbers

  • NumReq a checked page number and size
  • numbered(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 offsets
  • PageItem PageNo{number, current} or Ellipsis
  • page_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 ellipsis
  • page_numbers_for(p, window, edges) the same for a page, page_numbers_text(items) as text

Cursors (keyset pagination)

  • Cursor the sort keys of a row (as text), a Dir (Forward or Backward) and an optional expiry
  • after(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 scope
  • decode_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 limit
  • seek(sorted, req, limit, cmp) the rows a database would return, for in-memory lists
  • keyset(rows, limit, req, key_of) the page and its neighbours' cursors (KeysetPage[T]), from rows fetched ascending after the cursor or descending before it
  • sign_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 fragment
  • offset_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 last
  • parse_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 header

JSON

  • 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 pages
  • Page[T] itself encodes as {"items": [...], "total": 95, "limit": 20, "offset": 40, "next": 60}

Changes in 0.2.0

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.

Out of scope

  • Running queries: seek shows what to fetch, your SQL does the fetching (db.query with WHERE (a, b) > (?, ?) ORDER BY a, b LIMIT ?).
  • Encrypted cursors: tokens are signed, not sealed. Seal them with crypto.seal yourself if the keys must stay private.
  • Page sizes above 100: PageReq's range is part of its type since 0.1; Limits can lower it.
  • Total counts for keyset pages, and GraphQL Relay connection types.

Install

sspur add pagination

MIT licensed.

API

Page type

pub type Page[T] = {items: List[T], total: Int, limit: Int, offset: Int, next: Opt[Int]} derive Eq, Show

A page of items out of total, starting at offset; next is the offset of the next page.

PageReq type

pub type PageReq = {limit: Int where _ >= 1 and _ <= 100, offset: Int where _ >= 0} derive Eq, Show

A checked page request: a page size of 1 to 100 and an offset of at least 0.

request fn

fn request(limit: Opt[Int], offset: Opt[Int]) -> PageReq

The 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.

page fn

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.

prev fn

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).

Limits type

pub type Limits = {default_limit: Int where _ >= 1 and _ <= 100, max_limit: Int where _ >= 1 and _ <= 100} derive Eq, Show

Size limits for requests: the default page size and the largest one allowed, both within 1 to 100 (the range of PageReq).

request_in fn

fn request_in(lim: Limits, limit: Opt[Int], offset: Opt[Int]) -> PageReq

request 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.

request_str fn

fn request_str(limit: Opt[Str], offset: Opt[Str]) -> PageReq

request 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.

ParamErr type

pub type ParamErr = NotANumber{param: Str, text: Str} | OutOfRange{param: Str, value: Int, low: Int, high: Int} derive Eq, Show

Why a strict request was refused: a parameter that isn't an integer, or one outside its range (low to high, inclusive).

strict_request fn

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.

paginate fn

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.

bounds fn

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.

clamp_req fn

fn clamp_req(r: PageReq, total: Int where _ >= 0) -> PageReq

The 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.

last_offset fn

fn last_offset(total: Int where _ >= 0, limit: Int where _ >= 1) -> Int

The offset of the last page of total rows in pages of limit: 0 for an empty list.

count_pages fn

fn count_pages(total: Int where _ >= 0, limit: Int where _ >= 1) -> Int

The number of pages total rows fill in pages of limit: 0 for an empty list.

page_count fn

fn page_count[T](p: Page[T]) -> Int

The number of pages in p's list: 0 when it is empty.

page_number fn

fn page_number[T](p: Page[T]) -> Int

The 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.

has_next fn

fn has_next[T](p: Page[T]) -> Bool

Whether a page follows p (its next is set).

has_prev fn

fn has_prev[T](p: Page[T]) -> Bool

Whether a page comes before p (its offset is above 0).

is_first fn

fn is_first[T](p: Page[T]) -> Bool

Whether p starts at offset 0.

is_last fn

fn is_last[T](p: Page[T]) -> Bool

Whether no page follows p.

is_empty_page fn

fn is_empty_page[T](p: Page[T]) -> Bool

Whether p has no items.

out_of_range fn

fn out_of_range[T](p: Page[T]) -> Bool

Whether p lies past the end of its list: an offset above 0 that is at least the total.

next_req fn

fn next_req[T](p: Page[T]) -> Opt[PageReq]

The request for the page after p, if there is one.

prev_req fn

fn prev_req[T](p: Page[T]) -> Opt[PageReq]

The request for the page before p, if there is one.

first_req fn

fn first_req[T](p: Page[T]) -> PageReq

The request for the first page of p's list.

last_req fn

fn last_req[T](p: Page[T]) -> PageReq

The request for the last page of p's list (offset 0 when it is empty).

shown_range fn

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)).

range_label fn

fn range_label[T](p: Page[T]) -> Str

A 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.

map_items fn

fn map_items[A, B, e](p: Page[A], f: A -> B ! e) -> Page[B] ! e

p with f applied to each item; everything else is kept.

NumReq type

pub type NumReq = {number: Int where _ >= 1, size: Int where _ >= 1 and _ <= 100} derive Eq, Show

A checked page-number request: a 1-based page number and a page size of 1 to 100.

numbered fn

fn numbered(number: Opt[Int], size: Opt[Int]) -> NumReq

The 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.

numbered_in fn

fn numbered_in(lim: Limits, number: Opt[Int], size: Opt[Int]) -> NumReq

numbered with your own limits for the page size.

numbered_str fn

fn numbered_str(number: Opt[Str], size: Opt[Str]) -> NumReq

numbered for raw query strings; text that isn't an integer counts as missing.

strict_numbered fn

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.

offset_req fn

fn offset_req(n: NumReq) -> PageReq

The offset request for page-number request n: offset (number - 1) * size, saturating at the largest Int.

num_req fn

fn num_req(r: PageReq) -> NumReq

The page-number request for offset request r: the page its offset starts in.

PageItem type

pub type PageItem = PageNo{number: Int, current: Bool} | Ellipsis derive Eq, Show

One 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.

page_numbers fn

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.

page_numbers_for fn

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.

page_numbers_text fn

fn page_numbers_text(items: List[PageItem]) -> Str

A 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.

Dir type

pub type Dir = Forward | Backward derive Eq, Show

The direction a cursor reads in: Forward gives the rows after its keys, Backward the rows before them.

Cursor type

pub type Cursor = {keys: List[Str], dir: Dir, expires_ms: Opt[Int]} derive Eq, Show

A 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.

CursorErr type

pub type CursorErr = Malformed{reason: Str} | BadSignature | Expired{at_ms: Int} | Unsupported{version: Int} derive Eq, Show

Why 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.

after fn

fn after(keys: List[Str]) -> Cursor

The cursor for the rows after keys.

before fn

fn before(keys: List[Str]) -> Cursor

The cursor for the rows before keys.

is_expired fn

fn is_expired(c: Cursor, now_ms: Int) -> Bool

Whether c has expired at now_ms (Unix milliseconds): its expiry is at or before now_ms.

encode_cursor fn

fn encode_cursor(key: Secret[Bytes], scope: Str, c: Cursor) -> Str

The 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.

decode_cursor fn

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.

decode_cursor_at fn

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).

decode_cursor_any fn

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.

fetch_limit fn

fn fetch_limit(limit: Int where _ >= 1 and _ <= 1000000) -> Int

The 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.

KeysetPage type

pub type KeysetPage[T] = {items: List[T], next_page: Opt[Cursor], prev_page: Opt[Cursor]} derive Eq, Show

A keyset page before signing: its items in list order and the cursors of the next and previous pages.

keyset fn

fn keyset[T, e](rows: List[T], limit: Int where _ >= 1 and _ <= 1000000, req: Opt[Cursor], key_of: T -> List[Str] ! e) -> KeysetPage[T] ! e

The 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.

seek fn

fn seek[T, e](sorted: List[T], req: Opt[Cursor], limit: Int where _ >= 1 and _ <= 1000000, cmp: (T, List[Str]) -> Int ! e) -> List[T] ! e

The 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.

CursorPage type

pub type CursorPage[T] = {items: List[T], next_cursor: Opt[Str], prev_cursor: Opt[Str]} derive Eq, Show

A keyset page with signed cursor tokens, ready to send.

sign_page fn

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.

map_cursor_items fn

fn map_cursor_items[A, B, e](cp: CursorPage[A], f: A -> B ! e) -> CursorPage[B] ! e

cp with f applied to each item; the cursors are kept.

set_query fn

fn set_query(url: Str, params: List[(Str, Str)]) -> Str

url 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).

offset_url fn

fn offset_url(url: Str, r: PageReq) -> Str

The URL of request r on url, with limit and offset query parameters.

page_url fn

fn page_url(url: Str, number: Int, size: Int, page_param: Str, size_param: Str) -> Str

The URL of page number of size on url, with the query parameters named page_param and size_param.

rel_uri fn

fn rel_uri(links: List[WebLink], rel: Str) -> Opt[Str]

The target of the first link with relation rel (compared case-insensitively).

total_count_header fn

fn total_count_header[T](p: Page[T]) -> (Str, Str)

The X-Total-Count header for p.

Meta type

pub type Meta = {total: Int, limit: Int, offset: Int, number: Int, pages: Int, next_offset: Opt[Int], prev_offset: Opt[Int]} derive Eq, Show

The 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.

Envelope type

pub type Envelope[T] = {data: List[T], meta: Meta, links: Links} derive Eq, Show

A JSON body for an offset page: {"data": [...], "meta": {...}, "links": {...}}.

page_meta fn

fn page_meta[T](p: Page[T]) -> Meta

The Meta of p.

envelope fn

fn envelope[T](url: Str, p: Page[T]) -> Envelope[T]

The JSON envelope of p on url; json.encode it for the response body.

CursorEnvelope type

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, Show

A JSON body for a cursor page: the items, the cursor tokens, and the URLs that carry them.

cursor_envelope fn

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.

Versions

VersionPublishedCommitSourceEffects
0.2.0 2026-10-163f0a08f18dsnapshot
0.1.1 2026-10-16cfa722a8f4snapshot
0.1.0 2026-10-16027aefbf57snapshot