Pagination semantics for the read pipeline: the Range/Range-Unit request
headers, the Prefer: count= modes, and the resulting Content-Range
response header and HTTP status (200/206/416).
PostgREST resolves an effective {offset, limit} window by INTERSECTING the
window the limit/offset query parameters describe with the one the Range
header describes (headerAndLimitRange = rangeIntersection headerRange limitRange, ApiRequest.hs#L185), requests a row count according to
Prefer: count=, then renders a Content-Range of <first>-<last>/<total>
(or */<total> for an empty window) and picks the status from whether the
window covers the whole set.
The Range REQUEST header is read for GET only — headerRange = if method == "GET" then rangeRequested hdrs else allRange (ApiRequest.hs#L183, under a
comment citing RFC 9110's "the Range header must be ignored for all methods
other than GET"). method there is the raw request method, so HEAD is not
folded into GET and a POST /rpc/<fn> ignores the header too.
Summary
Types
A row window as PostgREST's NonnegRange: the inclusive lower and upper row
indexes, with nil for an unbounded upper end.
A NonnegRange: nil is allRange (unconstrained), :empty is
emptyRange, anything else a concrete bounds/0 pair. A pair whose upper
bound falls below its lower bound is empty too — ranged-sets defines
rangeIsEmpty (Range lower upper) = upper <= lower and makes every empty
range Eq-equal, which is why emptiness is a semantic test here rather than a
structural one.
Functions
Apply the Range header and the db-max-rows cap to a parsed plan.
The count mode a function call (/rpc/<fn>) actually honors.
Render the Content-Range header value.
Resolve the count mode from a Prefer: count=<mode> header. Defaults to
:none (PostgREST's default; total is rendered as *).
Whether a requested window is out of bounds: a non-zero offset that lands at
or past the last row, with a known total and no rows returned. PostgREST
renders this as 416 PGRST103 (OutOfBounds) — but only when a count is known
(i.e. Prefer: count= was honored).
HTTP status for a successful read given the window and (optional) total.
Types
@type bounds() :: {non_neg_integer(), non_neg_integer() | nil}
A row window as PostgREST's NonnegRange: the inclusive lower and upper row
indexes, with nil for an unbounded upper end.
@type count_mode() :: Bier.Preferences.count_mode()
@type range() :: nil | :empty | bounds()
A NonnegRange: nil is allRange (unconstrained), :empty is
emptyRange, anything else a concrete bounds/0 pair. A pair whose upper
bound falls below its lower bound is empty too — ranged-sets defines
rangeIsEmpty (Range lower upper) = upper <= lower and makes every empty
range Eq-equal, which is why emptiness is a semantic test here rather than a
structural one.
Functions
@spec apply_window(map(), Plug.Conn.t(), pos_integer() | nil) :: {:ok, map()} | {:error, {:invalid_range, :lower_gt_upper | :negative_limit}}
Apply the Range header and the db-max-rows cap to a parsed plan.
The Range header window is INTERSECTED with the window the limit/offset
query parameters describe — it does not override them. ?limit=2 describes
rows 0..1, so Range: 0-5 still yields two rows; conversely ?limit=3 with
Range: 0-1 yields two. max_rows (PostgREST db-max-rows, nil for
uncapped) then bounds the effective limit.
The intersection is the top-level range, and isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange) (ApiRequest.hs#L190) rejects
it with 416 PGRST103 whenever it came out EMPTY — no matter which input
emptied it. ?offset=3 with Range: 0-1 intersects to nothing and is a 416,
not an empty 200. ?limit=0 is the sole exception: it is rewritten to the
limitZeroRange sentinel upstream and answers 200 with zero rows.
Returns {:ok, plan} or {:error, {:invalid_range, reason}}, where reason
picks the details string exactly as InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit) does (ApiRequest.hs#L177) — on the
emptiness of the RANGE HEADER, not of whichever input emptied the result.
@spec call_count_mode(Plug.Conn.t()) :: count_mode()
The count mode a function call (/rpc/<fn>) actually honors.
A function call plans as a CallReadPlan, whose query is built with mempty
in the explain-query position (Query.hs#L54) — unlike the table/view branch
at Query.hs#L50 there is no EXPLAIN result to substitute — and whose
MainTx.hs#L161 clause returns the result set without rewriting the table
total. shouldCount PlannedCount is False, so the counting CTE is skipped
too and the SQL total stays null::bigint. Prefer: count=planned therefore
has no effect at all on an RPC: the total stays * and the status stays 200.
count=exact/count=estimated do run the counting CTE and still yield a
total.
@spec content_range(non_neg_integer(), non_neg_integer(), non_neg_integer() | nil) :: String.t()
Render the Content-Range header value.
offset— the effective offset (lower bound) of the window.rows— number of rows actually returned.total— the total count (integer) when known, ornilfor*.
@spec count_mode(Plug.Conn.t()) :: count_mode()
Resolve the count mode from a Prefer: count=<mode> header. Defaults to
:none (PostgREST's default; total is rendered as *).
Bier.Preferences owns the Prefer vocabulary — which count= tokens exist
and which are invalid — and this module only consumes the resolved mode. That
single ownership is what keeps count=none consistent: it is NOT a
PreferCount value, so it resolves to the default :none here because it is
unrecognized, while Bier.Preferences simultaneously reports it in
invalidPrefs (a handling=strict request carrying it is a 400 PGRST122).
@spec out_of_bounds?(non_neg_integer(), non_neg_integer(), non_neg_integer() | nil) :: boolean()
Whether a requested window is out of bounds: a non-zero offset that lands at
or past the last row, with a known total and no rows returned. PostgREST
renders this as 416 PGRST103 (OutOfBounds) — but only when a count is known
(i.e. Prefer: count= was honored).
@spec range_window(Plug.Conn.t()) :: range()
Parse the Range header (when Range-Unit is items/absent) into the
range/0 it denotes.
The header is honored for GET only; every other method (including HEAD and
a POST /rpc/<fn>) reads as "no Range header" (allRange).
@spec status(non_neg_integer(), non_neg_integer(), non_neg_integer() | nil) :: 200 | 206
HTTP status for a successful read given the window and (optional) total.
Returns 206 when a count is known and the returned window does not cover the whole set (a non-zero offset, or fewer rows than the total). Otherwise 200.