Querying & Pagination
How list endpoints read data, extend the query per request, and page results with the cursor-based pagination helper.
The canonical list/read surface on BaseRepository<T>. Every read here merges the current tenant's schoolId into the where for you — see Multi-Tenancy. Year-scoped lists additionally require an explicit campusAcademicYearId in the query DTO — that filter is yours to apply, never auto-merged; see Request Scoping.
Two ways to read a list
| Door | Use for | Returns |
|---|---|---|
findAll(options?) | straightforward reads | T[] |
createQueryBuilder(alias) | dynamic joins / optional filters | SelectQueryBuilder<T> |
Both auto-merge the tenant predicate.
Find options
findAll takes FindManyOptions<T> — the per-request extend surface. Pass where, relations, select, order inline:
const rows = await this.repo.findAll({
where: { status: 'active' },
relations: { profile: true },
select: { id: true, firstName: true },
order: { createdAt: 'DESC' },
});Same shape works across findOne, findById, findOneWhere, findAndCount, count, exists. The repo's scopeManyOptions rewrites where to add the tenant predicate, then passes through.
Query builder
createQueryBuilder(alias) returns a normal TypeORM builder with the tenant filter already andWhere-d on, and .where rewired to .andWhere:
qb.where = ((where, params) => boundAndWhere(where, params)) as typeof qb.where;TypeORM's real .where() resets all conditions — the rewire stops a later call site from silently dropping the schoolId predicate.
Cursor pagination
repo.cursorPaginate(alias, options) builds a tenant-injected query builder and pages it with a cursor. Service collection methods that page are named list, not findAll.
async list(query: TransactionListQueryDto) {
const scope = this.permissionScope.getScope<TransactionEntity>(
Action.LIST,
Resource.TRANSACTION,
);
return this.transactionRepository.cursorPaginate('tn', {
...query,
scope: scope ?? undefined,
searchColumns: TransactionService.SEARCH_COLUMNS,
});
}The DTO field names line up with the options, so ...query does most of the wiring. The options object:
| Option | Meaning |
|---|---|
afterId / beforeId | UUID cursors; forward / backward; mutually exclusive |
limit | page size, default 10 |
scope | CASL permission where from the caller — see RBAC & Scopes |
searchColumns + search | multi-word ILIKE; term split on whitespace, each word must match ≥1 column |
findOptions | standard where and relations |
cursorField | default 'id' |
dateField | default 'createdAt' |
startDate / endDate | date-range filter |
sortPrefix | extra leading ORDER BY keys for a stable composite cursor |
On the cursor path, findOptions.select, skip, take, and order are ignored — the cursor owns ordering and slicing. (They still work on plain findAll.)
Mechanics: results come back createdAt DESC, id DESC (id is the stable tiebreak; sortPrefix prepends keys ahead of that pair). The helper fetches limit + 1 rows to compute hasMore without a second query, then trims. Backward paging flips order internally, then reverses rows so you read newest-first.
What you get back
export interface CursorPaginatedResult<T> {
data: T[];
totalCount: number;
hasMore: boolean;
nextCursor: string | null;
previousCursor: string | null;
}Controllers document this in OpenAPI with the CursorPaginatedResponseDto(ItemDto) mixin, which wraps the item DTO in the paginated envelope.
The query DTO
List request DTOs extend CursorPaginationQueryDto from @app/shared — it carries search, both cursors, limit, and the date range, already validated:
export class CursorPaginationQueryDto {
@IsOptional() @IsString() search?: string;
@IsOptional() @IsUUID() afterId?: string;
@IsOptional() @IsUUID() beforeId?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
limit?: number; // default 10, coerced from the query string
}@Type(() => Number) on limit coerces the query-string value before @Min/@Max run — see DTOs & Validation. A feature adds its own filters by extending the base:
export class JobApplicationListQueryDto extends CursorPaginationQueryDto {
@IsOptional() @IsUUID() vacancyId?: string;
@IsOptional() @IsEnum(JobApplicationStatus) status?: JobApplicationStatus;
}The service decides which extra fields map to cursorPaginate options and which become findOptions.where.
cursorField, dateField, and sortPrefix take entity property names (createdAt, staffId) — never snake_case column names (created_at, staff_id). A column name fails to resolve and the order-by builder crashes at runtime.
Why cursors
An offset is a position, and positions drift: insert or delete a row on page one while a user reads page two and every later offset shifts — they see a row twice or skip one. A cursor anchors on an exact record, so the next page stays correct under concurrent writes.
Where to go next
Multi-Tenancy
How the schoolId predicate gets merged into every read.
DTOs & Validation
How query DTOs are coerced and validated before the service sees them.
RBAC & Scopes
Where the CASL scope passed into cursorPaginate comes from.
API Conventions
The response envelopes and controller patterns lists feed into.
DTOs & Validation
How request and response DTOs are decorated to validate input and document the API, and what the global pipe does and does not do.
Users & Profiles
One identity table, five profile types, a type discriminator — and where to read about the profiles, their cross-type links, and the user lifecycle.