Saltearse al contenido

Paginación

Veloce-TS incluye un conjunto completo de helpers para paginación por offset (página/límite) y por cursor.


La paginación por offset usa los parámetros page y limit para navegar por páginas de resultados.

El helper standalone paginate() construye una respuesta { data, meta } en una sola llamada:

import { Controller, Get, Query, paginate } from 'veloce-ts';
import { z } from 'zod';
const PaginacionSchema = z.object({
page: z.string().transform(Number).default('1'),
limit: z.string().transform(Number).default('10'),
});
@Controller('/productos')
class ProductoController {
@Get('/')
async listar(@Query(PaginacionSchema) query: z.infer<typeof PaginacionSchema>) {
const { page, limit } = query;
const offset = (page - 1) * limit;
const [data, total] = await Promise.all([
db.select().from(productos).limit(limit).offset(offset),
db.select({ count: sql<number>`count(*)` }).from(productos),
]);
return paginate(data, total[0].count, page, limit);
}
}

Estructura de respuesta:

{
"data": [...],
"meta": {
"total": 150,
"page": 2,
"limit": 10,
"totalPages": 15,
"hasNext": true,
"hasPrev": true,
"from": 11,
"to": 20
}
}

Para más control, usa la clase PaginationHelper:

import { PaginationHelper } from 'veloce-ts';
@Get('/')
async listar(@Query() query: any) {
// Parsea page/limit del query con valores por defecto y límite máximo
const { page, limit } = PaginationHelper.parsePaginationQuery(
query,
10, // defaultLimit
100 // maxLimit
);
const offset = (page - 1) * limit;
const data = await db.select().from(productos).limit(limit).offset(offset);
const total = await db.$count(productos);
return PaginationHelper.paginate(data, total, page, limit);
}

La paginación por cursor es más eficiente para grandes conjuntos de datos y feeds en tiempo real. En lugar de un número de página, se usa un cursor opaco apuntando a un registro específico.

import { Controller, Get, Query, createCursorPaginatedResult, parseCursorQuery } from 'veloce-ts';
import { gt } from 'drizzle-orm';
@Controller('/publicaciones')
class PublicacionController {
@Get('/')
async listar(@Query() query: any) {
// Parsea cursor y limit del query de forma segura
const { cursor, limit } = parseCursorQuery(query, 20, 100);
let filas;
if (cursor) {
const lastId = parseInt(atob(cursor));
filas = await db
.select()
.from(publicaciones)
.where(gt(publicaciones.id, lastId))
.limit(limit + 1); // obtener uno extra para detectar hasNext
} else {
filas = await db
.select()
.from(publicaciones)
.limit(limit + 1);
}
return createCursorPaginatedResult(filas, limit, 'id');
}
}

Estructura de respuesta:

{
"data": [...],
"meta": {
"count": 20,
"hasNext": true,
"hasPrev": false,
"nextCursor": "eyJpZCI6MjB9",
"prevCursor": null
}
}
Ventana de terminal
# Primera página
GET /publicaciones
# Siguiente página (usa nextCursor de la respuesta anterior)
GET /publicaciones?cursor=eyJpZCI6MjB9
# Página anterior
GET /publicaciones?cursor=eyJpZCI6MX0=&direction=prev
createCursorPaginatedResult(
data: T[],
limit: number,
cursorField: keyof T,
hadPrevCursor?: boolean // pasar true al navegar hacia adelante con cursor
): CursorPaginatedResult<T>

El parámetro hadPrevCursor (añadido en v0.4.0) establece correctamente hasPrev: true cuando no estás en la primera página:

const { cursor } = parseCursorQuery(query);
const resultado = createCursorPaginatedResult(
filas,
limit,
'id',
!!cursor // hasPrev = true cuando se proporcionó un cursor
);

Cuando ordenas por campos no únicos (p.ej. createdAt), necesitas un cursor compuesto usando múltiples campos para un ordenamiento estable.

import { createMultiCursor, decodeMultiCursor } from 'veloce-ts';
@Get('/')
async listar(@Query() query: any) {
const { cursor, limit } = parseCursorQuery(query, 20);
let where;
if (cursor) {
// Decodifica cursor compuesto: { createdAt, id }
const decoded = decodeMultiCursor(cursor);
where = or(
lt(publicaciones.createdAt, decoded.createdAt),
and(
eq(publicaciones.createdAt, decoded.createdAt),
lt(publicaciones.id, decoded.id)
)
);
}
const filas = await db
.select()
.from(publicaciones)
.where(where)
.orderBy(desc(publicaciones.createdAt), desc(publicaciones.id))
.limit(limit + 1);
const ultimoItem = filas[filas.length - 1];
const nextCursor = ultimoItem
? createMultiCursor(ultimoItem, ['createdAt', 'id'])
: null;
return {
data: filas.slice(0, limit),
meta: {
hasNext: filas.length > limit,
nextCursor,
count: Math.min(filas.length, limit),
},
};
}

interface PaginationMeta {
total: number; // total de elementos
page: number; // página actual (base 1)
limit: number; // elementos por página
totalPages: number; // total de páginas
hasNext: boolean; // true si hay página siguiente
hasPrev: boolean; // true si hay página anterior
from: number; // índice del primer elemento en esta página (base 1, p.ej. 11)
to: number; // índice del último elemento en esta página (p.ej. 20)
}
interface CursorPaginatedResult<T> {
data: T[];
meta: {
count: number; // elementos reales devueltos en esta página
hasNext: boolean;
hasPrev: boolean;
nextCursor: string | null;
prevCursor: string | null;
};
}

Evita que los clientes soliciten miles de registros en una sola llamada:

const { page, limit } = PaginationHelper.parsePaginationQuery(query, 10, 100);
// limit se limita a 100 aunque el cliente envíe limit=9999

2. Usa paginación por cursor para grandes conjuntos de datos

Sección titulada «2. Usa paginación por cursor para grandes conjuntos de datos»

La paginación por offset se vuelve lenta con offsets grandes (OFFSET 50000). Usa cursor para feeds, timelines o tablas con millones de filas.

3. Ordena siempre por columnas indexadas para cursor

Sección titulada «3. Ordena siempre por columnas indexadas para cursor»

La paginación por cursor solo funciona correctamente cuando se ordena por un campo indexado y estable. Usa id (clave primaria) o compuesto (createdAt, id).

4. Devuelve metadatos de paginación en todos los endpoints de lista

Sección titulada «4. Devuelve metadatos de paginación en todos los endpoints de lista»
// ✓ Bueno - los clientes pueden construir UI de paginación
return paginate(data, total, page, limit);
// ✗ Malo - los clientes no saben si hay más páginas
return data;
@Get('/')
@Summary('Listar productos con paginación')
@Description('Soporta paginación por offset (?page=1&limit=10) y por cursor (?cursor=xxx&limit=10)')
async listar(@Query() query: any) { ... }