HatchedDocs
Concepts

Pagination

Hatched ships two pagination envelopes — cursor (canonical for new endpoints) and offset (legacy). This page documents both shapes, the SDK helpers that walk them, and how to add cursor pagination to a server-side route.

Every Hatched list endpoint returns one of two envelopes. New endpoints use cursor pagination; older endpoints still return the offset shape. The SDK has helpers for both — application code doesn't need to care which shape it's reading.

Cursor pagination (canonical)

The canonical envelope — emitted by the server-side cursorPaginate helper, the shape new endpoints should adopt:

{
  "data": [ /* page rows */ ],
  "pagination": {
    "nextCursor": "eyJrIjoiMjAyNi0wNS0yNVQxODoxMjozNFoiLCJpZCI6IjkyZS4uLn0",
    "hasMore": true,
    "limit": 50
  }
}

The cursor is opaque — treat it as a JWT-ish blob. It encodes the sort key + tie-breaker id of the last row in the current page, so the next call returns the rows immediately after it.

The cursor resources shipped today do not yet return this envelope. Each exposes the cursor at the top level on its own data key: notifications.list{ notifications, nextCursor, unreadCount, pausedUntil }, feed.teamEvents.list{ events, next_cursor } (snake_case), webhooks.deliveries{ data, nextCursor }. The { data, pagination } envelope is what cursorPaginate produces for new server routes; until a resource is wired to it, adapt the resource's real shape into the paginateCursor fetcher (see Walking pages from the SDK).

Why cursor. Stable under concurrent writes (no missed/duplicated rows when new data lands between page calls), supports unbounded streams (no upper bound from total reaching memory limits), and aligns with keyset indexing so latency stays flat as data grows.

Request

Pass cursor and limit query params:

GET /api/v1/widget/notifications?cursor=eyJrIjoiMjAy...&limit=100
  • cursor (optional) — omit on the first request; pass the previous response's nextCursor (the canonical envelope nests it under pagination.nextCursor) on subsequent requests.
  • limit (optional) — page size, clamped server-side. The cursorPaginate helper defaults to 50 and clamps to [1, 200].

Response invariants

  • data.length is always ≤ pagination.limit.
  • pagination.hasMore === (pagination.nextCursor !== null).
  • When nextCursor === null, the cursor chain is exhausted.

Offset pagination (legacy)

The envelope:

{
  "data": [ /* page rows */ ],
  "meta": { "total": 1284, "page": 1, "limit": 50 }
}

Why we still ship it. Several endpoints already exposed this shape publicly. The contract is preserved; new endpoints just don't add to the surface.

Request

Pass page and limit:

GET /api/v1/buddies?page=2&limit=100
  • page (1-indexed, default 1).
  • limit (default 20, max 100). A limit above 100 is rejected with 422 validation_failed.

Response invariants

  • meta.total is the authoritative termination signal — the SDK paginator stops when (page-1) * limit + data.length >= total. Don't infer "this is the last page" from data.length < limit: filtered endpoints (status filter, soft deletes, audience scoping) routinely return a short page in the middle of the stream.
  • data.length may be 0 on the final page (when total is an exact multiple of limit).

Walking pages from the SDK

The SDK exposes two pairs of helpers. Each returns an AsyncIterableIterator<T> so you can for await … break to stop early without fetching the next page.

Cursor

paginateCursor / collectCursor expect the canonical { data, pagination: { nextCursor } } envelope. Today's cursor resources return the cursor at the top level on their own data key, so map the resource's real shape into that envelope inside the fetcher:

import { paginateCursor, collectCursor } from '@hatched/sdk-js';

for await (const note of paginateCursor((cursor) =>
  hatched.notifications.list({ cursor, limit: 100 }).then((page) => ({
    data: page.notifications,
    pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 100 },
  })),
)) {
  if (note.id === target) break;
}

// or buffer everything
const all = await collectCursor((cursor) =>
  hatched.notifications.list({ cursor, limit: 100 }).then((page) => ({
    data: page.notifications,
    pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 100 },
  })),
);

Offset

import { paginate, collect } from '@hatched/sdk-js';

for await (const buddy of paginate(
  (page) => hatched.buddies.list({ page, limit: 100, status: 'active' }),
)) {
  console.log(buddy.id);
}

const active = await collect(
  (page) => hatched.buddies.list({ page, limit: 100, status: 'active' }),
);

Both pairs accept a maxPages runaway guard and an AbortSignal:

const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
const bounded = await collectCursor(
  (cursor) =>
    hatched.notifications.list({ cursor, signal: ac.signal }).then((page) => ({
      data: page.notifications,
      pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 50 },
    })),
  { signal: ac.signal, maxPages: 20 },
);

Server-side: adding cursor pagination to a new endpoint

For new endpoints in apps/api, use the shared cursor helper instead of writing keyset logic by hand:

import { cursorPaginate } from '@/common/pagination/cursor';

@Get('items')
async list(@Query() query: CursorListItemsDto) {
  const qb = this.itemsRepo
    .createQueryBuilder('item')
    .where('item.customer_id = :customerId', { customerId });

  return cursorPaginate({
    qb,
    sortColumn: 'item.created_at',
    tieBreakerColumn: 'item.id',
    direction: 'DESC',
    limit: query.limit,
    cursor: query.cursor,
  });
}

The helper produces the canonical envelope, applies a keyset filter so the SQL plan stays on an index, and uses the row's createdAt + id as the cursor tuple by default. Override toCursor for endpoints sorted by a different column.

Choosing a sort key

The sort column must be monotonic within the result set — usually created_at. The tie-breaker is always a unique id (UUID). With those two together, two rows can never tie, so the keyset predicate is exact and the cursor never drops or duplicates rows.

When to migrate an offset endpoint to cursor

Existing offset endpoints stay as-is. Migrate when one of these is true:

  • The list grows unbounded (events, operations, ledger entries) and OFFSET N LIMIT M starts to scan a problematic number of pages.
  • Consumers report missed/duplicated rows under concurrent writes.
  • You need to expose a streaming API on top of the same data.

When you migrate: add the cursor query param alongside page; serve the cursor envelope when cursor is present, the offset envelope otherwise. Consumers pick the matching SDK helper — paginateCursor / collectCursor for the new shape, paginate / collect for legacy offset. There is no envelope autodetection on the client; using the wrong helper either misses rows or loops forever, so the two pairs are explicit by design.

Companion docs