Savvi Studio

Cursor Implementation Guide

Purpose: How to implement custom cursors
Last Updated: 2024-11-26

Overview

This guide shows how to implement custom cursors by extending the abstract cursor pattern.

Source Code Reference

See src/lib/cursor/abstract-cursor.ts for the base implementation.

Implementing a Custom Cursor

Basic Pattern

import { AbstractCursor } from '@/lib/cursor/abstract-cursor';

class MyCursor<T> extends AbstractCursor<T> {
  constructor(private config: Config) {
    super();
  }
  
  protected async fetchBatch(limit: number): Promise<T[]> {
    // Implement data source-specific fetching
    // Use this.token for position tracking
    return await myDataSource.fetch({
      after: this.token?.lastId,
      limit
    });
  }
  
  protected updateToken(items: T[]): void {
    // Update cursor token based on fetched items
    if (items.length > 0) {
      const last = items[items.length - 1];
      this.token = {
        lastId: last.id,
        direction: 'forward',
        limit: this.batchSize,
        timestamp: Date.now(),
        version: 1
      };
    }
  }
}

Required Methods

fetchBatch(limit: number)

Purpose: Fetch the next batch of items from your data source.

Implementation:

  • Use this.token for position tracking
  • Return array of items (empty if no more)
  • Should handle errors appropriately

updateToken(items: T[])

Purpose: Update cursor token after fetching items.

Implementation:

  • Extract position info from last item
  • Set this.token with updated position
  • Include metadata (timestamp, version, etc.)

Examples

Example 1: Database Cursor

See src/lib/db/api/QueryCursor.ts for the production PostgreSQL implementation.

Example 2: API Cursor

class ApiCursor<T> extends AbstractCursor<T> {
  constructor(
    private apiClient: ApiClient,
    private endpoint: string
  ) {
    super();
  }
  
  protected async fetchBatch(limit: number): Promise<T[]> {
    const response = await this.apiClient.get(this.endpoint, {
      params: {
        limit,
        cursor: this.token ? encodeCursor(this.token) : undefined
      }
    });
    
    return response.data.items;
  }
  
  protected updateToken(items: T[]): void {
    if (items.length > 0) {
      const last = items[items.length - 1];
      this.token = {
        lastId: last.id,
        direction: 'forward',
        limit: this.batchSize,
        timestamp: Date.now(),
        version: 1
      };
    }
  }
}

Best Practices

✅ Do

  • Keep cursors focused on pagination only
  • Use type-safe token structures
  • Handle empty results gracefully
  • Implement proper error handling
  • Document token format

❌ Don't

  • Mix transformation logic into cursors
  • Hold unnecessary state
  • Ignore error cases
  • Skip token validation
  • Make cursors data-source aware beyond what's needed

Testing Custom Cursors

describe('MyCursor', () => {
  it('fetches items correctly', async () => {
    const cursor = new MyCursor(config);
    const items = await cursor.fetch(10);
    expect(items).toHaveLength(10);
  });
  
  it('handles end of data', async () => {
    const cursor = new MyCursor(emptyConfig);
    expect(await cursor.hasMore()).toBe(false);
  });
  
  it('maintains token state', async () => {
    const cursor = new MyCursor(config);
    await cursor.fetch(10);
    const token = cursor.getToken();
    expect(token).toBeDefined();
  });
});

Next steps: See Usage Patterns for examples of using your cursor.