/**
* @fileoverview Pagination manager for handling multi-page data fetching
* This module provides utilities for orchestrating pagination across multiple pages.
*/
import { createLogger } from '../logging/logger.js';
import {
PageInfo,
PaginationParams,
PaginatedResponse,
MultiPageOptions,
MultiPageResult,
PaginatedResponseWithMetadata,
PaginationMetadata,
} from './types.js';
import { normalizePaginationParams } from './helpers.js';
const logger = createLogger('PaginationManager');
/**
* Type for a function that fetches a page of data
* @template T The type of items being fetched
*/
export type PageFetcher<T> = (cursor?: string, pageSize?: number) => Promise<PaginatedResponse<T>>;
/**
* Fetches multiple pages of data with configurable limits
* @template T The type of items being fetched
* @param fetcher Function that fetches a single page
* @param options Configuration for multi-page fetching
* @returns Aggregated results from all fetched pages
*/
export async function fetchMultiplePages<T>(
fetcher: PageFetcher<T>,
options: MultiPageOptions = {}
): Promise<MultiPageResult<T>> {
const { maxPages = 10, pageSize = 50, fetchAll = false, onProgress } = options;
const allItems: T[] = [];
let pagesFetched = 0;
let currentCursor: string | undefined;
let hasMore = true;
let totalCount: number | undefined;
logger.debug('Starting multi-page fetch', {
maxPages,
pageSize,
fetchAll,
});
// Use a bounded loop to prevent infinite loop warnings
const maxIterations = fetchAll ? Number.MAX_SAFE_INTEGER : maxPages;
for (let iteration = 0; iteration < maxIterations && hasMore; iteration++) {
try {
// Fetch the next page
const response = await fetcher(currentCursor, pageSize);
// Add items to the collection
allItems.push(...response.items);
pagesFetched++;
// Update pagination state
hasMore = response.pageInfo.hasNextPage;
currentCursor = response.pageInfo.endCursor;
totalCount = response.totalCount;
// Report progress if callback provided
if (onProgress) {
onProgress(pagesFetched, allItems.length);
}
logger.debug('Fetched page', {
pageNumber: pagesFetched,
itemsInPage: response.items.length,
totalItemsSoFar: allItems.length,
hasMore,
});
// Break if no more pages
if (!hasMore) {
break;
}
} catch (error) {
logger.error('Error fetching page', {
pageNumber: pagesFetched + 1,
error,
});
// Ensure loop termination on error
hasMore = false;
throw error;
}
}
logger.info('Multi-page fetch completed', {
pagesFetched,
totalItems: allItems.length,
hasMore,
});
return {
items: allItems,
pagesFetched,
hasMore,
...(currentCursor && { lastCursor: currentCursor }),
...(totalCount && { totalCount }),
};
}
/**
* Adds user-friendly pagination metadata to a standard response
* @template T The type of items in the response
* @param response Standard paginated response
* @param pagesFetched Number of pages that were fetched (for multi-page operations)
* @param limitReached Whether a max_pages limit was reached
* @returns Response with additional metadata
*/
export function addPaginationMetadata<T>(
response: PaginatedResponse<T>,
pagesFetched = 1,
limitReached = false
): PaginatedResponseWithMetadata<T> {
const metadata: PaginationMetadata = {
has_more_pages: response.pageInfo.hasNextPage,
page_size: response.items.length,
...(response.pageInfo.endCursor && { next_cursor: response.pageInfo.endCursor }),
...(response.pageInfo.startCursor && { previous_cursor: response.pageInfo.startCursor }),
...(response.totalCount > 0 && { total_count: response.totalCount }),
...(pagesFetched > 1 && { pages_fetched: pagesFetched }),
...(limitReached && { limit_reached: limitReached }),
};
return {
...response,
pagination: metadata,
};
}
/**
* Handles pagination parameters including page_size alias and max_pages
* @param params Raw pagination parameters from user input
* @returns Normalized parameters ready for API calls
*/
export function processPaginationParams(params: PaginationParams): {
normalizedParams: PaginationParams;
maxPages?: number;
} {
const { page_size, max_pages, ...restParams } = params;
// Use page_size as an alias for first if provided
if (page_size !== undefined && restParams.first === undefined) {
restParams.first = page_size;
}
// Normalize the parameters
const normalizedParams = normalizePaginationParams(restParams);
return {
normalizedParams,
...(max_pages !== undefined && { maxPages: max_pages }),
};
}
/**
* Creates a pagination iterator for async iteration over pages
* @template T The type of items being fetched
* @param fetcher Function that fetches a single page
* @param pageSize Size of each page
* @returns Async iterator for pages
*/
export function createPaginationIterator<T>(
fetcher: PageFetcher<T>,
pageSize = 50
): AsyncIterable<T[]> {
return {
[Symbol.asyncIterator](): AsyncIterator<T[]> {
let currentCursor: string | undefined;
let hasMore = true;
return {
async next(): Promise<IteratorResult<T[]>> {
if (!hasMore) {
return { done: true, value: undefined };
}
try {
const response = await fetcher(currentCursor, pageSize);
hasMore = response.pageInfo.hasNextPage;
currentCursor = response.pageInfo.endCursor;
return {
done: false,
value: response.items,
};
} catch (error) {
logger.error('Error in pagination iterator', { error });
throw error;
}
},
};
},
};
}
/**
* Validates cursor format and checks for expiration
* @param cursor The cursor to validate
* @returns Whether the cursor is valid
*/
export function isValidCursor(cursor: string | undefined): boolean {
if (cursor === undefined) {
return true; // Undefined cursor is valid (starts from beginning)
}
// Basic validation - cursor should be a non-empty string
return typeof cursor === 'string' && cursor.trim().length > 0;
}
/**
* Merges multiple paginated responses into a single response
* @template T The type of items being merged
* @param responses Array of paginated responses to merge
* @returns Merged paginated response
*/
export function mergeResponses<T>(responses: PaginatedResponse<T>[]): PaginatedResponse<T> {
if (responses.length === 0) {
return {
items: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
totalCount: 0,
};
}
const allItems = responses.flatMap((r) => r.items);
const firstResponse = responses[0];
const lastResponse = responses[responses.length - 1];
if (!firstResponse || !lastResponse) {
return {
items: allItems,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
totalCount: allItems.length,
};
}
const pageInfo: PageInfo = {
hasNextPage: lastResponse.pageInfo.hasNextPage,
hasPreviousPage: firstResponse.pageInfo.hasPreviousPage,
...(firstResponse.pageInfo.startCursor && {
startCursor: firstResponse.pageInfo.startCursor,
}),
...(lastResponse.pageInfo.endCursor && { endCursor: lastResponse.pageInfo.endCursor }),
};
return {
items: allItems,
pageInfo,
totalCount: lastResponse.totalCount || allItems.length,
};
}