Перейти к содержанию

Стабильный ID операции и неизменяемый снимок

Локальная транзакция теперь сохраняет заметку вместе с outbox. Осталось определить, что находится внутри очереди. Если сервер принял запрос, а браузер не получил ответ, клиент должен повторить именно прежнее изменение. В этой главе получим неизменяемый снимок с постоянным opId и разберём, как продолжать редактирование, пока этот снимок ещё ожидает подтверждения.

Речь идёт о прикладном протоколе «Блокнота». IndexedDB хранит снимок, но смысл повторного запроса определяют клиент и сервер. Полный конечный код включён в архив; сетевые обрывы автором не воспроизводились. Ожидаемые состояния объясняют, какие наблюдения читатель должен сопоставить во время своего будущего прохода.

Запись и изменение имеют разные идентификаторы

У «Плана урока» один noteId от создания до удаления. Однако его текст может пройти несколько независимых серверных изменений: создать, дополнить, удалить. Каждое получает свой opId. Поэтому noteId отвечает на вопрос «какую заметку меняем», а opId — «какое конкретное намерение уже могло быть обработано». Замена одного другим лишила бы сервер возможности различать новое изменение и повтор старого.

Операция также содержит baseRevision, то есть серверное состояние, которое клиент считал исходным. Revision ноль подходит только для новой записи, отсутствующей на сервере. Уже согласованный текст изменяют относительно последней известной ревизии. Сервер позже проверит это условие и не примет правку на основе старого состояния молча.

LocalVersion сохраняется рядом со снимком, но не входит в wire контракт. Это внутреннее средство браузера: оно позволяет понять, менял ли человек notes после создания операции. Сервер не нуждается в номере локальных сохранений конкретного профиля. Перед отправкой sync.js исключает localVersion и state из JSON, оставляя только прикладные поля.

Создаём один снимок на заметку

Функция operation находится в db.js. Она вызывается при первом сохранении новой заметки, а затем после подтверждения прежней операции, если обнаружена более свежая редакция. Ниже приведена полная функция формирования объекта.

function operation(note) {
  return {
    noteId: note.id,
    opId: crypto.randomUUID(),
    baseRevision: note.revision,
    localVersion: note.localVersion,
    title: note.title,
    body: note.body,
    deleted: note.deleted,
    state: 'queued'
  };
}

UUID создаётся один раз перед сохранением объекта в outbox. Нельзя генерировать его внутри каждого повторного fetch: сервер увидит отдельные намерения и не сможет распознать повтор. По этой же причине opId не заменяют после перезагрузки страницы. Отправитель сначала читает сохранённую очередь, а не реконструирует сообщение из текущего body.

В этой модели снимок неизменяем с момента создания, даже если ещё не был отправлен. Это более строгое правило, чем замораживание только после первой попытки, зато ему не нужен отдельный статус, который успеет сохраниться до возможного обрыва. За небольшую дополнительную отправку промежуточного текста мы получаем простой контракт: один opId всегда соответствует одним полям.

Правка во время запроса остаётся локальной

Пусть первая операция содержит «Разобрать HTTP», localVersion равна единице. Пока запрос ожидает ответ, пользователь сохраняет «Разобрать HTTP и кеширование». Notes теперь содержит вторую редакцию с localVersion два, но очередь всё ещё хранит первую. Интерфейс показывает последнюю локальную заметку; сервер временно получает прежний снимок.

Такое различие допустимо, потому что outbox представляет неподтверждённую отправку, а notes — текущую рабочую редакцию. Ошибочным было бы при ответе сервера безусловно заменить notes первым текстом. Вместо этого подтверждение сравнит версии: если пользователь не менял заметку, можно принять канонический ответ; если менял, сохраняют его редакцию и создают следующую операцию с новой baseRevision.

Ожидаемая последовательность при двух локальных сохранениях выглядит так. Сервер принимает op-A относительно revision ноль и возвращает revision один. Клиент удаляет op-A, обновляет подтверждённую revision в notes и формирует op-B с последним текстом относительно единицы. Op-B получает новый ID, поскольку это новое намерение. После второго подтверждения очередь пустеет.

Почему не складывать сразу две операции относительно нуля? Вторая уже окажется устаревшей после принятия первой. Можно построить очередь зависимостей и вычислять bases после каждого результата, но это усложнит контракт. Для небольших заметок выбран один текущий снимок, а следующая версия создаётся после подтверждения. Так связь между известной revision и отправкой остаётся явной.

Потерянный ответ не даёт новой операции

Теперь изменим условие: сервер принял op-A, но соединение оборвалось до получения ответа. В notes revision всё ещё ноль, в outbox остался op-A. Повторная попытка отправляет тот же JSON. Серверный ledger по opId должен вернуть прежнее подтверждение с revision один, не увеличивая revision снова и не добавляя ещё одно событие журнала.

Это свойство часто называют идемпотентностью прикладной операции. Оно требует хранения результата на сервере, а не только случайного идентификатора в клиенте. Если сервер забыл прежний ID после перезапуска, повтор снова станет неопределённым. Поэтому в конечной лаборатории ledger сохраняется в server data вместе с изменением заметки и её журналом; правила безопасной очистки ledger здесь не реализованы.

Сервер сравнивает и содержимое запроса. Если тот же opId приходит с другим title, body или baseRevision, возвращается ошибка. Такой отказ обнаруживает нарушение протокола: клиент переиспользовал имя уже существующего намерения. Если сервер вместо отказа применит новый текст, две копии клиента смогут получать разные результаты для одного идентификатора и перестанут понимать свои подтверждения.

В нашем сервере fingerprint вычисляется из JSON объекта. Конечный клиент создаёт поля в стабильном порядке и повторяет сохранённый снимок. Для общего API, которым пользуются разные библиотеки, понадобится каноническое представление полей, поскольку порядок ключей не должен определять прикладную идентичность. Это ограничение учебной реализации нужно учитывать при расширении числа клиентов.

Несколько повторов и одно подтверждение

Обрыв может повториться несколько раз. До получения пригодного ответа снимок остаётся в outbox; счётчик retry не меняет его payload. При перезагрузке отправитель продолжит с тем же opId. Если пользователь за это время изменил локальную запись, эти изменения сохраняются в notes и будут преобразованы в новую операцию только после разрешения текущего состояния.

Конфликт имеет другой смысл, чем временная ошибка сети. Сервер может ответить, что baseRevision устарела. Тогда прежняя операция становится blocked, а обе версии сохраняются для выбора. Повторение того же заблокированного намерения не исправит исходную ревизию. Понадобится явное решение, которое создаст новый ID на основе прочитанной серверной revision.

Теперь у очереди есть точная единица работы: сохранённый неизменяемый снимок, независимый от текущего состояния формы. Это позволяет пережить закрытие вкладки, несколько повторов и редактирование во время отправки без подмены уже существующего намерения. Следующая глава покажет серверную половину правила: проверку revision, ledger ответов и последовательный журнал изменений.

Оглавление серии