A TypeScript implementation of Relay's GraphQL Connection specification for Apollo Server. This library provides cursor-based pagination that follows the Relay Connection spec, allowing your GraphQL API to implement efficient, stable pagination.
This library was originally forked from Pocket/apollo-cursor-pagination and has been converted to TypeScript with enhanced type safety and additional features.
- âś… Relay Connection Spec Compliant: Implements the complete Relay Connection specification
- âś… TypeScript Support: Full TypeScript support with comprehensive type definitions
- âś… Multiple Data Source Support: Currently supports Knex.js, DynamoDB Toolbox, and JavaScript arrays with extensible architecture for other data sources
- âś… Primary Key Support: Enhanced cursor generation with primary key support
- âś… Secondary Index Support: Full support for DynamoDB GSIs and LSIs with custom cursor generation
- âś… Flexible Ordering: Support for single and multiple column ordering
- âś… Custom Edge Modification: Ability to add custom metadata to edges
- âś… Column Name Formatting: Support for custom column name transformations
- âś… Array Pagination: Built-in support for paginating JavaScript arrays with cursor-based pagination
- âś… Performance Controls: Built-in
maxPagesconfiguration to prevent runaway queries and manage resource consumption - âś… Advanced Filtering: Comprehensive support for DynamoDB Toolbox filters with performance optimization
npm install apollo-cursor-pagination-tsyarn add apollo-cursor-pagination-tsThis library requires the following peer dependencies:
- knex:
*(any version) - Required for the Knex.js connector - dynamodb-toolbox:
^2.6.5- Required for the DynamoDB connector (if using)
Make sure to install these in your project:
npm install knex
# or if using DynamoDB
npm install dynamodb-toolbox@^2.6.5import { knexPaginator } from 'apollo-cursor-pagination-ts';
import knex from 'knex';
// Your GraphQL resolver
const catsResolver = async (_, args) => {
const { first, last, before, after, orderBy, orderDirection } = args;
const baseQuery = knex('cats');
const result = await knexPaginator(baseQuery, {
first,
last,
before,
after,
orderBy,
orderDirection,
});
return result;
};import { dynamodbPaginator } from 'apollo-cursor-pagination-ts';
import {
Entity,
EntityRepository,
item,
string,
number,
prefix,
map,
EntityAccessPattern,
} from 'dynamodb-toolbox';
// Define your entity using v2 syntax
const UserEntity = new Entity({
name: 'User',
schema: item({
id: string().savedAs('pk').transform(prefix('USER')).key(),
email: string().savedAs('sk').transform(prefix('EMAIL')).key(),
name: string(),
age: number(),
category: string(),
}),
table: YourTable,
});
const userRepo = UserEntity.build(EntityRepository);
// Create an access pattern using EntityAccessPattern (required for pagination)
const usersByCategory = UserEntity.build(EntityAccessPattern)
.schema(map({ category: string() }))
.pattern(({ category }) => ({ partition: `CATEGORY#${category}` }))
.meta({
title: 'Users by Category',
description: 'Query users filtered by category',
});
// Your GraphQL resolver
const usersResolver = async (_, args) => {
const { first, last, before, after, orderDirection } = args;
const result = await dynamodbPaginator(
{ category: 'premium' }, // Query input
usersByCategory, // Access pattern
{ first, last, before, after, orderDirection }
);
return result;
};import { arrayPaginator } from 'apollo-cursor-pagination-ts';
// Your GraphQL resolver
const usersResolver = async (_, args) => {
const { first, last, before, after, orderBy, orderDirection } = args;
// Your array of users (could be from cache, memory, or pre-fetched data)
const users = [
{ id: '1', name: 'Alice', email: '[email protected]' },
{ id: '2', name: 'Bob', email: '[email protected]' },
// ... more users
];
const result = await arrayPaginator(users, {
first,
last,
before,
after,
orderBy,
orderDirection,
});
return result;
};type Cat {
id: ID!
name: String!
age: Int!
}
type CatEdge {
cursor: String!
node: Cat!
}
type CatConnection {
edges: [CatEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
cats(
first: Int
last: Int
before: String
after: String
orderBy: String
orderDirection: String
): CatConnection!
}The knexPaginator function is the main entry point for Knex.js integration:
import { knexPaginator } from 'apollo-cursor-pagination-ts';
const result = await knexPaginator(
baseQuery, // Knex query builder
paginationArgs, // GraphQL pagination arguments
options // Optional configuration
);baseQuery: A Knex.js query builder instancepaginationArgs: GraphQL pagination arguments:first: Number of items to fetch (forward pagination)last: Number of items to fetch (backward pagination)before: Cursor for backward paginationafter: Cursor for forward paginationorderBy: Column(s) to order byorderDirection: 'asc' or 'desc' (or array for multiple columns)
options: Optional configuration object
The function returns a ConnectionResult object:
interface ConnectionResult<T> {
pageInfo: {
hasPreviousPage: boolean;
hasNextPage: boolean;
startCursor?: string;
endCursor?: string;
};
totalCount?: number;
edges: Array<{
cursor: string;
node: T;
}>;
}If you're using an ORM like Objection.js that maps column names, you can use the formatColumnFn option:
const result = await knexPaginator(
baseQuery,
{ first, last, before, after, orderBy, orderDirection },
{
formatColumnFn: (column) => {
// Transform camelCase to snake_case
return column.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
},
}
);Add custom metadata to each edge:
const result = await knexPaginator(
baseQuery,
{ first, last, before, after, orderBy, orderDirection },
{
modifyEdgeFn: (edge) => ({
...edge,
customField: 'custom value',
timestamp: new Date().toISOString(),
}),
}
);For performance optimization, you can skip the total count calculation:
const result = await knexPaginator(
baseQuery,
{ first, last, before, after, orderBy, orderDirection },
{
skipTotalCount: true,
}
);Specify a custom primary key (defaults to 'id'):
const result = await knexPaginator(
baseQuery,
{ first, last, before, after, orderBy, orderDirection },
{
primaryKey: 'uuid',
}
);You can order by multiple columns:
const result = await knexPaginator(baseQuery, {
first: 10,
orderBy: ['createdAt', 'id'],
orderDirection: ['desc', 'asc'],
});The dynamodbPaginator function is the main entry point for DynamoDB Toolbox integration:
import { dynamodbPaginator } from 'apollo-cursor-pagination-ts';
const result = await dynamodbPaginator(
queryInput, // Query input parameters from AccessPattern
accessPattern, // DynamoDB Toolbox access pattern
paginationArgs, // GraphQL pagination arguments
options // Optional configuration
);queryInput: The input parameters for your DynamoDB query (e.g.,{ category: 'premium' })accessPattern: APagerEntityAccessPatternthat defines how to query your data (must usePagerEntityAccessPattern, not the standardAccessPattern)paginationArgs: GraphQL pagination arguments (same as Knex.js)options: Optional configuration object includingformatPrimaryKeyFnfor custom cursor generation
Access patterns are the key concept in DynamoDB Toolbox. They define how to query your data based on your table's design. Important: For pagination to work correctly, you must use EntityAccessPattern from DynamoDB Toolbox.
import { EntityAccessPattern } from 'dynamodb-toolbox';
import { map, string, number } from 'dynamodb-toolbox';
// Simple access pattern by partition key
const usersByCategory = UserEntity.build(EntityAccessPattern)
.schema(map({ category: string() }))
.pattern(({ category }) => ({ partition: `CATEGORY#${category}` }))
.meta({
title: 'Users by Category',
description: 'Query users filtered by category',
});
// Access pattern with sort key
const usersByCategoryAndDate = UserEntity.build(EntityAccessPattern)
.schema(map({ category: string(), date: string() }))
.pattern(({ category, date }) => ({
partition: `CATEGORY#${category}`,
range: { eq: `DATE#${date}` },
}))
.meta({
title: 'Users by Category and Date',
description: 'Query users by category and specific date',
});
// Access pattern with GSI
const usersByEmail = UserEntity.build(EntityAccessPattern)
.schema(map({ email: string() }))
.pattern(({ email }) => ({
index: 'email-index',
partition: `EMAIL#${email}`,
}))
.meta({
title: 'Users by Email',
description: 'Query users by email using GSI',
});
// Access pattern with range conditions
const usersByAgeRange = UserEntity.build(EntityAccessPattern)
.schema(map({ category: string(), minAge: number(), maxAge: number() }))
.pattern(({ category, minAge, maxAge }) => ({
partition: `CATEGORY#${category}`,
range: { gte: minAge, lte: maxAge },
}))
.meta({
title: 'Users by Age Range',
description: 'Query users in a specific age range',
});DynamoDB ordering is handled through the orderDirection parameter:
// Ascending order (default)
const result = await dynamodbPaginator(
{ category: 'premium' },
usersByCategory,
{ first: 10, orderDirection: 'asc' }
);
// Descending order
const result = await dynamodbPaginator(
{ category: 'premium' },
usersByCategory,
{ first: 10, orderDirection: 'desc' }
);Note: DynamoDB ordering is based on the sort key of your table or GSI. The orderDirection parameter controls whether the query uses reverse: true or not.
DynamoDB pagination works seamlessly with both Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI). When using secondary indexes, you need to specify the index property in your access pattern:
// GSI access pattern
const usersByEmail = UserEntity.build(EntityAccessPattern)
.schema(map({ email: string() }))
.pattern(({ email }) => ({
index: 'email-index', // Specify the GSI name
partition: `EMAIL#${email}`,
}));
// LSI access pattern
const usersByCategoryAndDate = UserEntity.build(EntityAccessPattern)
.schema(map({ category: string(), date: string() }))
.pattern(({ category, date }) => ({
index: 'category-date-index', // Specify the LSI name
partition: `CATEGORY#${category}`,
range: { eq: `DATE#${date}` },
}));Important: When using secondary indexes, you may need to use the formatPrimaryKeyFn option to ensure proper cursor generation. This is especially important when:
- Using GSIs: The cursor needs to include both the primary table keys and the GSI keys
- Complex key structures: When your table has multiple key attributes that need to be included in the cursor
// Example with formatPrimaryKeyFn for GSI pagination
const result = await dynamodbPaginator(
{ category: 'premium' },
usersByCategory,
{ first: 10 },
{
formatPrimaryKeyFn: (node) => ({
// Include primary table keys
pk: `USER#${node.id}`,
sk: `EMAIL#${node.email}`,
// Include GSI keys
pk2: `CATEGORY#${node.category}`,
sk2: `DATE#${node.createdAt}`,
}),
}
);The formatPrimaryKeyFn is a function that allows you to customize how the primary key is extracted from each node for cursor generation. This is particularly useful for:
- Secondary Index Queries: When querying GSIs or LSIs, you may need to include both the primary table keys and the index keys in the cursor
- Complex Key Structures: When your DynamoDB table has multiple key attributes that need to be preserved for pagination
- Custom Key Formatting: When you need to transform or combine multiple attributes into the cursor
// Basic usage
const result = await dynamodbPaginator(
{ category: 'premium' },
usersByCategory,
{ first: 10 },
{
formatPrimaryKeyFn: (node) => ({
pk: `USER#${node.id}`,
sk: `EMAIL#${node.email}`,
}),
}
);
// Advanced usage with GSI
const result = await dynamodbPaginator(
{ category: 'premium' },
usersByCategory,
{ first: 10 },
{
formatPrimaryKeyFn: (node) => ({
// Primary table keys
pk: `USER#${node.id}`,
sk: `EMAIL#${node.email}`,
// GSI keys (if using GSI)
pk2: `CATEGORY#${node.category}`,
sk2: `AGE#${node.age}`,
}),
}
);When to use formatPrimaryKeyFn:
- GSI Queries: Always use this when querying GSIs to ensure the cursor includes all necessary key information
- Complex Tables: When your table has multiple key attributes that need to be preserved
- Custom Cursor Logic: When you need custom logic for cursor generation
Note: If you don't provide formatPrimaryKeyFn, the paginator will automatically extract the primary key from the node using DynamoDB Toolbox's entity parser. This works fine for simple primary table queries but may not be sufficient for GSI queries.
Cursor Generation: DynamoDB cursors are based on the primary key (partition key + sort key) of your items. The cursor contains the encoded primary key information needed for pagination.
Table Design: Your DynamoDB table design should support the access patterns you want to paginate. Consider using:
- GSIs (Global Secondary Indexes) for different query patterns
- Composite sort keys for hierarchical data access
- Sparse indexes for filtering
Performance: DynamoDB pagination is very efficient as it uses the ExclusiveStartKey parameter, which provides O(1) performance for pagination operations.
Example Table Design with Secondary Indexes:
// Example table structure for user posts using v2 syntax
import { Entity, item, string, number, prefix } from 'dynamodb-toolbox';
const PostEntity = new Entity({
name: 'Post',
schema: item({
// Primary key
userId: string().savedAs('pk').transform(prefix('USER')).key(),
postId: string().savedAs('sk').transform(prefix('POST')).key(),
// Attributes
title: string(),
content: string(),
createdAt: string(),
category: string(),
status: string(),
// GSI1 for category-based queries
gsi1pk: string().savedAs('gsi1pk').transform(prefix('CATEGORY')),
gsi1sk: string().savedAs('gsi1sk').transform(prefix('POST')),
// GSI2 for status-based queries
gsi2pk: string().savedAs('gsi2pk').transform(prefix('STATUS')),
gsi2sk: string().savedAs('gsi2sk').transform(prefix('DATE')),
}),
table: YourTable,
indexes: {
gsi1: {
partitionKey: 'gsi1pk',
sortKey: 'gsi1sk',
},
gsi2: {
partitionKey: 'gsi2pk',
sortKey: 'gsi2sk',
},
},
});
// Access patterns for different query patterns
const postsByUser = PostEntity.build(EntityAccessPattern)
.schema(map({ userId: string() }))
.pattern(({ userId }) => ({ partition: `USER#${userId}` }));
const postsByCategory = PostEntity.build(EntityAccessPattern)
.schema(map({ category: string() }))
.pattern(({ category }) => ({
index: 'gsi1',
partition: `CATEGORY#${category}`,
}));
const postsByStatus = PostEntity.build(EntityAccessPattern)
.schema(map({ status: string() }))
.pattern(({ status }) => ({
index: 'gsi2',
partition: `STATUS#${status}`,
}));
// Usage with formatPrimaryKeyFn for GSI queries
const result = await dynamodbPaginator(
{ category: 'technology' },
postsByCategory,
{ first: 10 },
{
formatPrimaryKeyFn: (node) => ({
// Primary table keys
pk: `USER#${node.userId}`,
sk: `POST#${node.postId}`,
// GSI1 keys
pk2: `CATEGORY#${node.category}`,
sk2: `POST#${node.postId}`,
}),
}
);The arrayPaginator function provides cursor-based pagination for JavaScript arrays. This is useful when you have data in memory that you want to paginate, or when working with data that has already been fetched from a database.
import { arrayPaginator } from 'apollo-cursor-pagination-ts';
const result = await arrayPaginator(
array, // JavaScript array of objects
paginationArgs, // GraphQL pagination arguments
options // Optional configuration
);const users = [
{
id: '1',
name: 'Alice',
email: '[email protected]',
createdAt: '2023-01-01',
},
{ id: '2', name: 'Bob', email: '[email protected]', createdAt: '2023-01-02' },
{
id: '3',
name: 'Charlie',
email: '[email protected]',
createdAt: '2023-01-03',
},
// ... more users
];
// Forward pagination
const result = await arrayPaginator(users, {
first: 10,
orderBy: 'name',
orderDirection: 'asc',
});
// Backward pagination
const result = await arrayPaginator(users, {
last: 10,
orderBy: 'createdAt',
orderDirection: 'desc',
});
// Cursor-based pagination
const result = await arrayPaginator(users, {
first: 5,
after: 'some-cursor',
orderBy: 'email',
orderDirection: 'asc',
});The array paginator supports ordering by multiple columns, just like the Knex.js connector:
const result = await arrayPaginator(users, {
first: 10,
orderBy: ['name', 'email'],
orderDirection: ['asc', 'desc'], // name ascending, email descending
});By default, the array paginator uses 'id' as the primary key. You can specify a custom primary key:
const result = await arrayPaginator(
users,
{
first: 10,
orderBy: 'name',
},
{
primaryKey: 'email', // Use email as the primary key
}
);For performance optimization with large arrays, you can skip the total count calculation:
const result = await arrayPaginator(
users,
{
first: 10,
orderBy: 'name',
},
{
skipTotalCount: true,
}
);array: A JavaScript array of objects to paginatepaginationArgs: GraphQL pagination arguments:first: Number of items to fetch (forward pagination)last: Number of items to fetch (backward pagination)before: Cursor for backward paginationafter: Cursor for forward paginationorderBy: Column(s) to order by (string or array of strings)orderDirection: 'asc' or 'desc' (or array for multiple columns)
options: Optional configuration object:primaryKey: Custom primary key (defaults to 'id')skipTotalCount: Skip total count calculation for performance
The array paginator returns the same ConnectionResult object as other connectors:
interface ConnectionResult<T> {
pageInfo: {
hasPreviousPage: boolean;
hasNextPage: boolean;
startCursor?: string;
endCursor?: string;
};
totalCount?: number;
edges: Array<{
cursor: string;
node: T;
}>;
}The array paginator is particularly useful for:
- In-memory data: When you have data already loaded in memory
- Caching scenarios: When working with cached data that needs pagination
- Testing: For testing pagination logic with mock data
- Simple applications: When you don't need database-level pagination
- Data transformation: When you need to paginate data after processing or filtering
const usersResolver = async (_, args) => {
const { first, last, before, after, orderBy, orderDirection } = args;
// Fetch all users (in a real app, you might filter this first)
const allUsers = await fetchAllUsers();
// Apply pagination
const result = await arrayPaginator(allUsers, {
first,
last,
before,
after,
orderBy,
orderDirection,
});
return result;
};- Large arrays: For very large arrays, consider using database-level pagination instead
- Memory usage: The array paginator loads all data into memory, so be mindful of memory usage
- Sorting: Multi-column sorting is performed in memory and may be slower than database sorting for large datasets
The library is designed to be extensible. You can create connectors for other ORMs by implementing the OperatorFunctions interface.
To create a custom connector, you need to implement these methods:
interface OperatorFunctions<N, NA, C> {
// Apply cursor filtering for forward pagination
applyAfterCursor: (
nodeAccessor: NA,
cursor: string,
opts: OrderArgs<C>
) => NA;
// Apply cursor filtering for backward pagination
applyBeforeCursor: (
nodeAccessor: NA,
cursor: string,
opts: OrderArgs<C>
) => NA;
// Apply ordering to the query
applyOrderBy: (nodeAccessor: NA, opts: OrderArgs<C>) => NA;
// Return first N nodes for forward pagination
returnNodesForFirst: (
nodeAccessor: NA,
count: number,
opts: OrderArgs<C>
) => Promise<N[]>;
// Return last N nodes for backward pagination
returnNodesForLast: (
nodeAccessor: NA,
count: number,
opts: OrderArgs<C>
) => Promise<N[]>;
// Return total count of nodes
returnTotalCount: (nodeAccessor: NA) => Promise<number>;
// Convert nodes to edges with cursors
convertNodesToEdges: (
nodes: N[],
params: GraphQLParams | undefined,
opts: OrderArgs<C>
) => { cursor: string; node: N }[];
}import apolloCursorPaginationBuilder from 'apollo-cursor-pagination-ts';
const myCustomConnector = apolloCursorPaginationBuilder({
applyAfterCursor: (query, cursor, opts) => {
// Implement cursor filtering logic
return query;
},
applyBeforeCursor: (query, cursor, opts) => {
// Implement cursor filtering logic
return query;
},
applyOrderBy: (query, opts) => {
// Implement ordering logic
return query;
},
returnNodesForFirst: async (query, count, opts) => {
// Return first N nodes
return [];
},
returnNodesForLast: async (query, count, opts) => {
// Return last N nodes
return [];
},
returnTotalCount: async (query) => {
// Calculate total count
return 0;
},
convertNodesToEdges: (nodes, params, opts) => {
// Convert nodes to edges with cursors
return nodes.map((node) => ({
cursor: 'encoded-cursor',
node,
}));
},
});
export default myCustomConnector;This library implements the complete Relay Connection specification. Key features include:
- Must have
edgesandpageInfofields edgesreturns a list of edge typespageInforeturns a non-nullPageInfoobject
- Must have
nodeandcursorfields nodecontains the actual datacursoris an opaque string for pagination
- Forward pagination:
firstandafter - Backward pagination:
lastandbefore - Consistent ordering across both directions
hasNextPage: Boolean indicating if more edges existhasPreviousPage: Boolean indicating if previous edges existstartCursor: Cursor of the first edgeendCursor: Cursor of the last edge
- MaxPages Configuration and Filter Integration - Comprehensive guide to using
maxPageswith filters - MaxPages Quick Reference - Quick reference for
maxPagesconfiguration
The library includes advanced performance controls and filtering capabilities:
- MaxPages Configuration: Prevent runaway queries by limiting the number of pages fetched
- DynamoDB Toolbox Filters: Comprehensive filtering support with performance optimization
- Resource Management: Built-in safeguards for database and network resource consumption
Run the test suite:
npm testRun tests in watch mode:
npm run test:watch- Fork the repository
- Create a feature branch
- Make your changes
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
# Install dependencies
npm install
# Build the project
npm run build
# Run type checking
npm run typecheck
# Run linting
npm run lint
# Format code
npm run formatMIT License - see LICENSE file for details.
- Original implementation by Pocket
- Relay team for the Connection specification
- Apollo GraphQL for the excellent GraphQL server framework