Под постом набралось 5000 комментариев. Отдать их одним ответом нельзя: сервер, сеть и браузер захлебнутся. Поступают, как с книгой: читают страницами, а закладка показывает, где остановились. Это пагинация, а закладка называется курсором. Есть и вторая задача: пока вы читаете, под постом появляются новые комментарии. Спрашивать сервер каждые пять секунд (polling) неудобно, лучше услышать звонок: это подписка (subscription). В уроке вы сделаете ленту комментариев с кнопкой «Загрузить ещё» и с новыми комментариями в реальном времени.

Offset или курсор

Самый простой способ — offset: «пропусти 10, дай следующие 10» (skip и take, как в теме про Prisma). Он позволяет сразу перейти на страницу 50, но у него два недостатка. Глубокие страницы медленные: база всё равно перебирает пропущенные строки. И страницы «съезжают», если во время чтения что-то добавили: элементы дублируются или теряются.

Курсор говорит иначе: «дай 10 записей после этой закладки». Запрос идёт по индексу, не зависит от глубины и не ломается от новых записей. Платить приходится тем, что на страницу 50 сразу не перейти: только «дальше».

Offset показывает один комментарий дважды после вставки новой записи, курсор продолжает с нужного места
Offset показывает один комментарий дважды после вставки новой записи, курсор продолжает с нужного места

Relay connection: edges, node, cursor, pageInfo

В GraphQL у курсорной пагинации есть общая договорённость — connection из спецификации Relay. Поле возвращает не список, а объект с тремя частями:

  • edges — список «конвертов»; в каждом node (сам комментарий) и cursor (закладка именно на него). Конверт нужен, чтобы хранить данные о связи, а не об объекте.
  • pageInfo — навигация: hasNextPage («есть ли ещё?») и endCursor («закладка на последний элемент»).
  • totalCount — частое дополнение к спецификации: сколько всего элементов (в нашей схеме поле обязательное).

В спецификации у pageInfo есть ещё hasPreviousPage и startCursor (для движения назад), они нам не нужны. Поле Post.comments из прошлых уроков теперь возвращает connection (в учебном проекте внешних клиентов нет, в живом API так ломать схему нельзя):

graphql
type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type CommentEdge {
  cursor: String!
  node: Comment!
}

type CommentConnection {
  edges: [CommentEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type Post {
  # ...the other fields as before
  comments(first: Int = 10, after: String): CommentConnection!
}
Connection: edges с cursor и node, pageInfo с endCursor, следующий запрос идёт с after
Connection: edges с cursor и node, pageInfo с endCursor, следующий запрос идёт с after

Сервер: курсор в Prisma

Курсор для клиента — непрозрачная строка: он не должен разбирать её и догадываться о содержимом. Мы кодируем в base64url id комментария. Три правила: порядок опирается на уникальное неизменное поле (id), размер страницы ограничен, а читаем на одну запись больше, чтобы узнать про hasNextPage. Запрос Prisma тот же, что в теме про Prisma: cursor, skip: 1, take.

ts
// src/resolvers/post.ts
const encodeCursor = (id: number) =>
  Buffer.from(String(id)).toString('base64url');