Индексатор Ethereum: проекция текущего состояния курса
Оглавление курса «Web3: вход, смарт-аккаунты и индексатор» · Урок 15 из 30.
Журнал хранит события, а страница обычно хочет быстро получить текущую запись курса. Проекция переводит упорядоченную каноничную историю в удобное состояние чтения. В этом уроке построим такую функцию для CourseCatalog и сравним её с getter. Результат — деактивация сохраняет отпечаток, а повторная публикация заменяет его и возвращает активность.
Предполагается нормализованный журнал без дублей и известный индексированный уровень. Учебная программа и проверки не запускались. Входы ниже являются собственными событиями модели, а не выгрузкой реальной цепочки. Контракт сохраняет прежнюю базовую семантику; новые методы и версии не добавляются.
Три перехода одного курса
До первой публикации запись имеет нулевой отпечаток и active = false. Событие Published записывает новый ненулевой отпечаток и устанавливает активность. Событие Deactivated меняет только активность. Следующая публикация снова полностью заменяет текущую пару и делает запись активной.
Событие деактивации не содержит хеша. Значит, проекция должна помнить прежнее значение из прошлой публикации. Если обработка начинается посреди истории без начального снимка, такого значения может не быть. Нельзя заменить неизвестность нулём и объявить проекцию правильной. Для полного построения нужен блок развёртывания или проверенный начальный снимок на известном уровне.
Порядок событий обязателен. Если обработать деактивацию перед более ранней публикацией, результат станет активным вместо неактивного. Сортировка по времени получения запроса даст такую ошибку при параллельной загрузке. Используем порядок блока и позиции журнала, который определён цепочкой и нормализован в предыдущих уроках.
Чистая функция перехода
Ниже модуль catalog-projection.js. Он получает одну запись и одно уже проверенное событие. Функция не обращается к RPC или базе и потому ясно показывает семантику изменения.
const ZERO_HASH = '0x' + '00'.repeat(32);
export function initialCourse(courseId) {
return { courseId, contentHash: ZERO_HASH, active: false };
}
export function applyCatalogEvent(course, event) {
if (event.courseId !== course.courseId) throw new Error('Другой курс');
if (event.eventName === 'Published') {
if (!event.contentHash || event.contentHash === ZERO_HASH) {
throw new Error('Неверная публикация');
}
return { ...course, contentHash: event.contentHash, active: true };
}
if (event.eventName === 'Deactivated') {
if (!course.active) throw new Error('История не соответствует переходу');
return { ...course, active: false };
}
throw new Error('Неизвестное событие');
}
Тип courseId в этой чистой модели выбирается одинаковым на обеих сторонах, например канонической строкой из схемы хранения. Не сравниваем строку 17 с 17n через случайное нестрогое приведение. Адаптер базы выполняет нормализацию до вызова, а функция работает с известным договором.
Отклонение деактивации неактивной записи показывает противоречивую историю для базового контракта: успешный deactivate не мог испустить такое событие из неактивного состояния. Причиной может быть неполная начальная загрузка, неправильный порядок или другой контракт. Проекция не скрывает проблему удобным присваиванием false, а останавливает продвижение и требует разбора.
Построение из последовательности
Для курса17 начинаем с initialCourse и применяем его события по порядку до выбранного уровня. После PublishedA → Deactivated ожидается A иfalse. После последующего PublishedB ожидается B иtrue. Повторная публикация B создаёт ещё одно историческое действие, но текущая пара остаётся той же. Эти результаты следуют из модели, а выполнение кода здесь не проводилось.
Для многих курсов используется карта по courseId. События обрабатываются общим порядком, а соответствующая запись обновляется отдельно. При этом максимальный обработанный блок относится к полной проекции, а не к последнему изменившемуся курсу. Курс без недавних событий тоже может иметь актуальное состояние по завершённому уровню.
Проекция может хранить дату редакционного обновления или счётчик публикаций, но такие поля являются производными. Если они нужны интерфейсу, правило вычисления фиксируется отдельно. Нельзя назвать их непосредственным storage контракта. Особенно важно не путать время блока с временем сохранения строки в базе.
Сверка с getter
Для диагностики читаем courses(17) на том же блоке, до которого построена проекция. Совпадение отпечатка и активности подтверждает согласованность этих двух наблюдений в выбранном источнике. Чтение latest после дальнейших публикаций не подходит для такой сверки: оно относится к другой временной точке.
Совпадение одного курса не доказывает полноту всех событий реестра. Неизвестный пропуск мог затронуть другой номер. Сервис хранит непрерывный checkpoint и собственную статистику загрузки. Getter полезен как независимый сравнительный путь, но не заменяет весь процесс сохранения истории.
Если сеть сменила ветвь, прежняя проекция может расходиться с текущим состоянием. Не нужно исправлять её одной записью текущего getter и продолжать старый журнал: это смешает источники разных ветвей. Сначала определяется общая точка и каноничная история, затем проекция пересобирается согласованно.
Версия вычисления
Если приложение добавит новое производное поле, старую таблицу можно пересобрать из сохранённого журнала. Поэтому формат событий и версия проектора хранятся отдельно. Изменение вычислительной функции не обязано перечитывать всю сеть, если исходные данные полны и доступны. Но источник происхождения при этом остаётся прежним.
Проекция никогда не исполняет сетевую публикацию ради согласования. Она отражает наблюдаемое состояние. Если в базе обнаружена ошибка, исправляется обработчик и производные данные, а не контракт без пользовательского намерения. Такое разделение сохраняет контроль издателя и делает индексатор безопаснее для сопровождения.
Для упражнения постройте состояния пяти событий и отдельно начните историю со второй строки без снимка. Объясните, почему неполная загрузка должна быть замечена, даже если итоговый флаг иногда выглядит правдоподобно. Ожидаемый результат — проекция становится точной моделью переходов, а не кешем случайного последнего ответа. Базовый контракт описан в обзоре Solidity.
Проекция не хранит все смыслы курса
Результат applyEvents описывает состояние базового реестра: активность и последний хеш. Он не содержит оценки качества, даты редакционного одобрения или права конкретного читателя. Добавлять такие поля можно лишь из отдельного источника и с явным обозначением происхождения.
Если последняя запись неактивна, материал по старому публичному URL может оставаться доступным согласно политике сайта. Флаг active в библиотеке и HTTP-доступность страницы не являются одним и тем же механизмом. Наш контракт не способен удалить файл со статического хостинга.
При восстановлении полезно сначала собрать чистую проекцию сетевых полей, а затем выполнить обогащение названиями и поисковыми данными. Ошибка загрузки названия не должна менять active на false. Иначе временный сбой обычного веб-ресурса станет выглядеть как действие publisher в реестре, которого на самом деле не было.