/** * MCP Outline PostgreSQL - Cursor-Based Pagination * Efficient pagination using cursors instead of OFFSET * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { Pool, QueryResultRow } from 'pg'; import { logger } from './logger.js'; /** * Cursor pagination input arguments */ export interface CursorPaginationArgs { /** Maximum number of items to return (default: 25, max: 100) */ limit?: number; /** Cursor for next page (base64 encoded) */ cursor?: string; /** Direction of pagination (default: 'desc') */ direction?: 'asc' | 'desc'; } /** * Cursor pagination result */ export interface CursorPaginationResult { /** Array of items */ items: T[]; /** Cursor for the next page (null if no more pages) */ nextCursor: string | null; /** Cursor for the previous page (null if first page) */ prevCursor: string | null; /** Whether there are more items */ hasMore: boolean; /** Total count (optional, requires additional query) */ totalCount?: number; } /** * Cursor data structure (encoded in base64) */ interface CursorData { /** Value of the cursor field */ v: string | number; /** Direction for this cursor */ d: 'asc' | 'desc'; /** Secondary sort field value (for stable sorting) */ s?: string; } /** * Encode cursor data to base64 */ export function encodeCursor(data: CursorData): string { return Buffer.from(JSON.stringify(data)).toString('base64url'); } /** * Decode cursor from base64 */ export function decodeCursor(cursor: string): CursorData | null { try { const decoded = Buffer.from(cursor, 'base64url').toString('utf-8'); return JSON.parse(decoded) as CursorData; } catch (error) { logger.warn('Invalid cursor', { cursor, error: (error as Error).message }); return null; } } /** * Options for cursor-based pagination */ export interface PaginateOptions { /** The field to use for cursor (default: 'createdAt') */ cursorField?: string; /** Secondary sort field for stability (default: 'id') */ secondaryField?: string; /** Whether to include total count (requires extra query) */ includeTotalCount?: boolean; /** Default limit if not specified */ defaultLimit?: number; /** Maximum allowed limit */ maxLimit?: number; } const DEFAULT_OPTIONS: Required = { cursorField: 'createdAt', secondaryField: 'id', includeTotalCount: false, defaultLimit: 25, maxLimit: 100, }; /** * Build cursor-based pagination query parts * * @param args - Pagination arguments * @param options - Pagination options * @returns Query parts (WHERE clause, ORDER BY, LIMIT, parameters) */ export function buildCursorQuery( args: CursorPaginationArgs, options?: PaginateOptions ): { cursorCondition: string; orderBy: string; limit: number; params: any[]; paramIndex: number; } { const opts = { ...DEFAULT_OPTIONS, ...options }; const direction = args.direction || 'desc'; const limit = Math.min(Math.max(1, args.limit || opts.defaultLimit), opts.maxLimit); const params: any[] = []; let paramIndex = 1; let cursorCondition = ''; // Parse cursor if provided if (args.cursor) { const cursorData = decodeCursor(args.cursor); if (cursorData) { // Build cursor condition with secondary field for stability const op = direction === 'desc' ? '<' : '>'; if (cursorData.s) { // Compound cursor: (cursorField, secondaryField) comparison cursorCondition = `("${opts.cursorField}", "${opts.secondaryField}") ${op} ($${paramIndex}, $${paramIndex + 1})`; params.push(cursorData.v, cursorData.s); paramIndex += 2; } else { // Simple cursor cursorCondition = `"${opts.cursorField}" ${op} $${paramIndex}`; params.push(cursorData.v); paramIndex += 1; } } } // Build ORDER BY const orderDirection = direction.toUpperCase(); const orderBy = `"${opts.cursorField}" ${orderDirection}, "${opts.secondaryField}" ${orderDirection}`; return { cursorCondition, orderBy, limit: limit + 1, // Fetch one extra to detect hasMore params, paramIndex, }; } /** * Process query results for cursor pagination * * @param rows - Query result rows * @param limit - Original limit (not the +1 used in query) * @param cursorField - Field used for cursor * @param secondaryField - Secondary field for stable cursor * @param direction - Pagination direction * @returns Pagination result */ export function processCursorResults( rows: T[], limit: number, cursorField: string = 'createdAt', secondaryField: string = 'id', direction: 'asc' | 'desc' = 'desc' ): CursorPaginationResult { // Check if there are more results const hasMore = rows.length > limit; // Remove the extra item used for hasMore detection const items = hasMore ? rows.slice(0, limit) : rows; // Build next cursor let nextCursor: string | null = null; if (hasMore && items.length > 0) { const lastItem = items[items.length - 1]; nextCursor = encodeCursor({ v: lastItem[cursorField], d: direction, s: lastItem[secondaryField], }); } // Build prev cursor (first item, opposite direction) let prevCursor: string | null = null; if (items.length > 0) { const firstItem = items[0]; prevCursor = encodeCursor({ v: firstItem[cursorField], d: direction === 'desc' ? 'asc' : 'desc', s: firstItem[secondaryField], }); } return { items, nextCursor, prevCursor, hasMore, }; } /** * Execute a cursor-paginated query * * This is a high-level helper that combines building and processing. * * @param pool - PostgreSQL pool * @param baseQuery - Base SELECT query (without WHERE/ORDER BY/LIMIT) * @param baseParams - Parameters for the base query * @param args - Pagination arguments * @param options - Pagination options * @returns Paginated results * * @example * ```typescript * const result = await paginateWithCursor( * pool, * 'SELECT * FROM documents WHERE "deletedAt" IS NULL AND "collectionId" = $1', * [collectionId], * { limit: 25, cursor: args.cursor }, * { cursorField: 'createdAt' } * ); * ``` */ export async function paginateWithCursor( pool: Pool, baseQuery: string, baseParams: any[], args: CursorPaginationArgs, options?: PaginateOptions ): Promise> { const opts = { ...DEFAULT_OPTIONS, ...options }; const limit = Math.min(Math.max(1, args.limit || opts.defaultLimit), opts.maxLimit); const direction = args.direction || 'desc'; // Build cursor query parts const { cursorCondition, orderBy, limit: queryLimit, params: cursorParams, paramIndex } = buildCursorQuery(args, opts); // Combine base params with cursor params const allParams = [...baseParams, ...cursorParams]; // Adjust parameter placeholders in base query if needed // Base query params are $1, $2, etc. // Cursor params start at $N where N = baseParams.length + 1 // Build final query let query = baseQuery; // Add cursor condition if present if (cursorCondition) { // Check if base query has WHERE if (baseQuery.toUpperCase().includes('WHERE')) { query += ` AND ${cursorCondition}`; } else { query += ` WHERE ${cursorCondition}`; } } // Add ORDER BY and LIMIT query += ` ORDER BY ${orderBy}`; query += ` LIMIT ${queryLimit}`; // Execute query const result = await pool.query(query, allParams); // Process results const paginationResult = processCursorResults( result.rows, limit, opts.cursorField, opts.secondaryField, direction ); // Optionally get total count if (opts.includeTotalCount) { const countQuery = baseQuery.replace(/SELECT .* FROM/, 'SELECT COUNT(*) as count FROM'); const countResult = await pool.query<{ count: string }>(countQuery, baseParams); paginationResult.totalCount = parseInt(countResult.rows[0]?.count || '0', 10); } return paginationResult; } /** * Convert offset-based pagination to cursor-based response format * Useful for backwards compatibility */ export function offsetToCursorResult( items: T[], offset: number, limit: number, totalCount?: number ): CursorPaginationResult { const hasMore = totalCount !== undefined ? offset + items.length < totalCount : items.length === limit; return { items, nextCursor: hasMore ? encodeCursor({ v: offset + limit, d: 'desc' }) : null, prevCursor: offset > 0 ? encodeCursor({ v: Math.max(0, offset - limit), d: 'desc' }) : null, hasMore, totalCount, }; } /** * Validate pagination arguments */ export function validatePaginationArgs( args: CursorPaginationArgs, options?: Pick ): { limit: number; cursor: string | null; direction: 'asc' | 'desc' } { const maxLimit = options?.maxLimit ?? 100; const defaultLimit = options?.defaultLimit ?? 25; return { limit: Math.min(Math.max(1, args.limit ?? defaultLimit), maxLimit), cursor: args.cursor || null, direction: args.direction || 'desc', }; }