---
name: cratis-arc-query-paging
description: Add server-side paging and sorting to a Cratis Arc read-model query — which return shapes Arc pages, the exact query-string keys, sorting pitfalls per storage provider, and the paged frontend hooks. Use when a list query can grow large enough that returning every row is wasteful, or needs server-side sorting. Do not use for general query creation or as a performance review.
license: MIT
---

# Page and sort an Arc query

Arc applies paging and sorting for you. You never write `Skip`/`Take` or read
the query string — you choose a **return shape** that the framework can narrow.

## Verified product sources

| Package | Version | Purpose |
| --- | --- | --- |
| `Cratis.Arc.Core` | `22.16.0` | `QueryableQueryRenderer`, `Paging`, `Sorting`, `PagingInfo` |
| `Cratis.Arc.MongoDB` | `22.16.0` | `Observe` helpers that page at the source |
| `Cratis.Arc.EntityFrameworkCore` | `22.16.0` | `DbSet<T>` observe helpers that page at the source |
| `@cratis/arc.react` | `22.16.0` | `useWithPaging`, `useSuspenseWithPaging` |

> Re-verified at the versions above by **symbol and signature**: every type, attribute and member this skill names exists at that tag, and the public surface it describes is unchanged since the previous verification (Chronicle 16.45.x / Arc 22.10.4 — the Chronicle 16→18 client diff is converters, options and doc comments; no type was removed or renamed). Behavior claims were verified at the earlier tag unless a section says otherwise.

Reverify before claiming support for another version. A small, bounded result
set does not need any of this — `IEnumerable<T>` is fine.

## The wire contract

| Query-string key | Effect |
| --- | --- |
| `pageSize` | **Required to page at all** on the query string; any parseable integer is accepted (`0` yields an empty page, negatives are clamped by `Skip`) — the JSON body reader, by contrast, treats `pageSize <= 0` as not paged |
| `page` | Zero-based. Defaults to `0` when `pageSize` is present |
| `sortby` | Field name; PascalCased before use, so `?sortby=name` sorts on `Name` |
| `sortDirection` | `desc` (case-insensitive) sorts descending; **anything else, including a missing value, is ascending** |

Two consequences that surprise people:

- **`page` alone does nothing.** Without `pageSize` the request is not paged.
- **Sorting needs both keys.** `sortby` without `sortDirection` is ignored
  entirely.

Paging is **not** validated on the request path at 22.16.0: `PageNumber`/`PageSize`
concept validators exist (`page >= 0`, `pageSize > 0`) but nothing feeds the
query-string paging into them, so an out-of-range value produces an empty or
clamped page rather than a validation failure. Validate paging arguments in the
frontend, or expose them as explicit query arguments if the rule must be
enforced.

## Which return shapes page

A query is a public static method on the `[ReadModel]` type, discovered by its
return type.

| Return | Paged? | How |
| --- | --- | --- |
| `T`, `T?`, `IEnumerable<T>`, `List<T>`, `T[]` | No | Nothing narrows the result |
| `IQueryable<T>` | **Yes** | The query renderer counts, orders, then applies `Skip`/`Take` |
| `Task<IQueryable<T>>` | **Yes** | Awaited first, then rendered as the queryable it unwraps to |
| `ISubject<IEnumerable<T>>` | **Yes, with a paging-aware storage helper** | Not by the renderer — the helper reads the ambient query context; a custom subject does not page automatically |
| `ISubject<IQueryable<T>>` | **No** | No `Observe` overload returns it, and the renderer matches on the outer type |

There is exactly one query renderer, and it is for `IQueryable`. Everything else
that pages does so inside its storage provider.

```csharp
[ReadModel]
public record <ReadModel>(<...>)
{
    public static IQueryable<<ReadModel>> All(<Collection> collection) =>
        collection.AsQueryable();

    // Filter before returning; Arc pages on top of the filtered queryable.
    public static IQueryable<<ReadModel>> Active(<Collection> collection) =>
        collection.AsQueryable().Where(_ => !_.<IsArchived>);

    // A default order so paging is stable when the caller sends no sortby.
    public static IQueryable<<ReadModel>> AllByName(<Collection> collection) =>
        collection.AsQueryable().OrderBy(_ => _.<Name>);
}
```

For a live paged list, return what `Observe` returns:

```csharp
public static ISubject<IEnumerable<<ReadModel>>> AllLive(<Collection> collection) =>
    collection.Observe(item => !item.<IsArchived>);
```

`ARC0001` is an **error** when a query method's return type is none of the
allowed shapes for its read model, and `ARC0014` is an error when a
query-shaped method on a read model is generic — a generic method is registered
and routed but can never be invoked, because there is nothing to close its type
parameters with.

For MongoDB `Observe` cost, distinguish the initial retained result, incremental
membership updates, and page-refill queries from an application callback that
explicitly re-runs a query. It does not always fetch the whole collection per
change. See the read-model skill's [versioned query-cost guidance](../cratis-chronicle-read-model/references/queries.md#cost-and-composition).

## Page the source, not the answer

- Do not `.ToList()` before returning an `IQueryable<T>` — that defeats
  `Skip`/`Take`.
- Do not hard-code `Take(n)`; it fights `pageSize`.
- ⚠️ `(await ...).AsQueryable()` **pages correctly and costs everything.**
  LINQ-to-objects honours `Skip`/`Take`, so the rows are right and the whole set
  was read to produce them. Nothing in the build or the specs will tell you.

Note also that the renderer calls `Count()` on the queryable on **every**
request to fill `totalItems`, before applying the order and the window. That is
a second round trip to the store per query.

## Sorting behaves differently per shape

⚠️ This is the sharpest edge here.

- For `IQueryable<T>`, the sort field is resolved with `ElementType.GetProperty(field)`
  and the result is dereferenced without a guard. **An unknown `sortby` is a
  server error (HTTP 500), not an ignored parameter.** Validate or constrain the
  field before it reaches the query if callers can choose it.
- The MongoDB and Entity Framework Core observe paths deliberately **degrade**:
  an unknown sort field leaves the result unsorted rather than failing.

Because `sortby` is PascalCased, the client sends the camelCase property name
the generated proxy uses and the server resolves the CLR property.

Give any list with a natural order a default `OrderBy`. Paging without one is
unstable across storage providers, which return unordered results in different
orders.

## Consume it from the frontend

```tsx
const [result, perform, setSorting, setPage, setPageSize] =
    <QueryName>.useWithPaging(<pageSize>);

// Observable queries have no perform:
const [liveResult, setSorting2, setPage2, setPageSize2] =
    <ObservableQueryName>.useWithPaging(<pageSize>);
```

⚠️ **The tuple shapes differ.** A plain query's `useWithPaging` returns five
elements — result, `perform`, `setSorting`, `setPage`, `setPageSize`. An
observable query's returns four; there is nothing to re-perform because it is
already streaming. Destructuring one as the other silently binds the wrong
functions.

`useSuspenseWithPaging` is the suspense variant of each, with the same shapes.

`result.paging` is `{ page, size, totalItems, totalPages }`. `page` is
**zero-based** — render `page + 1` in a label and pass zero-based values to
`setPage`. `totalPages` is computed from `totalItems / size`, and is `0` when the
result is not paged.

## Specify the data contract, not the paging

Paging is the framework's responsibility. A specification should pin which rows
the query selects and in what order.

⚠️ A query method that takes a storage collection type cannot be reached from a
read-model scenario: the scenario materializes read models in memory and has no
collection to hand such a method. Specify the projection through the scenario
and assert on the materialized instances; if the selection logic itself is worth
pinning, keep it in a method a specification can supply arguments to.

## Verify

- The query returns `IQueryable<T>` / `Task<IQueryable<T>>` for one-shot paging,
  or `ISubject<IEnumerable<T>>` for a live paged list — never
  `ISubject<IQueryable<T>>`.
- Nothing materializes the set before returning it.
- A meaningful default order exists wherever the data has one.
- A caller-supplied `sortby` cannot reach an `IQueryable<T>` query as an unknown
  field.
- The frontend destructures the tuple shape that matches the query kind.
- `page` is treated as zero-based everywhere.
- `dotnet build` is clean in Debug and Release with `ARC0001` and `ARC0014`
  silent.

## Route near misses

- Creating the read model or its projection: the Chronicle read-model guidance.
- Inspecting an observable query over HTTP: `cratis-arc-observable-query-http`.
- Whole-slice performance work: the performance review guidance.
- An accepted `.play` model under the model root covers the behavior, or the
  repository is opted in (the root holds a committed `.play` file (`git ls-tree -r --name-only HEAD -- <root>` lists it), or the project set
  `mcpServers.screenplay.root` in `.cratis/ai.json`; an empty directory, install
  output, an uncommitted `.play` draft or a `.play` file outside the root does not count; master definition:
  `cratis-screenplay-modeling-lifecycle`): change the model first with
  `cratis-screenplay-event-modeling`. If the Screenplay skills are not installed,
  say so and do not author `.play` from memory.
  Edit code here only for infrastructure, clients, adapters, Screenplay code
  attachments, or gap-fill scope (`cratis-screenplay-render-and-gap-fill`);
  never edit Stage-managed output.
