Получение изменений и атомарный cursor
«Блокнот» умеет отправлять свои изменения. Теперь ему нужно получать изменения другой копии приложения. Серверный pull отдаёт страницу журнала, а браузер сохраняет её и новую позицию cursor. Результат урока — получение, которое можно безопасно продолжить после закрытия вкладки и которое не затирает ещё не отправленную локальную работу.
В конечном архиве чтение HTTP находится в sync.js, применение страницы — в db.js. Автор не запускал несколько браузерных профилей и не получал фактические страницы журнала. Примеры ниже показывают ожидаемое поведение включённого контракта. Они продолжают server seq и note revision, введённые в предыдущих главах.
Страница содержит последовательность событий
Пусть server journal содержит создание «Плана урока», его изменение и создание «Списка примеров». Каждое принятое изменение имеет seq, а каждая note — собственную revision. Запрос cursor ноль начинает чтение общего журнала аккаунта. После первых двух событий следующая позиция может быть два, хотя второй заметки ещё нет в этой странице.
Сервер ограничивает страницу пятьюдесятью событиями и возвращает hasMore. Клиент не угадывает конец по размеру массива: последний page может содержать ровно пятьдесят элементов, после которых ничего нет. Явное hasMore сообщает решение серверной стороны. В нашем небольшом протоколе события никогда не удаляются, поэтому целый cursor сохраняет понятный смысл от начала истории.
Если между двумя pull сервер получил новые изменения, следующий проход прочитает их после сохранённого cursor. Не требуется удерживать серверный снимок на всё время работы браузера. Журнал append-only задаёт порядок уже принятых событий. Производственная система с компактировкой потребовала бы дополнительного контракта для устаревшей позиции; учебная лаборатория это не скрывает под обычным пустым ответом.
Cursor сохраняется вместе с заметками
Ошибочный порядок — сначала записать meta.cursor, затем по одной заметке обновить notes. Если вкладка закроется после cursor, следующий запуск начнёт чтение уже за пропущенными объектами. Сервер считает их доставленными только потому, что клиент сообщил более позднюю позицию. Исправление состоит в одной readwrite транзакции notes, outbox и meta.
Следующий фрагмент показывает форму applyPage из db.js. Развилка для каждой note раскрывается далее; page уже прочитана и разобрана вне транзакции.
return transaction(db, ['notes', 'outbox', 'meta'], 'readwrite', s => {
for (const remote of page.changes) {
s.notes.get(remote.id).onsuccess = event => {
// Чтение outbox и принятие либо сохранение конфликта.
};
}
s.meta.put({ key: 'cursor', value: page.nextCursor });
});
Порядок постановки запросов не меняет атомарного результата: complete произойдёт после запланированных запросов и их обработчиков. Внутри callback не выполняется сетевое ожидание. Если запись какого-то события приводит к abort, новый cursor тоже не сохраняется. Следующий pull повторит страницу, которую клиент ещё не завершил локально.
Повторная страница не должна повреждать уже принятые объекты. Перед изменением клиент сравнивает remote.revision и local.revision; более старое или равное каноническое состояние можно пропустить. Но курсор страницы всё равно сохраняется после общего успешного перехода. Так приемник допускает повтор доставки без необходимости помнить отдельный ID каждой прочитанной строки.
Согласованная запись заменяется канонической
Если у локальной заметки нет outbox и нет conflict, новая remote версия может стать её каноническим состоянием. Клиент копирует title, body, deleted, revision и увеличивает localVersion. Последнее действие важно для открытой формы: её поля могут относиться к прежней редакции, хотя список уже показывает новую. Сохранение такой формы должно получить понятный отказ, а не стереть полученное изменение.
Для отсутствующей заметки создаётся локальный объект из remote с localVersion один. Благодаря этому второе устройство получает запись с прежним id. Оно не создаёт ещё один UUID для копии серверного документа. Идентичность заметки проходит через сервер и все браузеры; собственные локальные версии остаются отдельным внутренним состоянием каждой базы.
Если remote.deleted true, объект остаётся в notes, но обычный список его не показывает. Это tombstone, который разберём отдельной главой. Удаление не представляется отсутствием события: иначе давно отключённый браузер не узнал бы, что привычная заметка больше не должна отображаться. Последующая revision также позволит распознать правку на основе уже удалённого состояния.
Локальная очередь запрещает скрытую замену
Если outbox существует, remote может оказаться изменением другого устройства относительно прежней revision. Безусловное notes.put(remote) уничтожило бы локальный body, который ещё нигде не подтверждён. Поэтому applyPage сохраняет remote в note.conflict, блокирует текущую операцию и оставляет локальные поля доступными для сравнения.
Этот конфликт отличается от отказа push только местом обнаружения. В обоих случаях локальное намерение опирается на серверное состояние, которое больше не является текущим. Пользователь должен увидеть варианты и принять решение. Cursor при этом можно продвинуть: само серверное событие уже сохранено в conflict, поэтому не потеряно. Блокировка относится к заметке, а не ко всему журналу.
Допустим, серверная редакция меняется ещё раз, пока конфликт открыт. Более новая remote версия обновляет сохранённое remote состояние. Прежнее локальное содержание остаётся рабочей редакцией. Кнопка разрешения будет опираться на последнюю полученную revision, однако сервер всё равно проверит её при новой отправке. Между чтением и решением может появиться ещё одно изменение, поэтому повторный конфликт остаётся допустимым исходом.
В конечном проходе push выполняется перед pull. Это помогает сначала получить подтверждения собственных известных операций, в том числе после потерянного ответа. Однако порядок не отменяет правил конфликтов: после лимита отправок в очереди могут оставаться записи, а другое устройство может изменить сервер одновременно. Надёжность задаёт проверка revision и сохранение обеих версий, а не надежда на удачную последовательность вызовов.
Как выглядит получение во второй копии
Для будущего самостоятельного сценария откройте Alice в двух независимых профилях на том же локальном адресе. Первый профиль создаёт и отправляет «План урока». У второго нет этой записи до собственного pull. После получения ожидается тот же id, серверная revision и body, а outbox остаётся пуст: чтение remote не является новой пользовательской правкой.
Затем отключите запросы во второй копии и сохраните свой вариант. В первой измените серверную заметку. Когда вторая продолжит работу, она получит конфликт при push либо pull, в зависимости от своей очереди и доступной страницы. Значение cursor будет отражать уже сохранённое событие, а локальный текст останется доступным. Никакая из этих подписей не доказывает выполненный автором эксперимент.
Если сервер потерял data и cursor оказался впереди его seq, обработчик вернёт 409. Клиент не сбрасывает cursor автоматически и не объявляет список исправным. Это повод экспортировать локальное состояние и решить, какой источник восстанавливается. Перезапуск сервера без потери файла — другая ситуация: journal и ledger продолжаются с прежних значений.
Теперь обратное направление имеет ту же дисциплину, что и отправка: HTTP находится снаружи, связанное изменение объектов и позиции выполняется атомарно, неотправленная работа сохраняется. В следующем уроке превратим обнаруженный conflict в понятное ручное сравнение и новое прикладное намерение.