Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 120 additions & 0 deletions src/lib/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ export interface PaginatedResponse<T> {
meta: PaginationMeta;
}

export interface CursorPaginationParams {
limit: number;
cursor?: string;
}

export interface CursorPaginationMeta {
limit: number;
nextCursor?: string;
hasMore: boolean;
total?: number;
}

export interface CursorPaginatedResponse<T> {
data: T[];
meta: CursorPaginationMeta;
}

const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;

Expand Down Expand Up @@ -83,6 +100,98 @@ export function parsePagination(query: {
return { limit, offset };
}

/**
* Parse cursor-based pagination parameters
* Supports cursor parameter for keyset pagination
*/
export function parseCursorPagination(query: {
limit?: string;
cursor?: string;
}): CursorPaginationParams {
const rawLimit = parseIntParam(query.limit, 'limit', { min: 1 });
const limit = rawLimit !== undefined ? Math.min(rawLimit, MAX_LIMIT) : DEFAULT_LIMIT;

let cursor: string | undefined;
if (query.cursor !== undefined && query.cursor.trim() !== '') {
cursor = query.cursor.trim();
}

return { limit, cursor };
}

/**
* Validate and decode cursor
* Cursor format: base64(created_at|id)
* Returns { created_at, id } or throws ValidationError
*/
export function decodeCursor(cursor: string): { created_at: string; id: string } {
try {
// Decode base64
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');

// Split by pipe
const parts = decoded.split('|');
if (parts.length !== 2) {
throw new Error('Invalid cursor format');
}

const [created_at, id] = parts;

if (!created_at || !id) {
throw new Error('Invalid cursor format: missing required fields');
}

// Validate timestamp format
const date = new Date(created_at);
if (isNaN(date.getTime())) {
throw new Error('Invalid timestamp in cursor');
}

return { created_at, id };
} catch (error) {
throw new ValidationError([
{
field: 'query.cursor',
message: 'Invalid cursor format. Must be base64 encoded string of created_at|id',
code: 'INVALID_VALUE'
},
]);
}
}

/**
* Generate cursor for next page
* Format: base64(created_at|id)
*/
export function generateCursor(created_at: string, id: string): string {
return Buffer.from(`${created_at}|${id}`).toString('base64');
}

/**
* Check if there are more results beyond the fetched limit
*/
export function hasMoreResults<T>(results: T[], limit: number): boolean {
return results.length > limit;
}

/**
* Extract next cursor from results
* Assumes results are sorted by created_at DESC, id DESC
*/
export function getNextCursor<T extends { created_at: string | Date; id: string }>(
results: T[],
limit: number
): string | undefined {
if (results.length > limit) {
const lastItem = results[limit - 1];
const created_at = typeof lastItem.created_at === 'string'
? lastItem.created_at
: lastItem.created_at.toISOString();
return generateCursor(created_at, lastItem.id);
}
return undefined;
}

export function paginatedResponse<T>(
data: T[],
meta: PaginationMeta,
Expand All @@ -94,3 +203,14 @@ export function paginatedResponse<T>(
}
return { data, meta };
}

export function cursorPaginatedResponse<T>(
data: T[],
meta: CursorPaginationMeta,
): CursorPaginatedResponse<T> {
// Truncate to limit
if (data.length > meta.limit) {
data.length = meta.limit;
}
return { data, meta };
}
Loading
Loading