# ADR-002: Паттерн офлайн-очереди **Дата:** 2026-03-20 **Статус:** Accepted **Автор:** Architect Agent (Kin pipeline, BATON-001) --- ## Контекст Baton — приложение экстренного сигнала. Критичное требование: сигнал **не должен быть потерян**, если пользователь нажал кнопку в момент отсутствия сети (тоннель, слабый сигнал, офлайн). Сигнал должен быть сохранён локально и доставлен на сервер, как только соединение восстановится. Аудитория: 300-400 пользователей, разные браузеры и платформы (Android Chrome, iOS Safari, Desktop Firefox/Chrome). --- ## Варианты ### Вариант A: IndexedDB outbox + BackgroundSync + online event fallback **Схема:** 1. Кнопка нажата → немедленная попытка `fetch('/signal')` 2. Ошибка или offline → запись в IndexedDB outbox 3. Trigger 1: `window.addEventListener('online', flushOutbox)` — main thread, все браузеры 4. Trigger 2: SW регистрирует `registration.sync.register('flush-outbox')` — Chromium только 5. SW обрабатывает `sync` event → читает IndexedDB → flush **Плюсы:** - IndexedDB персистентна: не очищается при закрытии вкладки (в отличие от memory) - IndexedDB доступна как из main thread, так и из Service Worker → общее хранилище - BackgroundSync: браузер сам управляет повтором (Chrome может сработать даже при закрытой вкладке) - Dual trigger страхует: если BackgroundSync не сработал → online event - Квота IndexedDB: обычно GB (не 5 MB как localStorage) - Соответствует принятому решению #1006 **Минусы:** - IndexedDB API громоздкий → нужна обёртка (`idb` библиотека, ~1.9 KB gzip) или написать самому - BackgroundSync поддерживается только 78.75% браузеров (caniuse, март 2026) — Safari и Firefox не поддерживают - Сложнее отлаживать в DevTools, чем localStorage ### Вариант B: localStorage queue + online event listener **Схема:** 1. Кнопка нажата → попытка отправки 2. Ошибка → `JSON.stringify` очереди в `localStorage` 3. `window.addEventListener('online', flush)` → отправить всё из очереди **Плюсы:** - Самый простой вариант (~20 строк) - Нет зависимостей - Синхронный API — легко читать и писать **Минусы:** - iOS Safari приватный режим: `localStorage.setItem()` бросает `SecurityError` → нужен try/catch → если упал, сигнал теряется - localStorage недоступна в Service Worker контексте → нельзя flush из SW - Лимит: 5 MB (достаточно, но IndexedDB надёжнее) - Нет BackgroundSync — только один trigger (online event) ### Вариант C: Cache API + Request replay в Service Worker **Схема:** - SW перехватывает failed POST запросы → сохраняет в Cache API - При появлении сети → повторяет запросы из кэша **Плюсы:** - Нативная интеграция с SW fetch event - Нет отдельного хранилища **Минусы:** - Cache API спроектирован для Response (кэш ответов), не для Request replay - Нет гарантий персистентности очереди (Cache может быть очищен браузером без предупреждения) - Workbox Background Sync внутри использует IndexedDB, не Cache API (косвенное свидетельство) - Нестандартное использование API → неожиданное поведение в edge cases --- ## Решение **Выбран Вариант A: IndexedDB outbox + BackgroundSync + online event fallback** Соответствует зафиксированному решению #1006. --- ## Обоснование 1. **IndexedDB — единственный вариант, доступный и в main thread, и в Service Worker.** Это критично: flush может произойти как из app.js (online event), так и из sw.js (BackgroundSync event). Общее хранилище исключает дублирование кода. 2. **Dual trigger — страховочная сеть.** BackgroundSync не обязателен для работы (Safari/Firefox — 21% юзеров обойдутся без него), но является бонусом для Chrome пользователей: flush случится даже при закрытой вкладке. 3. **Вариант B отклонён** из-за проблемы iOS Safari приватного режима (решение #1003: не понижать явные требования) и недоступности в SW контексте. При том что приложение экстренного сигнала должно работать без потерь на iOS. 4. **Вариант C отклонён** как злоупотребление API не по назначению. Cache API не гарантирует персистентность POST запросов. 5. **Размер зависимости `idb`:** ~1.9 KB gzip — приемлемо. Альтернатива: написать минимальную обёртку (~30 строк) для трёх операций (add, getAll, delete). --- ## Последствия **При реализации учесть:** 1. **iOS Safari приватный режим:** `localStorage` недоступен → переход на IndexedDB не помогает (IndexedDB тоже может быть ограничен). Нужен graceful degradation: попытка записи в IndexedDB → при ошибке сигнал отправляется только online или теряется с явным UI-предупреждением. 2. **Idempotency ключ:** `id: "${Date.now()}-${Math.random().toString(36).slice(2)}"` — уникальный ключ каждой записи в outbox. Защита от дубликатов при повторных попытках. Бэкенд должен игнорировать дубликаты (INSERT OR IGNORE по `client_id`). 3. **Лимит попыток:** `attempts` в outbox entry. После 3-5 неудачных попыток — показать пользователю UI-предупреждение. Не flush бесконечно. 4. **SW lifecycle:** при обновлении SW (новая версия) старый SW активен до закрытия всех вкладок. Flush в процессе обновления → запрос может быть потерян. Idempotency ключ и `INSERT OR IGNORE` на бэкенде защищают от дубликатов. 5. **Background Sync — проверка перед регистрацией:** ```javascript if ('serviceWorker' in navigator && 'SyncManager' in window) { const reg = await navigator.serviceWorker.ready; await reg.sync.register('flush-outbox'); } else { if (navigator.onLine) flushOutbox(); // немедленный fallback } ``` 6. **Решение #1001 требует обновления:** фактический охват Background Sync — 78.75% (21% без поддержки), не 85% как было зафиксировано. Ручной fallback — не «опциональный», а обязательный элемент архитектуры.