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

Отправка outbox и подтверждение снимка

Сервер умеет различать новый запрос, повтор и устаревшую revision. Теперь «Блокнот» отправит сохранённый outbox и обработает ответ. Основная трудность возникает после успешного HTTP обращения: пока сеть работала, пользователь мог изменить заметку. Результат урока — подтверждение, которое удаляет только известную операцию и сохраняет более свежий локальный текст.

Полная реализация разделена между public/sync.js и public/db.js. В первой части находится сеть, во второй — короткие локальные транзакции. Автор не выполнял отправку и не измерял время ответа; состояния очереди ниже ожидаемые. Читать их следует вместе с исходниками и серверным контрактом предыдущей главы.

Снимок читается до сетевого запроса

Отправитель получает snapshot базы и выбирает операцию со state queued. На этом этапе транзакция чтения уже закончилась. Из операции удаляются локальные поля state и localVersion, остальные образуют JSON push. Сохранённый opId передаётся без замены; случайное число при каждой попытке здесь разрушило бы обработку потерянного ответа.

Запрос имеет ограничение времени через AbortSignal.timeout. Такое прекращение ожидания защищает активную вкладку от бесконечно занятого прохода, но не доказывает, что сервер ничего не применил. Сервер может завершить обработку уже после клиентского timeout. Поэтому при отказе операция остаётся, а следующий проход повторяет тот же снимок.

Fetch также не считает любой HTTP ответ успешной синхронизацией. 401 приводит к остановке, другие неподходящие статусы становятся ошибкой, только ожидаемый JSON допускается к обработке. Прикладной конфликт в нашей лаборатории приходит внутри успешного ответа со status conflict. Это осознанный контракт: клиент получил понятное решение сервера, хотя изменение заметки не принято.

Подтверждение принадлежит конкретному opId

Acknowledge создаёт readwrite транзакцию notes и outbox, повторно читает текущую операцию по noteId и сравнивает её opId с отправленным. Если прежней операции уже нет или вместо неё появилась другая, поздний ответ не должен удалять новую очередь. Такая проверка нужна даже при одном активном отправителе: будущие изменения интерфейса или восстановление состояния могут изменить локальную очередь.

Следующий фрагмент показывает начало обработчика из db.js. Sent — ранее прочитанный снимок, reply — разобранный ответ push, s — stores новой транзакции.

s.outbox.get(sent.noteId).onsuccess = event => {
  const queued = event.target.result;
  if (!queued || queued.opId !== sent.opId) return;
  s.notes.get(sent.noteId).onsuccess = e => {
    const note = e.target.result;
    if (!note) return;
    // Затем обрабатывается accepted либо conflict.
  };
};

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

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

Новая редакция переживает accepted

Если reply.status accepted, текущая операция удаляется, а note.revision получает канонический номер сервера. Затем сравниваются note.localVersion и sent.localVersion. При равенстве пользователь не изменял локальный объект после снимка; можно принять возвращённые title, body и deleted. При различии текущий локальный текст сохраняется, а operation(note) создаёт новую отправку.

Возьмём прежний пример. Отправлена localVersion один с текстом «Разобрать HTTP», в notes уже версия два с добавленным кешированием. Сервер возвращает revision один. Acknowledge сохраняет второй текст и формирует новый opId относительно server revision один. Так последняя работа пользователя не исчезает из-за того, что сеть подтвердила старую редакцию.

Все локальные изменения подтверждения выполняются в одной транзакции. Нельзя сначала удалить outbox, потом отдельно обновить revision и в третьем действии создать следующую операцию. Закрытие вкладки между шагами могло бы оставить более свежую заметку без отправки. Мы сохраняем тот же принцип, который применили к кнопке формы: связанный прикладной переход выражается одним набором изменений.

Ожидаемый результат после двух подтверждений — current note содержит второй текст, server revision повышена дважды, outbox пуст. Количество запросов не обязано совпадать с количеством нажатий кнопки: несколько поздних локальных редакций объединяются в текущем notes, пока одна операция ждёт ответ. Важно не экономить каждое сообщение, а сохранить последнюю редакцию и ясную принадлежность подтверждений.

Конфликт блокирует отправку этой заметки

Если сервер возвращает conflict, клиент сохраняет remote внутри note.conflict и меняет state операции на blocked. Notes не заменяется серверным текстом, а очередь не удаляется как успешно выполненная. В списке заметка получает подпись конфликта. Обычный проход синхронизации пропускает blocked и может продолжить другие независимые записи.

Нельзя изменить baseRevision внутри прежнего снимка и снова отправить его с тем же opId. Ledger сервера уже знает результат этой операции; изменение payload приведёт к отказу 422. Даже без ledger подобный retry скрыто переписал бы чужую работу. Новая исходная revision должна появиться после осознанного чтения и выбора пользователя, с отдельным ID намерения.

Пока конфликт открыт, человек может продолжать редактировать локальный body. SaveNote сохраняет conflict и не создаёт дополнительной очереди. Это даёт рабочее место для ручного объединения, не заставляя соглашаться с сервером перед каждой правкой. В соответствующей главе отдельно свяжем кнопки выбора с созданием нового снимка и возможным повторным конфликтом.

Граница прохода и ожидаемое состояние интерфейса

В конечном sync.js один проход отправляет не более пятидесяти снимков. Даже если человек постоянно редактирует заметку, проход не должен бесконечно занимать единственную блокировку. Затем он получает одну страницу pull. Если осталось продолжение, планировщик быстро предложит следующий проход, иначе будет ждать обычный интервал.

Строка прогресса сообщает, какая запись отправляется, а после получения страницы — cursor и размер очереди. Размер включает blocked операции, поэтому ненулевая очередь не обязательно означает активную доставку. Отдельная подпись конфликта объясняет причину. Для реального продукта такие сведения можно представить дружелюбнее, но нельзя свести все состояния к одному мигающему «синхронизация» без результата.

Если ответ потерян после принятия сервером, следующий повтор вернёт прежний accepted. Acknowledge применит его как обычно. Если локальная транзакция подтверждения не завершилась, очередь тоже останется и будет повторена безопасно. Таким образом, идемпотентный серверный контракт закрывает промежуток между серверным сохранением и браузерным подтверждением без распределённой транзакции.

Теперь отправка уважает оба состояния: известный серверу снимок и текущую локальную редакцию. Следующая глава построит обратное направление — получение журнала. Для него также потребуется атомарный переход: нельзя продвинуть cursor раньше, чем записи страницы действительно сохранены в IndexedDB.

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