List endpoints — pagination, sort and search
Every list endpoint in the internal API takes the same five query parameters. Before I10 there were six competing conventions, measured across the controllers:
| spelling | files |
|---|---|
limit | 41 |
offset | 8 |
page | 5 |
pageSize | 2 |
cursor | 1 |
sort + dir | 1 |
q | 7 |
search | 2 |
A shared DTO existed in the /admin/* slice only. A front-end developer
moving between two screens had to open the controller to learn what the
second one called its page size, and the shared DataTable could not send
paging parameters at all without per-endpoint special-casing.
The convention
GET /controls?limit=50&offset=100&sort=updatedAt&dir=desc&q=mfa
| parameter | meaning |
|---|---|
limit | Page size. 1..200, default 50. An endpoint may set a lower ceiling (/admin-access-log caps at 100). |
offset | Rows to skip. >= 0, default 0. |
sort | Field name. Each endpoint declares which names it accepts; an unknown one falls back to that endpoint's default rather than erroring. |
dir | asc | desc. Anything else reads as the endpoint's default. |
q | Free-text search. |
cursor | Keyset pagination, for the endpoints that genuinely need it (GET /records/:type/:id/activity). An alternative to offset, never combined with it. |
Out-of-range values clamp, they do not 400: these are read endpoints
whose callers already handle "fewer rows than I asked for", and a 400 on
?limit= (an empty form field) punishes a client for nothing.
The response envelope
{
"items": [/* … */],
"totalCount": 412, // rows matching the filters, ignoring limit/offset
"hasMore": true, // offset + items.length < totalCount
"limit": 50,
"offset": 100,
}
totalCount and hasMore are spelled to match ROADMAP-V100 I11's
truncation signal on purpose: a client should not have to learn one word for
"there is more" on a capped list and a different one on a paged list.
X-List-Truncated (wave 2's B7) stays what it is — the disclosure for
endpoints that return a bare array and so have no envelope to put a
field in.
Endpoints that already shipped a different envelope add these fields and
keep their old ones. GET /controls is the worked example: it returns
total/page/pageSize (deprecated) alongside
totalCount/hasMore/limit/offset, so the API and the UI never had to
deploy on the same commit.
Compatibility — nothing was removed
Every older spelling is still accepted and resolved onto the canonical one:
| legacy | canonical |
|---|---|
pageSize | limit |
page | offset, as (page - 1) * limit |
search | q |
sortBy | sort |
sortDir | dir |
A canonical parameter always wins when both are present. So
?page=4&pageSize=25&search=acme and ?offset=75&limit=25&q=acme return the
same rows, and every bookmark, saved report URL and customer script written
against the old names keeps working. Retiring an alias is a separate,
announced decision — not a side effect of this change.
What the guard prevents is a new endpoint being written in the old dialect, which is the only way six conventions could grow back.
The public API is exempt, permanently
The public API (/v1/* and /v2/*) is not covered by any of this.
It is the documented, versioned, SDK-consumed surface: published in an
OpenAPI spec, called by the JavaScript and Python SDKs, the
Terraform provider and customers' own automation, and v1 carries a real
Sunset header with a date attached (see
public-api-versioning.md). Renaming a query
parameter there is a breaking change to customers' code no matter how many
aliases accompany it, and quietly changing a surface under an active
deprecation contract would make that contract worthless.
It already uses limit/offset on its paginated route — which is exactly
why those two were chosen as canonical here.
Writing a new list endpoint
import { normalizeListQuery, paginatedList } from '../common/list-query';
@Get()
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
@ApiQuery({ name: 'sort', required: false })
@ApiQuery({ name: 'dir', required: false })
@ApiQuery({ name: 'q', required: false })
async list(@TenantId() tenantId: string, @Query() query: Record<string, unknown>) {
const list = normalizeListQuery(query, {
allowedSorts: ['name', 'createdAt'],
defaultSort: 'name',
defaultDir: 'desc',
});
const [rows, totalCount] = await this.service.list(tenantId, list);
return paginatedList(rows, totalCount, list);
}
Take the whole query object rather than one @Query('name') per
parameter: the normalizer is what knows the alias set, and a per-name list in
the handler would be a second copy of it that drifts.
For a zod-validated endpoint (/admin/*, usage-metering), keep the
schema exactly as it is and change the parse call:
const result = parseListQuery(SomeQuerySchema, query);
parseListQuery resolves the aliases before zod sees them. It is a
pre-processor rather than a .transform() because a transform returns a
ZodEffects, which has no .extend() — and seven admin DTOs extend
PaginationQuerySchema.