Savvi Studio

Cursor Pagination Examples

These examples demonstrate cursor-based pagination patterns for stateless queries.

Referenced by: cursor-patterns.md, patterns.md

Example 1: Basic Node Pagination

import { encodeCursor, decodeCursor } from '@/lib/cursor';
import { listNodes } from '@db/graph';

async function paginateNodes(
    client: PoolClient,
    cursor?: string
) {
    const limit = 50;
    
    // Decode cursor if present
    const cursorData = cursor ? decodeCursor(cursor) : null;
    const afterId = cursorData?.lastId;
    
    // Query with cursor
    const nodes = await listNodes(client, {
        limit: limit + 1,  // Fetch extra to check if more exist
        after_id: afterId
    });
    
    // Check if more results exist
    const hasMore = nodes.length > limit;
    const results = hasMore ? nodes.slice(0, limit) : nodes;
    
    // Create next cursor
    const nextCursor = hasMore
        ? encodeCursor({
            lastId: results[results.length - 1].id,
            limit,
            direction: 'forward',
            timestamp: Date.now(),
            version: 1
        })
        : null;
    
    return {
        nodes: results,
        cursor: nextCursor,
        hasMore
    };
}

Example 2: Edge Pagination

import { encodeCursor, decodeCursor } from '@/lib/cursor';
import { listEdges } from '@db/graph';

async function paginateEdges(
    client: PoolClient,
    sourceId: bigint,
    cursor?: string
) {
    const limit = 50;
    const cursorData = cursor ? decodeCursor(cursor) : null;
    
    const edges = await listEdges(client, {
        source_id: sourceId,
        after_id: cursorData?.lastId,
        limit: limit + 1
    });
    
    const hasMore = edges.length > limit;
    const results = hasMore ? edges.slice(0, limit) : edges;
    
    const nextCursor = hasMore
        ? encodeCursor({
            lastId: results[results.length - 1].id,
            limit,
            direction: 'forward',
            timestamp: Date.now(),
            version: 1
        })
        : null;
    
    return {
        edges: results,
        cursor: nextCursor,
        hasMore
    };
}

Example 3: REST API Endpoint with Cursors

// GET /api/nodes?cursor=...
export async function GET(request: Request) {
    const { searchParams } = new URL(request.url);
    const cursor = searchParams.get('cursor') ?? undefined;
    
    const result = await withClient(async (client) => {
        return await paginateNodes(client, cursor);
    });
    
    return Response.json({
        data: result.nodes,
        pagination: {
            cursor: result.cursor,
            hasMore: result.hasMore
        }
    });
}

Example 4: GraphQL Resolver with Cursors

const resolvers = {
    Query: {
        nodes: async (
            _parent,
            { cursor, limit },
            { client }
        ) => {
            const result = await paginateNodes(client, cursor);
            
            return {
                edges: result.nodes.map(node => ({
                    node,
                    cursor: encodeCursor({
                        lastId: node.id,
                        limit,
                        direction: 'forward',
                        timestamp: Date.now(),
                        version: 1
                    })
                })),
                pageInfo: {
                    hasNextPage: result.hasMore,
                    endCursor: result.cursor
                }
            };
        }
    }
};

Example 5: Offset-Based Cursor

import { encodeCursor, decodeCursor } from '@/lib/cursor';

async function paginateWithOffset(
    client: PoolClient,
    cursor?: string
) {
    const limit = 25;
    
    // Decode cursor or start at 0
    const cursorData = cursor ? decodeCursor(cursor) : null;
    const offset = cursorData?.offset ?? 0;
    
    // Query with offset
    const result = await client.query(
        'SELECT * FROM nodes ORDER BY id LIMIT $1 OFFSET $2',
        [limit + 1, offset]
    );
    
    const hasMore = result.rows.length > limit;
    const nodes = hasMore ? result.rows.slice(0, limit) : result.rows;
    
    // Create next cursor
    const nextCursor = hasMore
        ? encodeCursor({
            offset: offset + limit,
            limit,
            direction: 'forward',
            timestamp: Date.now(),
            version: 1
        })
        : null;
    
    return {
        nodes,
        cursor: nextCursor,
        hasMore
    };
}

Example 6: Value-Based Cursor (Custom Sort)

import { encodeCursor, decodeCursor } from '@/lib/cursor';

async function paginateByCreatedAt(
    client: PoolClient,
    cursor?: string
) {
    const limit = 50;
    const cursorData = cursor ? decodeCursor(cursor) : null;
    
    // Build query with value-based cursor
    let query = 'SELECT * FROM nodes';
    const params: any[] = [];
    
    if (cursorData?.lastValue && cursorData?.lastId) {
        query += ' WHERE (created_at, id) > ($1, $2)';
        params.push(cursorData.lastValue, cursorData.lastId);
    }
    
    query += ' ORDER BY created_at, id LIMIT $' + (params.length + 1);
    params.push(limit + 1);
    
    const result = await client.query(query, params);
    
    const hasMore = result.rows.length > limit;
    const nodes = hasMore ? result.rows.slice(0, limit) : result.rows;
    
    const nextCursor = hasMore && nodes.length > 0
        ? encodeCursor({
            lastValue: nodes[nodes.length - 1].created_at,
            lastId: nodes[nodes.length - 1].id,
            limit,
            direction: 'forward',
            timestamp: Date.now(),
            version: 1
        })
        : null;
    
    return {
        nodes,
        cursor: nextCursor,
        hasMore
    };
}

Example 7: Cursor Error Handling

function decodeCursor(cursor: string): CursorData | null {
    try {
        const decoded = JSON.parse(
            Buffer.from(cursor, 'base64').toString('utf-8')
        );
        
        // Validate structure
        if (!decoded.version || !decoded.timestamp) {
            return null;
        }
        
        // Check age (e.g., 1 hour max)
        const age = Date.now() - decoded.timestamp;
        if (age > 3600000) {
            console.warn('Cursor expired');
            return null;
        }
        
        return decoded;
    } catch (error) {
        console.error('Invalid cursor:', error);
        return null;
    }
}

async function paginateWithFallback(
    client: PoolClient,
    cursor?: string
) {
    try {
        return await paginateNodes(client, cursor);
    } catch (error) {
        if (error.message === 'Invalid cursor') {
            // Start from beginning
            return await paginateNodes(client);
        }
        throw error;
    }
}

Example 8: Test Cursor Pagination

import { test } from '@/test-utils-integration/config/database.context';
import { createResource } from '@db/graph';

test('pagination with cursors', async ({ newDbClient }) => {
    // Create test data
    const ids = await Promise.all([
        createResource(newDbClient, { p_type_namespace: 'test.node' }),
        createResource(newDbClient, { p_type_namespace: 'test.node' }),
        createResource(newDbClient, { p_type_namespace: 'test.node' })
    ]);
    
    // First page
    const page1 = await paginateNodes(newDbClient);
    expect(page1.nodes.length).toBeLessThanOrEqual(50);
    
    // Second page
    if (page1.cursor) {
        const page2 = await paginateNodes(newDbClient, page1.cursor);
        
        // Verify no overlap
        if (page2.nodes.length > 0) {
            expect(page2.nodes[0].id).toBeGreaterThan(
                page1.nodes[page1.nodes.length - 1].id
            );
        }
    }
});