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

Потеря сети в мини-приложении

Публичной ссылкой человек делится по собственному действию. Но личный план всё ещё зависит от сети: соединение может оборваться именно в момент сохранения. Результат этого урока — модель неизвестного итога и точный договор безопасного повтора. Мы создадим полный маленький модуль снимка операции; надёжный серверный реестр и транзакция описываются как обязательная граница интеграции, а не как уже готовая очередь в архиве.

Представим PUT Markdown/2 с expectedVersion 4. Сервер успел записать version 5, однако ответ потерялся. Клиент получил write_unconfirmed. Если показать «Не сохранено» и отправить новый UUID, человек не узнает о первом результате, а система может создать независимое изменение. Если сообщить «Сохранено», тоже нет подтверждения. Верное сообщение описывает наблюдение: итог пока не подтверждён, выбор сохранён в текущей форме и требуется сверка.

Один идентификатор для одной попытки

OperationId создаётся до отправки и относится к конкретному набору courseId, sessionsPerWeek и expectedVersion. Пока результат неизвестен, повтор использует тот же идентификатор и то же тело. Если пользователь хочет изменить частоту, это новый draft, который нельзя подставить в прежнюю операцию. Сервер должен отклонить использование одного UUID с другим содержимым. Иначе идемпотентность превращается в случайное правило, зависящее от того, какой запрос пришёл раньше.

Полный независимый pending-operation.js принимает уже нормализованный выбор формы. Crypto.randomUUID требует подходящего безопасного окружения; HTTPS относится к выбранному профилю mini app. Модуль не содержит токена и не записывает объект в localStorage. Снимок остаётся в памяти текущего владельца, как pending урока тринадцать. Наличие функции генерации отдельно проверяется, а отсутствие не заменяется временем и Math.random.

const courses = new Set(['javascript', 'html-css', 'markdown', 'performance']);
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

export function createPendingOperation(value, version, scope = window) {
  if (!value || !courses.has(value.courseId) ||
      !Number.isInteger(value.sessionsPerWeek) || value.sessionsPerWeek < 1 ||
      value.sessionsPerWeek > 7 || !Number.isSafeInteger(version) || version < 0) {
    throw new Error('Invalid plan snapshot');
  }
  if (typeof scope.crypto?.randomUUID !== 'function') {
    throw new Error('Secure operation identifier unavailable');
  }
  const operationId = scope.crypto.randomUUID();
  if (!uuid.test(operationId)) throw new Error('Invalid generated identifier');
  return Object.freeze({courseId: value.courseId,
    sessionsPerWeek: value.sessionsPerWeek, expectedVersion: version, operationId});
}

export function sameOperation(a, b) {
  return Boolean(a && b && a.operationId === b.operationId &&
    a.courseId === b.courseId && a.sessionsPerWeek === b.sessionsPerWeek &&
    a.expectedVersion === b.expectedVersion);
}

Этот модуль не гарантирует, что сервер обработает UUID один раз. Он только делает один клиентский снимок и защищает его верхний уровень от случайного присваивания. Все поля скалярные, поэтому выбранной freeze достаточно для нынешней модели. При расширении вложенными объектами потребуется отдельно определить копирование и сравнение. API-клиент получает снимок, а state.begin связывает его operationId с pending; эти роли не должны каждый раз генерировать собственные несовпадающие значения.

Серверный реестр

Ключ реестра состоит из ownerId и operationId. После подтверждения сессии сервер ищет прежнюю операцию этого владельца. Если она завершена и содержимое совпадает, возвращает сохранённый ответ, даже когда текущая version плана уже стала выше. Если сначала проверять expectedVersion на текущем плане, повтор успешной записи ошибочно получит конфликт вместо своего подтверждения. Порядок проверки является частью договора, а не косметикой реализации.

Новая операция атомарно сравнивает version, изменяет план и сохраняет свой окончательный результат. Реестр не должен сообщить completed до подтверждённой транзакции. Уникальный ключ защищает параллельные повторы; сравнение тела защищает повтор UUID с другими значениями. Для проекта нужны согласованный store, обработка конкурентной вставки, восстановление после сбоя и ограниченная политика хранения. Серия не предоставляет реализованную транзакцию или базу, поэтому её нельзя запускать как готовый надёжный backend.

Мы выбираем срок хранения результата двадцать четыре часа, а клиентское восстановление — в пределах этого окна. Это политика учебного проекта, не обещание Telegram. После срока сервис объясняет невозможность прежней сверки и заново читает состояние. Не храните operation records бесконечно без причины. При этом истечение реестра не отменяет version и правило владения: старый запрос не получает право безусловно перезаписать текущий план.

Сверка и повтор

В контракте архива описан GET /api/operations/{operationId} с bearer. Completed возвращает записанный plan, pending — состояние ожидания, отсутствие — 404 для этого владельца. Само отсутствие записи при одновременно находящемся в пути PUT ещё не доказывает, что запись никогда не произойдёт. Поэтому автоматический вывод «можно забыть первый запрос» неверен. Безопасное действие после согласованного ожидания — повтор того же тела и UUID по реализованному серверному договору.

При 401 потребуется новая допустимая сессия того же подтверждённого владельца. Нельзя повторять сохранённый pending после входа другого аккаунта. Токен из старой модели не переносится в новый owner. Если приложение не может доказать связь, оно сохраняет только понятное объяснение пользователю и читает текущий план заново. Постоянное восстановление pending после полного закрытия страницы не реализовано; текущий модуль отвечает лишь за открытый экземпляр приложения.

Чтение и запись повторяются по-разному

GET можно повторить с ограниченным числом попыток и паузой; ошибка чтения не меняет данные. Запись без серверной идемпотентности нельзя автоматически повторять по тому же принципу. Наш API-клиент не делает скрытый retry PUT. Для явных 400 и 409 показывается причина и сохраняется draft, для неизвестного результата сохраняется pending. Даже ошибка 500 после записи не является надёжным доказательством отсутствия изменения; итог подтверждается отдельно.

Navigator.onLine помогает показать подсказку, но не проверяет достижимость нашего API. MDN описывает эту ограниченность. Не отключайте форму навсегда только потому, что подсказка сети неверна. Обрыв запроса, серверный отказ и недоступность DNS различаются по наблюдаемому результату. Интерфейс может предложить повтор чтения и сохранить ввод, а текст должен объяснять действие, а не раскрывать технический stack trace.

Состояние после возвращения сети

Ожидаемый сценарий — сервер записал version 5, первый ответ потерялся, повтор прежнего UUID возвращает тот же подтверждённый снимок, а version не увеличивается второй раз. Другой сценарий — UUID повторён с частотой четыре и отклонён как несовпадающий. При редактировании во время ожидания подтверждение обновляет base, но более новый draft остаётся dirty. Это связывает сетевой договор с моделью предыдущих уроков и исключает потерю нового ввода.

Ни сеть, ни модуль, ни реестр, ни транзакция здесь не выполнялись. Ожидаемые сценарии не являются результатами тестирования. Для реального приложения нужно реализовать описанную серверную часть до включения безопасного повторения записи. Следующий урок выберет минимальные возможности клиентов Telegram и запасные действия, чтобы отсутствие нового метода SDK не выглядело как потеря всего пользовательского плана.

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