Naalya Handbook

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

DoorUse forReturns
findAll(options?)straightforward readsT[]
createQueryBuilder(alias)dynamic joins / optional filtersSelectQueryBuilder<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:

libs/shared/src/database/base.repository.ts
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.

apps/server/src/app/transaction/transaction.service.ts
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:

OptionMeaning
afterId / beforeIdUUID cursors; forward / backward; mutually exclusive
limitpage size, default 10
scopeCASL permission where from the caller — see RBAC & Scopes
searchColumns + searchmulti-word ILIKE; term split on whitespace, each word must match ≥1 column
findOptionsstandard where and relations
cursorFielddefault 'id'
dateFielddefault 'createdAt'
startDate / endDatedate-range filter
sortPrefixextra 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

libs/shared/src/database/pagination/cursor-pagination.types.ts
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:

libs/shared/src/database/pagination/cursor-pagination.dto.ts
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:

apps/.../job-application/job-application-list-query.dto.ts
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

On this page