qmemory
Version:
A comprehensive production-ready Node.js utility library with MongoDB document operations, user ownership enforcement, Express.js HTTP utilities, environment-aware logging, and in-memory storage. Features 96%+ test coverage with comprehensive error handli
629 lines (568 loc) • 29.2 kB
JavaScript
/**
* Pagination Utilities
* Standardized pagination parameter validation and response formatting
*
* This module provides comprehensive pagination functionality that integrates
* seamlessly with the existing HTTP utilities and maintains consistency with
* the library's design patterns. It handles parameter validation, database
* query preparation, and response metadata generation.
*
* Design rationale:
* - Consistent pagination behavior across all endpoints that handle large datasets
* - Standardized error responses using existing HTTP utility patterns
* - Centralized parameter validation and calculation to reduce code duplication
* - RESTful pagination conventions with 1-based page numbering for user-friendly URLs
* - 0-based skip calculations optimized for database queries
*
* Benefits:
* 1. Uniform pagination behavior across the entire API surface
* 2. Reduced controller complexity through centralized validation logic
* 3. Consistent error messaging and HTTP status codes
* 4. Performance optimization through proper database query parameters
* 5. Client-friendly metadata for building navigation controls
*
* Integration with existing utilities:
* - Uses existing HTTP utilities (sendInternalServerError) for consistent error handling
* - Follows the same logging patterns as other modules for debugging consistency
* - Maintains the same parameter validation approach used throughout the library
*/
// Import existing HTTP utilities to maintain consistency with library patterns
const { sendInternalServerError } = require('./http-utils');
/**
* Validates and extracts pagination parameters from request query string
*
* This function implements comprehensive pagination parameter validation following
* the same defensive programming patterns used throughout the library. It handles
* parameter extraction, type conversion, range validation, and automatic error
* response generation when validation fails.
*
* Validation rules applied:
* - Page must be a positive integer starting from 1 (user-friendly numbering)
* - Limit must be a positive integer starting from 1 (prevents empty pages)
* - Both parameters have sensible defaults when not provided by client
* - Maximum limit enforced to prevent resource exhaustion attacks
* - Invalid parameters trigger immediate HTTP error responses
*
* Design decisions:
* - Returns null on validation failure after sending error response
* - Uses parseInt() with fallback to defaults for robust parameter handling
* - Calculates skip offset automatically for database query optimization
* - Provides detailed error messages for better API developer experience
* - Follows existing library pattern of direct HTTP response on validation failure
*
* Performance considerations:
* - Validation is O(1) and very fast for all input types
* - Skip calculation prevents database from scanning unwanted records
* - Early return on validation failure prevents unnecessary processing
*
* @param {Object} req - Express request object containing pagination query parameters
* @param {Object} res - Express response object for sending validation error responses
* @param {Object} options - Configuration options for pagination behavior
* @param {number} options.defaultPage - Default page number when not specified (default: 1)
* @param {number} options.defaultLimit - Default records per page when not specified (default: 50)
* @param {number} options.maxLimit - Maximum allowed records per page (default: 100)
* @returns {Object|null} Pagination parameters object with page, limit, skip, or null if validation fails
*/
function validatePagination(req, res, options = {}) { // standard query parameter validation for pagination
// Validate Express response object like other HTTP utilities
if (!res || typeof res.status !== 'function' || typeof res.json !== 'function') {
throw new Error('Invalid Express response object provided');
}
// Validate request object
if (!req) {
throw new Error('Invalid Express request object provided');
}
console.log(`validatePagination is running with query: ${JSON.stringify(req.query)}`);
try {
// Set default configuration values with reasonable limits
// These defaults balance usability with performance considerations
const {
defaultPage = 1, // Start with first page for intuitive user experience
defaultLimit = 50, // 50 records balances performance with usability
maxLimit = 100 // Prevent resource exhaustion while allowing flexibility
} = options;
// Ensure query object exists to prevent property access errors
// Defensive programming prevents crashes when query is undefined
req.query = req.query || {};
// Extract and convert pagination parameters with proper validation
// Handle string inputs and check for valid numeric values
let page = defaultPage;
let limit = defaultLimit;
// Parse page parameter if provided - validate for proper integer format
if (req.query.page !== undefined) {
const pageStr = String(req.query.page).trim();
const pageParam = parseInt(pageStr, 10);
// Check if input is a valid integer string (no decimals, no non-numeric chars)
// Allow leading zeros by comparing with the parsed value
if (isNaN(pageParam) || pageStr.includes('.') || !/^\d+$/.test(pageStr)) {
console.log(`validatePagination is returning null due to invalid page: ${req.query.page}`);
res.status(400).json({
message: 'Page must be a positive integer starting from 1',
timestamp: new Date().toISOString()
});
return null;
}
page = pageParam;
}
// Parse limit parameter if provided - validate for proper integer format
if (req.query.limit !== undefined) {
const limitStr = String(req.query.limit).trim();
const limitParam = parseInt(limitStr, 10);
// Check if input is a valid integer string (no decimals, no non-numeric chars)
// Allow leading zeros by using regex pattern
if (isNaN(limitParam) || limitStr.includes('.') || !/^\d+$/.test(limitStr)) {
console.log(`validatePagination is returning null due to invalid limit: ${req.query.limit}`);
res.status(400).json({
message: 'Limit must be a positive integer starting from 1',
timestamp: new Date().toISOString()
});
return null;
}
limit = limitParam;
}
// Validate page parameter using same validation pattern as other library modules
// Page must be positive integer starting from 1 for user-friendly URLs
if (page < 1 || !Number.isInteger(page)) {
console.log(`validatePagination is returning null due to invalid page: ${page}`);
res.status(400).json({
message: 'Page must be a positive integer starting from 1',
timestamp: new Date().toISOString()
});
return null;
}
// Validate limit parameter to prevent invalid page sizes
// Limit must be positive integer starting from 1 to ensure meaningful results
if (limit < 1 || !Number.isInteger(limit)) {
console.log(`validatePagination is returning null due to invalid limit: ${limit}`);
res.status(400).json({
message: 'Limit must be a positive integer starting from 1',
timestamp: new Date().toISOString()
});
return null;
}
// Enforce maximum limit to prevent resource exhaustion and maintain performance
// This protects against both accidental and malicious large page size requests
if (limit > maxLimit) {
console.log(`validatePagination is returning null due to limit exceeding maximum: ${limit} > ${maxLimit}`);
res.status(400).json({
message: `Limit cannot exceed ${maxLimit} records per page`,
timestamp: new Date().toISOString()
});
return null;
}
// Calculate database skip offset for efficient query performance
// Convert 1-based page numbering to 0-based database offset
const skip = (page - 1) * limit;
// Return validated pagination parameters ready for database queries
const pagination = { page, limit, skip };
console.log(`validatePagination is returning: ${JSON.stringify(pagination)}`);
return pagination;
} catch (error) {
// Handle unexpected errors using existing HTTP utility for consistency
// This maintains the same error handling patterns used throughout the library
console.error('Pagination validation error:', error);
sendInternalServerError(res, 'Internal server error during pagination validation');
return null;
}
}
/**
* Creates comprehensive pagination metadata for API responses
*
* This function generates standardized pagination metadata that enables clients
* to build navigation controls and understand the complete dataset context.
* The metadata follows common pagination patterns used in REST APIs.
*
* Design rationale:
* - Provides all information needed for client-side navigation implementation
* - Calculates derived values (totalPages, hasNext/PrevPage) to reduce client complexity
* - Includes both boolean flags and specific page numbers for flexible client usage
* - Follows naming conventions that are intuitive for API consumers
* - Enables clients to display "Page X of Y" style navigation
*
* Metadata structure optimized for common use cases:
* - Navigation buttons (previous/next with enabled/disabled states)
* - Page number displays with current page highlighting
* - Result count displays ("Showing X-Y of Z results")
* - Jump-to-page functionality with total page validation
*
* Performance characteristics:
* - All calculations are O(1) operations using basic arithmetic
* - Metadata generation is very fast regardless of dataset size
* - Values are computed once and cached in the response object
*
* @param {number} page - Current page number (1-based)
* @param {number} limit - Number of records per page
* @param {number} totalRecords - Total number of records in the complete dataset
* @returns {Object} Comprehensive pagination metadata object for API responses
*/
function createPaginationMeta(page, limit, totalRecords) { // metadata generation for client navigation
// Calculate total pages with proper ceiling division to handle partial pages
// Math.ceil ensures that any remainder creates an additional page
const totalPages = Math.ceil(totalRecords / limit);
// Calculate navigation state flags for client-side button state management
// These boolean flags simplify client logic for enabling/disabling navigation
const hasNextPage = page < totalPages;
const hasPrevPage = page > 1;
// Return comprehensive metadata object with all navigation information
// Structure designed for easy consumption by various client implementations
return {
currentPage: page, // Current page for highlighting in navigation
totalPages, // Total pages for "X of Y" displays
totalRecords, // Total records for result count displays
recordsPerPage: limit, // Records per page for consistency validation
hasNextPage, // Boolean flag for next button state
hasPrevPage, // Boolean flag for previous button state
nextPage: hasNextPage ? page + 1 : null, // Next page number or null for conditional rendering
prevPage: hasPrevPage ? page - 1 : null // Previous page number or null for conditional rendering
};
}
/**
* Creates a paginated response object with data and metadata
*
* This function combines query results with pagination metadata to create
* standardized API responses that include both the requested data and all
* navigation information needed by clients.
*
* Design rationale:
* - Provides consistent response structure across all paginated endpoints
* - Combines data and metadata in a single response to reduce client requests
* - Follows common API patterns that clients expect for paginated data
* - Enables clients to implement rich navigation experiences
*
* Response structure considerations:
* - Data array contains the actual results for the current page
* - Pagination object contains all navigation metadata
* - Structure is optimized for JSON serialization and client consumption
*
* @param {Array} data - Array of records for the current page
* @param {number} page - Current page number
* @param {number} limit - Records per page
* @param {number} totalRecords - Total records in complete dataset
* @returns {Object} Complete paginated response with data and metadata
*/
function createPaginatedResponse(data, page, limit, totalRecords) { // complete response structure
return {
data, // Current page results
pagination: createPaginationMeta(page, limit, totalRecords), // Navigation metadata
timestamp: new Date().toISOString() // Response timestamp for consistency
};
}
/**
* Validates and extracts cursor-based pagination parameters from request query
*
* Cursor-based pagination provides better performance for large datasets and prevents
* pagination drift when data is being modified. This approach uses stable unique
* identifiers as cursors rather than numeric offsets.
*
* Design benefits:
* - Consistent results even when data is added/removed during pagination
* - Better performance for large datasets (no OFFSET scanning required)
* - Supports real-time data without pagination drift issues
* - Natural ordering preservation for time-series or sorted data
*
* Cursor format expectations:
* - Encoded cursor strings containing sort field values and unique identifiers
* - Base64 encoded JSON for tamper resistance and URL safety
* - Graceful fallback to first page when cursor is invalid
*
* @param {Object} req - Express request object containing cursor parameters
* @param {Object} res - Express response object for sending error responses
* @param {Object} options - Configuration options for cursor pagination
* @param {number} options.defaultLimit - Default page size (default: 50)
* @param {number} options.maxLimit - Maximum allowed page size (default: 100)
* @param {string} options.defaultSort - Default sort field (default: 'id')
* @returns {Object|null} Cursor pagination object or null if validation fails
*/
function validateCursorPagination(req, res, options = {}) { // cursor-based pagination validation
console.log(`validateCursorPagination is running with query: ${JSON.stringify(req.query)}`);
// Validate Express response object like other HTTP utilities
if (!res || typeof res.status !== 'function' || typeof res.json !== 'function') {
throw new Error('Invalid Express response object provided');
}
// Validate request object
if (!req) {
throw new Error('Invalid Express request object provided');
}
try {
// Set default configuration values for cursor pagination
const {
defaultLimit = 50,
maxLimit = 100,
defaultSort = 'id'
} = options;
// Ensure query object exists to prevent property access errors
req.query = req.query || {};
// Extract cursor pagination parameters
let limit = defaultLimit;
const cursor = req.query.cursor || null;
const direction = req.query.direction || 'next'; // 'next' or 'prev'
const sort = req.query.sort || defaultSort;
// Parse limit parameter if provided
if (req.query.limit !== undefined) {
const limitStr = String(req.query.limit).trim();
const limitParam = parseInt(limitStr, 10);
// Validate limit is a proper integer
if (isNaN(limitParam) || limitStr.includes('.') || !/^\d+$/.test(limitStr)) {
console.log(`validateCursorPagination is returning null due to invalid limit: ${req.query.limit}`);
res.status(400).json({
message: 'Limit must be a positive integer starting from 1',
timestamp: new Date().toISOString()
});
return null;
}
limit = limitParam;
}
// Validate limit parameter range
if (limit < 1 || !Number.isInteger(limit)) {
console.log(`validateCursorPagination is returning null due to invalid limit: ${limit}`);
res.status(400).json({
message: 'Limit must be a positive integer starting from 1',
timestamp: new Date().toISOString()
});
return null;
}
// Enforce maximum limit to prevent resource exhaustion
if (limit > maxLimit) {
console.log(`validateCursorPagination is returning null due to limit exceeding maximum: ${limit} > ${maxLimit}`);
res.status(400).json({
message: `Limit cannot exceed ${maxLimit} records per page`,
timestamp: new Date().toISOString()
});
return null;
}
// Validate direction parameter
if (!['next', 'prev'].includes(direction)) {
console.log(`validateCursorPagination is returning null due to invalid direction: ${direction}`);
res.status(400).json({
message: 'Direction must be either "next" or "prev"',
timestamp: new Date().toISOString()
});
return null;
}
// Parse cursor if provided
let decodedCursor = null;
if (cursor) {
try {
const cursorJson = Buffer.from(cursor, 'base64').toString('utf-8');
decodedCursor = JSON.parse(cursorJson);
console.log(`validateCursorPagination decoded cursor:`, decodedCursor);
} catch (error) {
console.log(`validateCursorPagination is returning null due to invalid cursor: ${error.message}`);
res.status(400).json({
message: 'Invalid cursor format',
timestamp: new Date().toISOString()
});
return null;
}
}
const pagination = {
limit,
cursor: decodedCursor,
direction,
sort,
rawCursor: cursor
};
console.log(`validateCursorPagination is returning: ${JSON.stringify(pagination)}`);
return pagination;
} catch (error) {
console.error('Cursor pagination validation error:', error);
sendInternalServerError(res, 'Internal server error during cursor pagination validation');
return null;
}
}
/**
* Creates encoded cursor for cursor-based pagination
*
* Generates a tamper-resistant, URL-safe cursor that encodes the position
* information needed to continue pagination from a specific point in the dataset.
*
* Cursor encoding strategy:
* - Includes sort field value and unique identifier for stable positioning
* - Base64 encoding for URL safety and basic tamper resistance
* - JSON structure for flexibility with multiple sort fields
* - Compact format to minimize URL length impact
*
* @param {Object} record - The record to create a cursor from
* @param {string} sortField - Field name used for sorting
* @returns {string} Base64 encoded cursor string
*/
function createCursor(record, sortField = 'id') { // generate encoded cursor for pagination
if (!record) return null;
const cursorData = {
[sortField]: record[sortField],
id: record.id || record._id, // Support both SQL and MongoDB style IDs
timestamp: new Date().toISOString()
};
const cursorJson = JSON.stringify(cursorData);
const encodedCursor = Buffer.from(cursorJson, 'utf-8').toString('base64');
console.log(`createCursor generated cursor for ${sortField}=${record[sortField]}: ${encodedCursor}`);
return encodedCursor;
}
/**
* Creates cursor-based pagination metadata for API responses
*
* Generates comprehensive pagination metadata for cursor-based pagination that
* includes navigation cursors and page context information. This metadata enables
* clients to implement forward/backward navigation and understand their position
* in the dataset.
*
* @param {Array} data - Current page of records
* @param {Object} pagination - Pagination parameters used for the query
* @param {boolean} hasMore - Whether more records exist in the requested direction
* @param {string} sortField - Field used for sorting and cursor generation
* @returns {Object} Cursor pagination metadata object
*/
function createCursorPaginationMeta(data, pagination, hasMore, sortField = 'id') { // cursor metadata generation
const meta = {
limit: pagination.limit,
direction: pagination.direction,
sort: pagination.sort,
hasMore,
cursors: {}
};
// Generate cursors for navigation if data exists
if (data && data.length > 0) {
if (pagination.direction === 'next') {
meta.cursors.next = hasMore ? createCursor(data[data.length - 1], sortField) : null;
meta.cursors.prev = createCursor(data[0], sortField);
} else {
meta.cursors.next = createCursor(data[data.length - 1], sortField);
meta.cursors.prev = hasMore ? createCursor(data[0], sortField) : null;
}
}
// Add current cursor for reference
if (pagination.rawCursor) {
meta.cursors.current = pagination.rawCursor;
}
console.log(`createCursorPaginationMeta generated metadata:`, meta);
return meta;
}
/**
* Validates and extracts sorting parameters from request query
*
* Provides standardized sorting parameter validation that integrates with both
* offset-based and cursor-based pagination. Supports multiple sort fields with
* direction specification and validates against allowed fields for security.
*
* Sorting parameter format:
* - Single field: ?sort=name or ?sort=-name (descending)
* - Multiple fields: ?sort=name,-createdAt,id
* - Direction indicators: + for ascending (default), - for descending
*
* Security considerations:
* - Validates sort fields against allowlist to prevent injection attacks
* - Sanitizes field names to prevent NoSQL injection
* - Limits number of sort fields to prevent performance issues
*
* @param {Object} req - Express request object containing sort parameters
* @param {Object} res - Express response object for sending error responses
* @param {Object} options - Configuration options for sorting validation
* @param {Array} options.allowedFields - List of fields that can be sorted (required)
* @param {string} options.defaultSort - Default sort field and direction
* @param {number} options.maxSortFields - Maximum number of sort fields allowed
* @returns {Object|null} Sort configuration object or null if validation fails
*/
function validateSorting(req, res, options = {}) { // sorting parameter validation
console.log(`validateSorting is running with query: ${JSON.stringify(req.query)}`);
// Validate Express response object
if (!res || typeof res.status !== 'function' || typeof res.json !== 'function') {
throw new Error('Invalid Express response object provided');
}
// Validate request object
if (!req) {
throw new Error('Invalid Express request object provided');
}
try {
const {
allowedFields = [],
defaultSort = 'id',
maxSortFields = 3
} = options;
// Validate that allowed fields are provided
if (!Array.isArray(allowedFields) || allowedFields.length === 0) {
throw new Error('allowedFields must be provided and cannot be empty');
}
req.query = req.query || {};
// Extract sort parameter
const sortParam = req.query.sort || defaultSort;
// Parse sort fields and directions
const sortFields = sortParam.split(',').map(field => field.trim()).filter(Boolean);
// Validate number of sort fields
if (sortFields.length > maxSortFields) {
console.log(`validateSorting is returning null due to too many sort fields: ${sortFields.length} > ${maxSortFields}`);
res.status(400).json({
message: `Cannot sort by more than ${maxSortFields} fields`,
timestamp: new Date().toISOString()
});
return null;
}
// Parse and validate each sort field
const sortConfig = [];
for (const fieldSpec of sortFields) {
const direction = fieldSpec.startsWith('-') ? 'desc' : 'asc';
const fieldName = fieldSpec.replace(/^[+-]/, '');
// Validate field name format (alphanumeric and underscore only)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)) {
console.log(`validateSorting is returning null due to invalid field format: ${fieldName}`);
res.status(400).json({
message: `Invalid field name format: ${fieldName}`,
timestamp: new Date().toISOString()
});
return null;
}
// Validate field name against allowlist
if (!allowedFields.includes(fieldName)) {
console.log(`validateSorting is returning null due to invalid field: ${fieldName}`);
res.status(400).json({
message: `Invalid sort field: ${fieldName}. Allowed fields: ${allowedFields.join(', ')}`,
timestamp: new Date().toISOString()
});
return null;
}
sortConfig.push({ field: fieldName, direction });
}
const result = {
sortConfig,
sortString: sortParam,
primarySort: sortConfig[0] // First sort field for cursor pagination
};
console.log(`validateSorting is returning: ${JSON.stringify(result)}`);
return result;
} catch (error) {
console.error('Sorting validation error:', error);
sendInternalServerError(res, 'Internal server error during sorting validation');
return null;
}
}
/**
* Creates complete cursor-based paginated response
*
* Combines data with cursor pagination metadata to create a standardized response
* structure that enables efficient navigation through large datasets without the
* performance issues of offset-based pagination.
*
* @param {Array} data - Array of records for the current page
* @param {Object} pagination - Pagination parameters used for the query
* @param {boolean} hasMore - Whether more records exist in the requested direction
* @param {string} sortField - Field used for sorting and cursor generation
* @returns {Object} Complete cursor-based paginated response
*/
function createCursorPaginatedResponse(data, pagination, hasMore, sortField = 'id') { // complete cursor response
return {
data,
pagination: createCursorPaginationMeta(data, pagination, hasMore, sortField),
timestamp: new Date().toISOString()
};
}
// Export pagination utilities using consistent module pattern
// This follows the same export structure used throughout the library
module.exports = {
validatePagination, // Parameter validation and extraction
createPaginationMeta, // Metadata generation for responses
createPaginatedResponse, // Complete response structure creation
validateCursorPagination, // Cursor-based pagination validation
createCursor, // Cursor encoding for navigation
createCursorPaginationMeta, // Cursor pagination metadata
createCursorPaginatedResponse, // Complete cursor response structure
validateSorting // Sorting parameter validation
};