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 by re-running the same filteredFindManyquery with nolimit/offsetand taking the length of the resulting row set — not a lightweight SQLCOUNT(*). The second query fetches and deserializes every matching row, so a paged list costs two round trips to the database and the second one scales with the total match count, not one cheap count plus one page.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 under REST and RPC — it
keeps returning a bare array, and limit/offset have no special
handling for it. REST and RPC are the only two transports, so this rule
has no exceptions.
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.
Procedure Arguments: PageInput
@@paged only affects a model’s generated list route. A custom
procedure that wants the same limit/offset capability declares a
PageInput argument instead:
PageInput is { limit: Int?, offset: Int? } — field names and
optionality match PageInfo’s own limit/offset exactly, so a
generated list route and a hand-written PageInput-accepting
procedure decode the same wire shape:
.resolve(max_limit) —
limit defaults to max_limit when unset and is clamped to [0, max_limit]; offset defaults to 0 and is clamped to >= 0. This is
the same clamp rule generated list routes already apply to their own
limit/offset, so a PageInput-accepting procedure gets the
identical resource-exhaustion guard without reimplementing it:
PageInput doesn’t return a Page<T> by itself — the procedure’s
return type is whatever it’s declared as. Pair page: PageInput with a
Page<Model> return type to give the procedure the exact same envelope
a @@paged list route returns:
FindMany<Model>’s own headline use case composes with PageInput
exactly this way.
Generated clients mirror PageInput the same way they mirror
PageInfo/Page<T> — a hardcoded, per-language struct/interface/class,
not derived per schema:
Embedded (on-device) pagination
Everything above describes the REST/RPC server surface, but pagination isn’t limited to it. Every embedded (cratestack-rusqlite) model and
view delegate exposes .find_many(...).paginate(page: PageInput) -> Page<M> unconditionally — @@paged is neither rejected nor required on
an embedded schema; the method is just always there.
COUNT(*) over the same
filters followed by the paginated SELECT, both inside one connection
borrow so a concurrent write can’t split what the count describes from
the page it’s paired with — the same Page<M> / PageInfo shape the
server-side envelope uses, assembled locally instead of over the wire.
Unlike the server path, there’s no separate wire contract to fix per
model here, so there’s nothing for @@paged to gate: the caller is the
same binary that defines the schema, choosing per call site whether it
wants Page<M> (.paginate(...)) or a bare Vec<M> (.run()).
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