A cursor-pagination endpoint. Client reports inconsistent page sizes — sometimes 19, sometimes 20, sometimes 21. Mesrai traced it to a missing `skip: 1` on the cursor branch.
The vulnerable diff
// api/feed.ts
feedRouter.get("/feed", async (req, res) => {
const cursor = req.query.cursor as string | undefined;
// BUG: cursor is inclusive in Prisma — first row repeats from previous page
const rows = await db.feed.findMany({
orderBy: { id: "desc" },
take: 20,
cursor: cursor ? { id: cursor } : undefined,
});
res.json({ rows, nextCursor: rows.at(-1)?.id });
});What is wrong
Prisma's `cursor` option includes the cursor row in the result. To paginate, you usually want the cursor row excluded — `skip: 1` when a cursor is provided. The bug is invisible if the client always shows page items uniquely (just looks like a duplicate); visible when the client dedupes or counts.
The attack
Symptom:
Page 1: 20 rows, cursor = row20.id
Page 2 (cursor=row20.id): returns rows [20, 21, ..., 39] — row 20 repeats!
Client dedupes → page shows 19 items.Off-by-one in the other direction (skip too many) gives gaps. Both look like inconsistent pagination.
Mesrai's review comment
mesraipilot · Bot · reviewed 30 sec ago
[mesrai] [code-review] [Logic] [Pagination] [medium]
Prisma `cursor` is inclusive. Pagination needs `skip: 1` on the
cursor branch to exclude the cursor row:
const rows = await db.feed.findMany({
orderBy: { id: "desc" },
take: 20,
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
});
Also consider the next-page detection pattern that uses take: limit+1:
take: 21, // request one extra
const hasMore = rows.length > 20;
const items = rows.slice(0, 20);
const nextCursor = hasMore ? items.at(-1)?.id : null;
Cleaner than a separate count query; the extra row is the boundary
indicator.The fix
// api/feed.ts — fixed
feedRouter.get("/feed", async (req, res) => {
const cursor = req.query.cursor as string | undefined;
const rows = await db.feed.findMany({
orderBy: { id: "desc" },
take: 21, // +1 for hasMore
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
});
const hasMore = rows.length > 20;
const items = rows.slice(0, 20);
res.json({ rows: items, nextCursor: hasMore ? items.at(-1)?.id : null });
});Skip the cursor row. Take one extra to detect hasMore. Consistent page size, no duplicates, no extra count query.
Why human review missed it
Cursor pagination off-by-one is easy to miss in unit tests because they often use small fixtures that don't trigger the duplicate. The bug shows up only on page 2+. Mesrai catches every `cursor:` Prisma call without `skip: 1` and recommends the standard pattern.
Related rules + further reading
Mesrai rule pack: logic/cursor-pagination-skip — flags Prisma cursor calls without skip:1 on the cursor branch.
Prisma docs: Pagination — Cursor-based.
Common in feed/timeline implementations.
Takeaway
Skip the cursor row. Take +1 for hasMore. Consistent pages. Mesrai catches the off-by-one.