259 lines
8.4 KiB
JavaScript
259 lines
8.4 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Cursor-Based Pagination Helper (Stripe API Style)
|
|
*
|
|
* Provides efficient pagination for large datasets using cursor-based approach
|
|
* instead of common offset-based (skip/limit) approache which degrades performance on deep pagination.
|
|
*
|
|
* Benefits:
|
|
* - Constant-time performance regardless of page depth
|
|
* - Uses MongoDB indexes efficiently (_id index)
|
|
* - Prevents Chrome DevTools cache eviction on large responses
|
|
* - Compatible with Stripe API pagination patterns
|
|
*
|
|
* Usage:
|
|
* const { buildCursorQuery, processCursorResults } = require('./helpers/cursor_pagination');
|
|
*
|
|
* const pagination = buildCursorQuery(req.body, { defaultLimit: 1000 });
|
|
* const records = await Model.find(pagination.filter, null, pagination.options);
|
|
* const result = processCursorResults(records, pagination.limit);
|
|
*/
|
|
|
|
const mongoose = require('mongoose');
|
|
const env = require('./env');
|
|
const { AppInputError } = require('./app_error');
|
|
const { Errors } = require('./constants');
|
|
|
|
// Pagination limits from environment variables
|
|
const DEFAULT_PAGE_LIMIT = env.PAGINATION_DEFAULT_LIMIT;
|
|
const MAX_PAGE_LIMIT = env.PAGINATION_MAX_LIMIT;
|
|
|
|
/**
|
|
* Build cursor-based query filter and options
|
|
* @param {Object} params - Request parameters
|
|
* @param {string} [params.startingAfter] - Cursor to start after (forward pagination)
|
|
* @param {string} [params.endingBefore] - Cursor to end before (backward pagination)
|
|
* @param {number} [params.limit] - Number of records per page (use -1 or 0 for all records)
|
|
* @param {boolean} [params.returnAll] - If true, return all records without pagination
|
|
* @param {Object} baseFilter - Base filter to apply (e.g., { fileId: '123' })
|
|
* @param {Object} options - Configuration options
|
|
* @param {number} [options.defaultLimit] - Default limit if not specified (uses env.PAGINATION_DEFAULT_LIMIT)
|
|
* @param {number} [options.maxLimit] - Maximum allowed limit (uses env.PAGINATION_MAX_LIMIT)
|
|
* @param {string} [options.cursorField='_id'] - Field to use for cursor (must be indexed)
|
|
* @param {boolean} [options.allowReturnAll=true] - Whether to allow returning all records
|
|
* @returns {Object} Query configuration { filter, options, limit, isBackward, hasStartingAfter, hasEndingBefore, returnAll }
|
|
*/
|
|
function buildCursorQuery(params, baseFilter = {}, options = {}) {
|
|
const {
|
|
defaultLimit = DEFAULT_PAGE_LIMIT,
|
|
maxLimit = MAX_PAGE_LIMIT,
|
|
cursorField = '_id',
|
|
allowReturnAll = true
|
|
} = options;
|
|
|
|
const startingAfter = params.startingAfter;
|
|
const endingBefore = params.endingBefore;
|
|
|
|
// Check if client wants all records
|
|
const requestedLimit = parseInt(params.limit);
|
|
const returnAll = params.returnAll === true || (allowReturnAll && (requestedLimit === -1 || requestedLimit === 0));
|
|
|
|
// If returning all, set limit to null, otherwise apply default and max
|
|
const limit = returnAll ? null : Math.min(requestedLimit || defaultLimit, maxLimit);
|
|
|
|
// Validate that only one cursor is used at a time
|
|
if (startingAfter && endingBefore) {
|
|
AppInputError.throw(undefined, 'Cannot use both startingAfter and endingBefore simultaneously');
|
|
}
|
|
|
|
// Cursors are ignored when returning all records
|
|
if (returnAll && (startingAfter || endingBefore)) {
|
|
AppInputError.throw(undefined, 'Cannot use cursors when requesting all records (returnAll or limit=-1)');
|
|
}
|
|
|
|
// Clone base filter to avoid mutation
|
|
const filter = { ...baseFilter };
|
|
|
|
// Add cursor to filter (only if not returning all)
|
|
if (!returnAll) {
|
|
if (startingAfter) {
|
|
// Fetch records AFTER the cursor (forward pagination)
|
|
filter[cursorField] = { $gt: mongoose.Types.ObjectId(startingAfter) };
|
|
} else if (endingBefore) {
|
|
// Fetch records BEFORE the cursor (backward pagination)
|
|
filter[cursorField] = { $lt: mongoose.Types.ObjectId(endingBefore) };
|
|
}
|
|
}
|
|
|
|
// Determine sort direction (reverse for backward pagination)
|
|
const sortDirection = endingBefore ? -1 : 1;
|
|
const sortOption = { [cursorField]: sortDirection };
|
|
|
|
// Build query options
|
|
const queryOptions = {
|
|
lean: true,
|
|
sort: sortOption
|
|
};
|
|
|
|
// Only set limit if not returning all
|
|
if (!returnAll) {
|
|
queryOptions.limit = limit + 1; // Fetch one extra to determine if there are more records
|
|
}
|
|
|
|
return {
|
|
filter,
|
|
options: queryOptions,
|
|
limit,
|
|
isBackward: !!endingBefore,
|
|
hasStartingAfter: !!startingAfter,
|
|
hasEndingBefore: !!endingBefore,
|
|
cursorField,
|
|
returnAll
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process cursor query results and extract pagination metadata
|
|
* @param {Array} records - Query results
|
|
* @param {number|null} limit - Requested limit (null if returning all)
|
|
* @param {boolean} isBackward - Whether this is backward pagination
|
|
* @param {string} [cursorField='_id'] - Field used for cursor
|
|
* @param {boolean} [returnAll=false] - Whether all records are being returned
|
|
* @returns {Object} Processed results { data, hasMore, startingAfter, endingBefore }
|
|
*/
|
|
function processCursorResults(records, limit, isBackward = false, cursorField = '_id', returnAll = false) {
|
|
// If returning all records, no pagination metadata needed
|
|
if (returnAll) {
|
|
return {
|
|
data: records,
|
|
hasMore: false,
|
|
returnAll: true,
|
|
total: records.length
|
|
};
|
|
}
|
|
|
|
// Check if there are more records
|
|
const hasMore = records.length > limit;
|
|
|
|
// Remove the extra record if present
|
|
if (hasMore) {
|
|
records.pop();
|
|
}
|
|
|
|
// For backward pagination, reverse the results to maintain correct order
|
|
if (isBackward) {
|
|
records.reverse();
|
|
}
|
|
|
|
const result = {
|
|
data: records,
|
|
hasMore: hasMore
|
|
};
|
|
|
|
// Provide cursor tokens for next/previous pages
|
|
if (records.length > 0) {
|
|
result.startingAfter = records[records.length - 1][cursorField]; // Cursor for next page
|
|
result.endingBefore = records[0][cursorField]; // Cursor for previous page
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Complete cursor pagination helper - combines query building and result processing
|
|
* @param {Object} Model - Mongoose model to query
|
|
* @param {Object} params - Request parameters
|
|
* @param {Object} baseFilter - Base filter to apply
|
|
* @param {Object} options - Configuration options
|
|
* @returns {Promise<Object>} Paginated results with metadata
|
|
*/
|
|
async function paginateWithCursor(Model, params, baseFilter = {}, options = {}) {
|
|
// Validate parameters
|
|
if (params.startingAfter && params.endingBefore) {
|
|
AppInputError.throw(undefined, 'Cannot use both startingAfter and endingBefore simultaneously');
|
|
}
|
|
|
|
// Build query
|
|
const queryConfig = buildCursorQuery(params, baseFilter, options);
|
|
|
|
// Execute query
|
|
const records = await Model.find(queryConfig.filter, null, queryConfig.options);
|
|
|
|
// Process results
|
|
const result = processCursorResults(
|
|
records,
|
|
queryConfig.limit,
|
|
queryConfig.isBackward,
|
|
queryConfig.cursorField,
|
|
queryConfig.returnAll
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Validate cursor pagination parameters
|
|
* @param {Object} params - Request parameters to validate
|
|
* @returns {Object} Validation result { valid: boolean, error: string|null }
|
|
*/
|
|
function validateCursorParams(params) {
|
|
if (params.startingAfter && params.endingBefore) {
|
|
return {
|
|
valid: false,
|
|
error: 'Cannot use both startingAfter and endingBefore simultaneously'
|
|
};
|
|
}
|
|
|
|
if (params.startingAfter && !mongoose.Types.ObjectId.isValid(params.startingAfter)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Invalid startingAfter cursor: must be a valid ObjectId'
|
|
};
|
|
}
|
|
|
|
if (params.endingBefore && !mongoose.Types.ObjectId.isValid(params.endingBefore)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Invalid endingBefore cursor: must be a valid ObjectId'
|
|
};
|
|
}
|
|
|
|
if (params.limit && (isNaN(params.limit) || params.limit < 1)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Invalid limit: must be a positive number'
|
|
};
|
|
}
|
|
|
|
return { valid: true, error: null };
|
|
}
|
|
|
|
/**
|
|
* Express middleware for cursor pagination parameter validation
|
|
* @param {Object} options - Configuration options
|
|
* @returns {Function} Express middleware function
|
|
*/
|
|
function cursorPaginationMiddleware(options = {}) {
|
|
return (req, res, next) => {
|
|
const params = req.body || req.query;
|
|
const validation = validateCursorParams(params);
|
|
|
|
if (!validation.valid) {
|
|
return AppInputError.create(undefined, validation.error);
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
buildCursorQuery,
|
|
processCursorResults,
|
|
paginateWithCursor,
|
|
validateCursorParams,
|
|
cursorPaginationMiddleware,
|
|
DEFAULT_PAGE_LIMIT,
|
|
MAX_PAGE_LIMIT
|
|
};
|