Mr. Doge

Concepts

Pagination

Keyset cursors, the listAll helper, and progressive rendering for large result sets.

List endpoints (matches.list, ai.picks.list) use keyset cursor pagination. Each page returns a cursor pointing to the next, and the cursor is drift-safe — rows added or removed between pages won't cause duplicates or gaps.

The response shape

type Paginated<T> = {
  data: T[];
  pagination: {
    nextCursor: string | null; // null when there's no more
    hasMore: boolean;
  };
};

Manual cursor walk

let cursor: string | undefined = undefined;
const all: Match[] = [];

while (true) {
  const page = await mrdoge.matches.list({
    sports: ["soccer"],
    limit: 100,
    cursor,
  });

  all.push(...page.data);
  if (!page.pagination.hasMore) break;
  cursor = page.pagination.nextCursor ?? undefined;
}

Cursors are opaque base64 strings. Don't parse, decode, or modify them — just pass them back to the next request.

The listAll helper

For most cases, use listAll instead — it walks the cursors for you:

const all = await mrdoge.matches.listAll({
  sports: ["soccer"],
  // no `cursor` — listAll manages it
});

console.log(all.length); // every match across all pages

listAll is available on both paginated resources:

const allPicks = await mrdoge.ai.picks.listAll({ date: "2026-05-18" });
const allMatches = await mrdoge.matches.listAll({ status: ["live"] });

Progressive rendering with onPage

For large result sets, render incrementally as pages arrive:

const matches: Match[] = [];

await mrdoge.matches.listAll(
  { sports: ["soccer"] },
  {
    onPage: (page, accumulated) => {
      matches.push(...page);
      render(matches); // update UI per page, don't wait for full walk
    },
  },
);

This is how the Mr. Doge mobile app renders the matches feed — the first 100 matches show up immediately, the rest stream in.

Cancellation

Pass an AbortSignal to abort an in-flight walk:

const controller = new AbortController();

setTimeout(() => controller.abort(), 5000);

try {
  const matches = await mrdoge.matches.listAll(
    { sports: ["soccer"] },
    { signal: controller.signal },
  );
} catch (err) {
  if (err.name === "AbortError") {
    console.log("Walk cancelled after 5s");
  }
}

listAll cancels mid-page — the in-flight request is aborted via fetch's native AbortSignal. Already-fetched pages are not returned. See error handling.

Page size

limit caps at 100 for matches.list and ai.picks.list. Other list methods have different caps — check the reference for each.

Oversized limit values are rejected at validation time — the server returns invalid_params.

Why keyset and not offset?

Offset pagination breaks when rows shift between requests:

Request page 1 → request page 2, but a row was added at the top → row at index 50 is now at index 51, you fetch index 50 again → duplicate.

Keyset cursors point to the last seen row, not an index. Adding rows above the cursor doesn't affect subsequent pages — no duplicates, no gaps.

Next

On this page

Pagination