Pagination
Transport-neutral keyset (cursor) and offset paging that produces a Page<T>, with composite sorts and an opaque cursor codec.
Every list handler returns one shared, transport-neutral envelope, Page<T> (from
Elarion.Abstractions.Paging), so a list looks identical across JSON-RPC, MCP, HTTP, and the
generated TypeScript client:
public sealed record Page<T> {
public required IReadOnlyList<T> Items { get; init; }
public string? StartCursor { get; init; } // keyset only
public string? EndCursor { get; init; } // keyset only
public bool HasNext { get; init; }
public bool HasPrevious { get; init; }
public int? Total { get; init; } // offset only
}The cursors follow the Relay convention: StartCursor/EndCursor are opaque tokens for the first
and last items, populated for keyset pagination and null for offset pagination, where Total is
populated instead. Page<T>.Empty is a ready-made empty page.
Keyset (seek) pagination is the default for feeds and large lists; offset pagination is available where a UI needs page numbers and a total count.
Pagination ships in a separate package (Elarion.Paging) so the EF Core marker package stays
dependency-free. The execution helpers depend on EF Core (Microsoft.EntityFrameworkCore.Relational),
while the provider-neutral Page<T> envelope and the IKeysetPageRequest/IOffsetPageRequest
contracts live in Elarion.Abstractions.Paging. Reference Elarion.Paging (the runtime helpers) and
Elarion.EntityFrameworkCore (which bundles the generator that emits keyset definitions) in the
project that owns your entities and handlers:
<PackageReference Include="Elarion.Paging" Version="0.2.6" />
<PackageReference Include="Elarion.EntityFrameworkCore" Version="0.2.6" />Keyset pagination
Declare the ordered key columns on a dedicated partial class with [Keyset<TEntity>] — the entity
stays free of pagination concerns, and an entity can have any number of orderings. Columns are
ascending by default; prefix a name with - for descending. The generator
(KeysetGenerator) fills the class with a reflection-free
IKeysetDefinition<TEntity> (ordering and seek predicate are plain typed expressions the provider
translates to SQL like hand-written OrderBy/Where) plus a static Definition singleton and an
opaque cursor codec that carries the key values (no extra round-trip to reload a reference row):
using Elarion.Paging; // [Keyset<T>]
public sealed class Client {
public Guid Id { get; init; }
public DateTime CreatedAt { get; init; }
public string Name { get; init; } = "";
}
// one partial class per ordering, declared near the feature that pages it
[Keyset<Client>(nameof(Client.CreatedAt), nameof(Client.Id))] // CreatedAt, then Id to break ties
public sealed partial class ClientsByCreated;For a "newest first" feed, prefix each column with - to sort descending. Keeping every column the
same direction also lets the Npgsql provider use a row-value seek (see below):
[Keyset<Post>("-CreatedAt", "-Id")] // CreatedAt desc, then Id desc to break ties
public sealed partial class RecentPosts;Handlers call ToKeysetPageAsync with the keyset definition and a server-side projection. Passing
the definition explicitly (ClientsByCreated.Definition) keeps keyset symmetric with offset paging —
which passes a SortMap — and lets one handler choose among an entity's orderings. The request
implements IKeysetPageRequest, so its After/Before/Size fields stay flat for HTTP GET query
binding (KeysetPageRequest is a ready-made implementation for handlers that need no extra filters):
[Handler("clients.list")]
[HttpEndpoint("clients")]
public sealed class ListClients(AppDbContext db, ICurrentUser user)
: IHandler<ListClients.Query, Result<Page<ListClients.Item>>> {
public sealed record Query : IKeysetPageRequest {
public string? After { get; init; }
public string? Before { get; init; }
public int Size { get; init; } = 20;
public string? Search { get; init; }
}
public sealed record Item(Guid Id, string Name, DateTime CreatedAt);
public async ValueTask<Result<Page<Item>>> HandleAsync(Query query, CancellationToken ct) {
var rows = db.Clients.Where(c => c.OwnerId == user.UserId);
if (!string.IsNullOrEmpty(query.Search)) {
rows = rows.Where(c => c.Name.Contains(query.Search));
}
return await rows.ToKeysetPageAsync(
query,
ClientsByCreated.Definition,
c => new Item(c.Id, c.Name, c.CreatedAt),
maxSize: 100, ct);
}
}The selector is the only projection — it runs server-side, composed with the key columns so the
query reads just your columns plus the keys (untracked, no extra round-trip) and encodes the boundary
cursors from those keys. That is why it is passed in rather than applied as a separate .Select
before paging: the cursor needs the key values your DTO does not carry.
Pass After (an EndCursor from a prior page) to page forward, Before (a StartCursor) to page
backward, or neither for the first page. Size is clamped to maxSize (defaulting to
QueryablePagingExtensions.DefaultMaxSize, which is 100).
Cursors are opaque but not a security boundary — they encode the boundary row's key values and
are neither signed nor encrypted. Keep applying your own authorization filter (e.g.
Where(c => c.OwnerId == user.UserId)); never trust a cursor for access control.
Each cursor embeds a build-stable keyset-identity tag (a hash of the definition's fully-qualified
name and its ordered column types), so a cursor minted by one keyset endpoint is rejected — not
silently reinterpreted — when fed to another. A cursor that is malformed, truncated, of the wrong
format version, or carries a mismatched identity tag makes BuildSeek (and therefore
ToKeysetPageAsync) throw a MalformedCursorException rather than resetting to the first page.
Map it to a validation / 400 Bad Request response so the client learns its cursor is invalid; its
message names no key values and is safe to surface.
Keyset column rules
The generator enforces the hard column rules at compile time, so a typo or a column that cannot be translated is a build error, not a runtime bug:
- Column names must match a property (
ELKEY001, error), so a typo — or a rename you forget to propagate — fails the build. That is why the"-Column"string form needs nonameof. - Columns must be non-nullable (
ELKEY003, error) for deterministic SQL ordering. - If the entity has a primary-key property that is not part of the keyset, the generator warns
(
ELKEY004) that paging may skip or repeat rows; append it (or another unique column). The key is detected as a[Key]-annotated property, or the EF conventional nameIdor{EntityType}Id. It stays a warning rather than an error because a keyset whose own columns are already unique is legitimately deterministic without the key. - Supported column types are the integral types (
int,long,short,byte),decimal/double/float,string,Guid,DateTime,DateTimeOffset,DateOnly,TimeOnly, and enums; others reportELKEY002. - The keyset class must be a top-level, non-nested partial class (
ELKEY005) so the generator can emit the definition into it.
Provider-optimized seek (Npgsql row values)
By default the seek predicate is a portable lexicographic comparison
(a > x || (a == x && b > y)) that every provider can translate. PostgreSQL can do better: a
row-value expression
(a, b) > (x, y) is a single comparison that a composite index on (a, b) satisfies directly. EF
Core has no provider-neutral row-value API yet (dotnet/efcore#26822),
so this is an explicit opt-in that keeps the default output provider-neutral.
Add the assembly-level attribute (typically in the project that owns your entities) to switch the generated seek to row values:
using Elarion.EntityFrameworkCore;
[assembly: UseElarionEntityFrameworkCore(Provider = EfCoreProvider.Npgsql)]With Npgsql selected, a keyset of two or more columns (up to seven) that share one direction emits
EF.Functions.GreaterThan/LessThan over ValueTuples, which Npgsql renders as a row value:
// [Keyset<Client>(nameof(Client.CreatedAt), nameof(Client.Id))] with the Npgsql provider:
__e => EF.Functions.GreaterThan(
ValueTuple.Create(__e.CreatedAt, __e.Id),
ValueTuple.Create(__key0, __key1));The generator falls back to the portable predicate automatically when row values do not apply: a
single-column keyset, mixed sort directions (e.g. -CreatedAt, Id), a keyset of more than seven
columns, or the default Portable provider. The opt-in requires the
Npgsql.EntityFrameworkCore.PostgreSQL package, which supplies the
EF.Functions.GreaterThan(ValueTuple, ValueTuple) overloads the generated code calls.
Offset pagination
When a UI needs random page access and a total count, use ToOffsetPageAsync with a SortMap — an
AOT-safe whitelist that binds sort keys to typed selectors (no dynamic LINQ, and clients can only sort
by columns you allow). Each entry may chain ThenBy tiebreakers (each with a SortDirection) for a
stable composite order. The map is a fixed whitelist that doesn't depend on the request, so build it
once with CreateBuilder(...).Add(...).Build() and keep the immutable result in a static readonly
field rather than rebuilding per call:
private static readonly SortMap<Client> Sort = SortMap<Client>
.CreateBuilder("createdAt", c => c.CreatedAt, SortDirection.Descending) // default sort
.ThenBy(c => c.Id, SortDirection.Descending) // stable tiebreaker
.Add("name", c => c.Name)
.ThenBy(c => c.Id)
.Build();
public async ValueTask<Result<Page<Item>>> HandleAsync(Query query, CancellationToken ct) {
return await db.Clients
.Where(c => c.OwnerId == user.UserId)
.ToOffsetPageAsync(query, c => new Item(c.Id, c.Name, c.CreatedAt), Sort, maxSize: 100, ct);
}The request implements IOffsetPageRequest — flat Page (1-based), Size, and Sort fields, again
to keep HTTP GET query binding simple (OffsetPageRequest is the ready-made implementation).
Build() produces an immutable map backed by a FrozenDictionary, so a shared static instance is
safe for concurrent requests; the mutable SortMapBuilder is single-threaded and used only at
startup.
A request Sort of "name" orders by Name then Id; a leading - (descending) or +
(ascending) flips only the entry's primary column, while declared tiebreakers keep their direction (so
"-name" sorts Name descending, Id ascending). Unknown or blank keys fall back to the default
entry. Offset pages fill Total; keyset pages fill the cursors instead.
Consuming an offset page
Page<T> is one envelope for both strategies, so an offset endpoint still returns the cursor fields —
they are simply always null. The rule for a client is short:
totalis always present on an offset endpoint (ToOffsetPageAsynccounts the filtered query every call), so page numbers are computable:totalPages = Math.ceil(total / size)— wheresizeis the effective page size, not necessarily the one you sent.startCursorandendCursorare alwaysnull— ignore them. Do not build a "next page" from them, and do not treat their absence as an error.hasNext/hasPreviousare filled on both strategies, so a plain prev/next control needs nothing else.
The effective size matters because the server clamps Size to the handler's maxSize (default 100,
as described under keyset pagination — the same clamp applies to both strategies),
and substitutes 20 for anything below 1, itself capped at maxSize (so a handler with
maxSize: 10 substitutes 10). Page<T> does not echo the size back, so divide by the size the server
would actually have used, or the last page number is wrong whenever a client over-asks:
// Mirror the handler's maxSize; the server clamps and the envelope never reports what it used.
const MAX_SIZE = 100
const requested = 20
const size = Math.min(requested < 1 ? 20 : requested, MAX_SIZE)
const page = await rpc.clients.list({ page: 1, size, sort: '-createdAt' })
// Offset endpoint: `total` is set, the cursors are not.
const totalPages = Math.ceil((page.total ?? 0) / size)
renderRows(page.items)
renderPager({ totalPages, canPrev: page.hasPrevious, canNext: page.hasNext })The simplest way to stay honest is to never send a size above the handler's maxSize in the first
place — then requested and effective size are the same number.
The uniform shape is deliberate: every list endpoint — keyset feed or numbered table — hands the client
the same envelope, so one list component, one Zod schema, and one cache key strategy cover both. A
strategy-specific response type would buy nothing but a fork in every consumer. If you find yourself
reading a cursor on an offset endpoint, you are on the wrong strategy — switch the handler to
ToKeysetPageAsync rather than teaching the client to guess.
Naming the request fields
IOffsetPageRequest fixes three member names — Page, Size, Sort — and the execution helpers read
the request through that interface. Those names reach the wire through your DTO, so a request that
implements the interface publishes page / size / sort.
If a contract needs different wire names (pageSize, say, or a sortBy/sortDirection pair), do not
rename the interface members — a request DTO owns its wire contract, and the paging contract is not the
place to negotiate it. Carry your own fields and build an OffsetPageRequest inside the handler:
public sealed record Query {
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20; // this endpoint's own wire name
public string? SortBy { get; init; }
public bool Descending { get; init; } // this endpoint's own wire shape
}
public async ValueTask<Result<Page<Item>>> HandleAsync(Query query, CancellationToken ct) {
var paging = new OffsetPageRequest {
Page = query.Page,
Size = query.PageSize,
Sort = query.Descending ? $"-{query.SortBy}" : query.SortBy,
};
return await db.Clients
.Where(c => c.OwnerId == user.UserId)
.ToOffsetPageAsync(paging, c => new Item(c.Id, c.Name, c.CreatedAt), Sort, maxSize: 100, ct);
}The translation is three lines in one place, and the endpoint keeps the vocabulary its clients expect.
Bulk operations
Insert large entity sets at PostgreSQL COPY speed with a non-tracking, EF-native ExecuteInsertAsync.
Archive & restore
Soft delete done deliberately — a nullable ArchivedAt timestamp, explicit query predicates, restore as a first-class command, a real DELETE for retention, and the common alternatives this recipe rejects.