pagination
why
- any read whose result set can grow will be asked for more rows than a screen or request can afford.
- bounded cursor page = one screen load = one durable query with stable known cost.
- cursor makes "the same logical page" re-requestable while rows are inserted.
- offset fails both ways: cost grows with the offset; a shifting table silently drops or duplicates items for the user.
rule
- any read whose result set can grow gets a bounded page contract before it ships.
- page = cursor over a stable, server-defined order. one page is one durable query with a known cost, not an unbounded scan whose cost grows with the table.
- offset skips rows to reach a page (50,000 rows for page 1000) — cost grows linearly with the offset.
- a row inserted between a user's page request and load call shifts the whole result set: missed items or duplicates.
- without a cursor the client cannot re-request the same logical page; it can only guess an offset and hope the table hasn't changed.
contract shape
| field | rule |
|---|---|
| cursor | opaque, typed, stable across consecutive reads of the same order. |
| order | server-defined and named in the contract (createdAt desc); the client never supplies raw order SQL. |
| page size | bounded above by the contract; the client may request smaller. |
| next cursor | present iff another page exists. never hasMore + offset. |
- cursor survives inserts and deletes between pages: it encodes the position in the stable order, so a row inserted before the cursor cannot duplicate into the next page.
- offset (
skip/takeagainst a growing table) duplicates and skips rows; its cost grows withoffset— reject it for durable lists.
decision table
| read kind | pagination decision |
|---|---|
| durable list that can grow | cursor over a stable indexed order. |
| fixed small set (enum-like, settings) | no pagination; one bounded read. |
| search/filter results | cursor plus typed filter contract; filters are part of the cursor identity. |
| workflow/step history | cursor over the durable record's time order. |
| aggregate/dashboard numbers | not a list; one projection read, per aggregate design. |
do not
- ship an unbounded
listoperation because "there are only a few now". - accept client-supplied raw ordering or unbounded
limit. - return
hasMore: truewithout the cursor that continues the order. - let the client reconstruct the cursor format — it is opaque server vocabulary.
verification
- insert rows around the cursor position between two page reads; confirm no duplicate and no skipped row.
- request the largest allowed page; confirm the boundary rejects larger.
- read a filtered list, mutate a matching row's filter field, continue the cursor — the continuation follows the contract's declared snapshot semantics.
good and bad
bad
list(offset: number, take = 50) // skip/take against a growing table// response: { items, hasMore: true }
- duplicates and skips rows when anything is inserted between reads.
- cost grows with
offset;hasMoregives the client no way to continue the order. - user-visible cost: a list that silently loses or repeats items.
- operational cost: query latency climbs with table growth until the endpoint times out under normal traffic.
- paging then becomes an incident-driven rewrite instead of a contract decided up front.
good
list(cursor?: OrderCursor, pageSize?: number)// order fixed in contract: createdAt desc; response: { items, nextCursor? }
- typed opaque cursor over a stable server-defined order.
- no duplicates across inserts; cost bounded by page size; continuation is explicit.
- every screen load stays one indexed query with a cost the contract already declared — the list keeps working as the table grows.
- the same page contract later serves infinite scroll, export, and retry without a redesign.