Pagination
A plain generated list route returns every row the caller’s read policy admits, as a bare array. That’s fine for small, bounded collections (an account’s linked cards, a customer’s addresses) but wrong for anything that grows without bound — transaction history, audit trails, anything paginated in a real UI.@@paged opts a model’s list route into a
stable, offset-based paging envelope instead.
Schema attribute
@@pagedmust be bare —@@paged(mode: "offset")or any other argument form fails validation with “use bare@@pagedin this slice.” Cursor-based paging isn’t implemented; this reads as a placeholder for a future mode, not a currently-selectable option.- one model can declare it at most once.
@@soft_delete (which
needs a deletedAt DateTime? column), @@paged only changes how the
list route behaves. The underlying table is unaffected.
Query contract
The generated list route acceptslimit and offset as ordinary query
parameters, on top of the usual fields / include / sort / where
contract every list route already has:
- both are optional
Ints; a non-numeric value or a negative value is a400naming the specific problem limitis capped atMAX_LIST_LIMIT(1000), and omitting it is not “no limit.” An explicitlimitabove the cap is a400. Omittinglimitentirely defaults it to the cap — it does not fall through to an unbounded fetch, soGET /transactionswith nolimitat all still only returns (at most) the first 1000 rows matching the read policy and anywherefilteroffsetdefaults to0when omittedMAX_LIST_LIMITis a hard ceiling, not per-model configurable. Every generated list route enforces the same constant (cratestack_core::page::MAX_LIST_LIMIT, currently1000), regardless of whether the model is@@paged— a non-paged model’s list route caps an omitted or over-limitlimitexactly the same way. There is no schema-level override today; if your use case genuinely needs a different ceiling, that’s a framework-level change, not something you can raise per model.
{"limit": 20, "offset": 40} for model.Transaction.list) — the
server synthesizes the equivalent query string internally and runs the
identical list path, so the envelope below is transport-independent.
Response envelope
cratestack_core::page::{Page, PageInfo}.
totalCount— total rows matching the read policy and anywherefilter, ignoringlimit/offset. Computed with a second query (aCOUNTover the same filtered predicate) run alongside the list query — a paged list costs two round trips to the database, not one.pageInfo.limit— the limit actually applied, including theMAX_LIST_LIMITdefault when the request omitted it (nevernull)pageInfo.offset— echoes exactly what the request suppliedpageInfo.hasNextPage—offset + limit < totalCountpageInfo.hasPreviousPage—offset > 0
@@paged model’s list route is unaffected — it keeps returning a
bare array, and limit/offset have no special handling for it.
Generated clients
Every generated client (Rust, Dart, TypeScript) exposes the same envelope;Page<T> in each is a hardcoded mirror of the struct above,
not an independently-designed type — field names differ only by each
language’s own casing convention (totalCount/total_count,
hasNextPage, etc.), never in shape.
Rust (generated HTTP client — client::Client, accessors are
pluralized like every other generated client; the server-side direct
Cratestack accessor used inside your own procedure handlers is
singular instead, e.g. cool.transaction(), not cool.transactions()):
list_view(...) /
listView(...) alongside list(...): same Page<T> envelope, just
with T narrowed to the projected shape instead of the full model.
The generated TypeScript client doesn’t have a projection-view API
today — only list(...) returning Page<Model>. See
Client Runtime for the full
Rust/Dart selection/projection API this composes with.
What this is not
- not cursor-based — no opaque cursor token, no keyset pagination.
offseton a table that’s being written to concurrently can skip or repeat rows across page fetches, same as any offset-based scheme. - not a per-model-tunable size limit —
MAX_LIST_LIMITrejects pathological requests, but it’s one fixed constant for the whole framework. It’s a safety ceiling, not a page-size policy you can set per model or per caller — build that on top if you need it. - not free — every paged fetch is two queries (list + count), not
one. A model that’s always fetched in full (small reference tables)
doesn’t want
@@paged. - not required for bounded relations — a to-many relation you always fetch completely (a currency’s denominations, a country’s provinces) should stay a plain list.
When to use it
Apply@@paged to any model whose list route can return an unbounded
or large number of rows: transaction/ledger history, audit-adjacent
event logs, anything a real UI paginates or infinite-scrolls. Skip it
for small, bounded collections where “just return everything” is
simpler for every caller.
Read Next
- Client Runtime — how
Page<T>composes with selection/projection builders across generated clients - Field Attributes — where
@@pagedsits alongside the other model-level attributes