Индексатор Ethereum: курсорная пагинация истории
Оглавление курса «Web3: вход, смарт-аккаунты и индексатор» · Урок 19 из 30.
История растёт, и браузер не должен получать все события сразу. Курсорная пагинация позволяет читать ограниченные порции в определённом порядке. В этом уроке закрепим снимок истории и передадим положение последней строки как cursor. Результат — появление новых публикаций не сдвигает страницы, которые читатель уже просматривает.
Предполагаются каноничный журнал, завершённая проекция и индексированный блок. База и HTTP-запросы не запускались. Начнём с истории одного курса17 в одном экземпляре. Публичные данные не требуют сессии, но параметры API всё равно проверяются и ограничиваются.
Почему offset может сдвигаться
Пагинация OFFSET20 предполагает порядок набора на момент каждого запроса. Если перед ним добавилась новая строка, следующая страница может повторить событие или пропустить другое. Для статического снимка offset допустим, однако живая история требует явного понимания изменяемости. Курсор запоминает положение относительно выбранного порядка.
Сортируем события по убыванию blockNumber и logIndex. Первую страницу ограничиваем верхним блоком snapshotH. Новые события вышеH не попадают в этот просмотр до явного обновления. Cursor содержит положение последней показанной строки и контекст снимка: сеть, экземпляр, поколение проекции и верхнюю границу.
Если произошла реорганизация и поколение истории изменилось, сервер не продолжает курсор прежней ветви молча. Он сообщает, что снимок больше не доступен в выбранной политике, и предлагает начать новое чтение. Альтернативой является хранение неизменяемых поколений достаточно долго, но это отдельная ресурсная политика сервиса.
Запрос следующей порции
Для включённого каноничного журнала одна позиция блока и logIndex задаёт порядок. Значения параметризуются, а не вставляются в SQL из строки cursor. Ниже исходник запроса будущего адаптера.
SELECT block_number, block_hash, transaction_hash, log_index,
event_name, course_id, content_hash
FROM catalog_events
WHERE environment_id = $1 AND chain_id = $2 AND contract_address = $3
AND course_id = $4 AND canonical = true AND block_number <= $5
AND (block_number, log_index) < ($6, $7)
ORDER BY block_number DESC, log_index DESC
LIMIT $8;
Для первой страницы условие последней позиции задаётся отдельным вариантом запроса без cursor или известной верхней границей. Не нужно использовать произвольное огромное число как универсальный sentinel. Максимальный pageSize ограничен сервером; клиентское значение не даёт права получить миллион строк.
Если политика хранит несколько поколений проекции, фильтр также включает выбранное поколение. В минимальной таблице предыдущих уроков canonical отражает текущую ветвь, поэтому смена поколения приводит к отказу старого cursor. Это ограничение API следует обозначить, а не выдавать текущую таблицу за неизменяемый архив снимков.
Формат курсора
Курсор является непрозрачной строкой для браузера, но сервер разбирает конкретную схему. Например, внутренний документ содержит версию, snapshotBlockHash, blockNumber и logIndex последней строки. Подпись или MAC курсора помогает обнаружить изменение параметров и связывает его с областью запроса. Она не превращает cursor в разрешение персонального доступа.
Если вместо подписи используется открытый кодированный JSON, сервер всё равно проверяет все значения и контекст. Нельзя доверять chainId, адресу и pageSize только потому, что строка прошла base64-декодирование. Кодировка скрывает видимость, но не обеспечивает подлинность. Для публичной истории главным остаётся корректность и ограничение нагрузки.
В курсор не добавляются приватные подписи входа или cookie. Персональные endpoints отдельно определяют, к какой сессии относится просмотр. Статическая история реестра может быть публичной, но это не причина перенести в тот же механизм персональный прогресс читателя без авторизации.
Стабильность и новые события
Пока посетитель читает старые страницы, интерфейс сообщает о появлении новой истории отдельно. Кнопка обновления начинает новый snapshot и сбрасывает курсор. Уже показанные строки не переставляются внезапно. Это полезно для клавиатуры и для чтения деталей транзакции.
Поле hasMore определяется по наличию дополнительной строки или выбранному договору API. Сервер может запросить pageSize+1, вернуть pageSize строк и сформировать следующий cursor по последней возвращённой. Не следует считать полную страницу доказательством наличия ещё одной: это только признак, что текущий лимит достигнут.
Если строк нет, это конец выбранного снимка или отсутствие событий в его области. Это не обязательно «курс никогда не существовал», если историческая загрузка была ограничена. Ответ сообщает границы покрытия и статус начальной загрузки, чтобы браузер не добавил необоснованную интерпретацию.
Индекс базы
Запросу нужен индекс, подходящий фильтрам сети, экземпляра, курса и порядку. Конкретная схема зависит от объёма и планов запросов. Здесь не выдумываем измеренное ускорение и не заявляем выполненный benchmark. Будущий проект рассматривает explain и эксплуатационные данные после разрешения запусков.
Числовой blockNumber не сортируется как текст: строка100 лексикографически может оказаться рядом с10 иначе, чем ожидает человек. База хранит значение числовым типом, а JSON передаёт его по схеме как десятичную строку. Адаптер cursor сохраняет точность и восстанавливает подходящий тип для SQL-параметра.
Для упражнения добавьте событие вышеsnapshotH между первой и второй страницей. Ожидается отсутствие сдвига старого просмотра. Затем смените поколение ветви: прежний cursor получает понятный отказ или обслуживается сохранённым поколением по явной политике. Эти ожидания не проверялись программой. Итог — пагинация имеет источник и снимок, а не только номер страницы. Сравнение строк SQL описано в PostgreSQL Row Comparisons.
Курсор и стабильность поиска
Представим, что первая страница вернула десять последних событий при cutoff 210. Пока читатель открывает вторую, появляется блок 211. Если все страницы сохраняют cutoff 210, новая запись не сдвинет уже показанную границу. Обновление списка с начала создаст новый срез, что можно показать отдельной кнопкой.
Строковые числа в API не должны означать строковую сортировку в хранилище. Значение 100 по лексикографическому порядку может оказаться рядом с 10 раньше 99. SQL использует числовые поля, а формат JSON нужен лишь для безопасной передачи больших значений.
Подпись курсора защищает его структуру от произвольного изменения посетителем, но не делает запрос авторизованным. Публичная история остаётся публичной; персональный список дополнительно проверяет сессию и область доступа. Токен пагинации не заменяет этот контроль и не должен содержать доступные без защиты приватные поля.