Pagination
A pagination: block on a query-json/query-html route paginates the main query — the
framework appends the dialect’s pagination clause at execution time, so the authored
2-way SQL stays plain-tool runnable and carries no LIMIT of its own (TQL-YAML-1018
warns when it does).
Offset strategy (default)
Section titled “Offset strategy (default)”sources: main: sql: file: search.sql # ends in ORDER BY <cols>, <pk> — a stable order, no LIMITpagination: size: 50 # rows per page maxSize: 200 # opt-in: the caller may pass ?size= up to this cap count: true # opt-in: run a select count(*) wrapper for totalsThe framework owns the ?page= (1-based) and ?size= request parameters — they are not
declared inputs (a bad value is a field-scoped 400). One row beyond the page is fetched
to answer hasNext without a count. The page context entry carries
number/size/hasNext/hasPrev (+ totalRows/totalPages with count: true) for
response bodies and templates — map it into the body like any other value
(response shaping):
response: json: body: rows: main.rows meta: page{"rows": [{"id": 1, "name": "…"}], "meta": {"number": 1, "size": 50, "hasNext": true, "hasPrev": false, "totalRows": 1234, "totalPages": 25}}The response automatically carries
X-Total-Count (when counting) and RFC 8288 Link rel="next"/rel="prev" headers. A
recipe: list view on a paginated route renders the kit’s hc-pagination nav, links preserving
the search and sort state (declarative views).
Keyset strategy
Section titled “Keyset strategy”input: after: { type: integer, required: false }sources: main: sql: file: users.sql params: after: params.afterpagination: strategy: keyset by: id # the cursor column; TQL-YAML-1016 when missing size: 20Keyset keeps the predicate in the SQL — SQL-first, plain-runnable. The
/*%if after != null */ wrapper is a 2-way SQL conditional block, so the cursor clause
renders only when a cursor was sent (two-way-sql.md):
select u.id, u.name from users uwhere 1 = 1/*%if after != null */ and u.id > /* after */ 0/*%end*/order by u.idThe framework derives the next cursor from the last row’s by: column (page.next), and
the Link: <…?after=N>; rel="next" header/nextHref follow. count: composes when a
total is worth its cost.
Composite cursors
Section titled “Composite cursors”A cursor over several columns declares by: as an ordered list — by: [order_id, line_no]. The next cursor becomes one opaque row token, and the framework decodes an
incoming ?after= into params.after.<column> parts (numeric parts bind as numbers). The
authored SQL writes the tuple predicate, binding each part like any other params
expression:
pagination: { strategy: keyset, by: [order_id, line_no] }sources: main: sql: file: lines.sql mode: query params: after_order_id: params.after.order_id after_line_no: params.after.line_no/*%if after_order_id != null */ and (t.order_id, t.line_no) > (/* after_order_id */ 0, /* after_line_no */ 0)/*%end*/order by t.order_id, t.line_noDialects without row-value comparison expand the same predicate as
a > x or (a = x and b > y). A malformed or wrong-arity after token is refused as an
input error; a single-column by: keeps today’s shape — the author declares the after
input and binds it directly.
Machine-checkable
Section titled “Machine-checkable”TQL-YAML-1015 (pagination on a non-query recipe), 1016 (keyset without by:/unknown
strategy), 1017 (size bounds), 1018 (authored LIMIT/FETCH warning); a page coverage
kind (coverage.thresholds.page) counts every paginated route a suite exercises; the
OpenAPI contract gains the page/size/after parameters. tesseraql scaffold crud
lists paginate this way out of the box (size 50, maxSize 200, counted).
- two-way-sql.md — the query being paged.
- declarative-views.md — the list view that renders the pages.