Офлайн и синхронизация: IndexedDB, очередь и LWW

Офлайн и синхронизация: IndexedDB, очередь и LWW

Интернет в телефоне есть не всегда: метро, дорога, дача. Поэтому для «КБЖУ Дневника» мы приняли жёсткий порядок. Запись сначала попадает в IndexedDB и только потом уходит на сервер. Нажатие «Добавить в дневник» сразу пишет еду в локальную базу, и интерфейс показывает её мгновенно, без ожидания сети. За доставку на сервер отвечает очередь, о ней ниже.

С хранилищем мы определились быстро. localStorage не даёт ни транзакций, ни курсоров, ни структуры, а каталог на 131 продукт вместе с записями и очередью занял бы там почти весь лимит в 5 МБ. IndexedDB решает все три проблемы: объектные store’ы, ключи и курсоры для чистки.

Шаги каждой мутации

Добавление записи у нас устроено так:

async function addEntry(entry, extra) {
  state.entries.set(entry.client_uuid, entry); // память — для рендера
  await db.putEntry(entry);                    // IndexedDB — переживёт перезагрузку
  await db.enqueue({ kind: 'entry_save', payload: entryPayload(entry), ...(extra || {}) });
  rememberRecentFood(entry.food_id);
  renderActiveView();
  scheduleSync(); // попытка отправки
}

У каждой записи есть client_uuid, который мы генерируем на устройстве через crypto.randomUUID(). Это главный идентификатор, одинаковый в локальной базе, в очереди и на сервере. Правятся и удаляются записи по тому же uuid.

Очередь хранится в отдельном object store в IndexedDB с автоинкрементным seq, и порядок в ней важен. Когда пользователь создаёт «свой» продукт, первой задачей в очередь ставится сам продукт, а второй — запись со ссылкой на него, чтобы к моменту отправки запись нашла свой продукт уже созданным.

Очередь отправляется по одной задаче

Очередь отправляется по одной задаче. Порядок «сначала продукт, потом запись» нельзя нарушать, а параллельная отправка сильно усложняет обработку ошибок. Запуск отправки делают три события: online, таймер раз в 30 секунд и вообще любое действие пользователя. Ядро цикла:

const task = items[0];
try {
  await execTask(task);
  await db.removeQueueItem(task.seq);
} catch (err) {
  if (err instanceof api.ApiError) {
    if (err.code === 'OFFLINE' || err.status === 0) { failed = true; break; } // сеть
    if (err.status === 429) { failed = true; await sleep(Math.min(err.retryAfter || 30, 60) * 1000); continue; }
    if (err.status >= 400 && err.status < 500) { // отказ навсегда
      await db.removeQueueItem(task.seq); continue; // снимаем молча
    }
    failed = true; break; // 5xx — попробуем позже
  }
  failed = true; break;
}

На каждый запрос мы даём 8 секунд через AbortController. Если истёк таймаут или сеть недоступна, задача спокойно ждёт в очереди дальше. 429 уважает retry_after из тела ответа с потолком в минуту. 4xx снимается сразу и без ретраев: сервер ответил «нет» окончательно, и долбить смысла нет.

Идемпотентность держится на client_uuid. Серверный флоу POST /api/entries/save ищет запись по паре из client_uuid и владельца и в зависимости от ответа обновляет её или создаёт. Повторная отправка не создаёт дубль: тот же uuid, тот же id. Это проверял и автотест: повторный save с другими граммами вернул тот же id, и счётчик записей не вырос. Правка идёт через тот же механизм, с тем же client_uuid в очереди.

Merge при старте и удаление через очередь

При каждом запуске приложение загружает четыре ресурса (/api/entries, /api/foods, /api/profile, /api/weights) и объединяет их с локальными данными в IndexedDB. Правило слияния простое. Локальные строки с флагом dirty всегда выигрывают, потому что ещё не отправлялись на сервер, и сервер о них просто не знает. Чистые строки сверяются по метке обновления updated_at, и в базу переписываются только изменившиеся. Профиль мы защитили отдельно: пока флаг profile_dirty не выставлен, pull не трогает локальные правки норм.

Каталог обновляется отдельным дельта-запросом. Клиент хранит метку времени последней выгрузки и передаёт её параметром since; сервер фильтрует прямо в базе — updated_at больше метки — и отдаёт только изменившиеся продукты, не больше 100 за запрос. Их клиент раскладывает в IndexedDB по id. Страница короче сотни означает, что дельта исчерпана. Первая выгрузка идёт с меткой начала эпохи и теми же страницами забирает весь каталог. После слияния клиент перечитывает индекс в памяти и снова запускает pruneFoods().

Дневник: записи видны сразу из локальной базы (светлая и тёмная темы)

Удаление тоже идёт через очередь. Локально строка исчезает сразу, а в очередь кладётся entry_delete. Сервер отвечает {"ok":true} даже для несуществующего uuid, так что повторная отправка ничего не ломает. Сразу после удаления тост предлагает «Отменить»: восстановленная запись возвращается с тем же client_uuid, и история не рвётся.

Удалённые записи возвращались

Первый серьёзный баг: запись, удалённая на сервере (например, с другого устройства), снова появлялась в списке после перезагрузки приложения, и дневник за день показывал неверную сумму.

Причина в порядке работы синхронизации. Для записей без локальных правок серверный ответ считается источником: строки, которых на сервере нет, приложение удаляет из памяти. Копии в IndexedDB это не касалось, потому что вызов deleteEntry для локальной базы просто не был написан. После перезагрузки приложение читало записи из локальной базы, и они снова попадали в список.

Причиной оказался порядок операций: uuid-ы на удаление собирались уже после state.entries.delete(u). Второй проход по словарю их уже не видел, и список на удаление из IndexedDB оставался пустым. Исправление в том, чтобы собрать список до чистки:

const staleUuids = [];
for (const [u, e] of Array.from(state.entries)) {
  if (!e.dirty && !serverUuids.has(u)) {
    state.entries.delete(u);
    staleUuids.push(u); // собираем ДО чистки словаря
  }
}
await db.putEntries(Array.from(state.entries.values()));
for (const u of staleUuids) await db.deleteEntry(u);

Отдельная защита нужна вокруг удаления. Если в очереди уже лежит entry_delete для uuid, то entry_save этого uuid уходит в сеть как обычно, но локальную строку после успешной отправки восстановление больше не возвращает.

«null» на экране и мусор в поиске

Мусор в поиске появлялся по двум разным причинам.

Буквальный «null» в списке оказался рендер-багом. В офлайн-ветке в replaceChildren(...) попадал null, и браузер печатал его как текст. Дети секции теперь собираются в массив и передаются spread’ом, без условного хвоста. Тот же дефект проявлялся ещё в двух местах, в карточке норм и в списке недавних, и исправлен там так же.

Вторая причина мусора: записи, оставшиеся в IndexedDB от ранних сборок приложения. У них нет названия, или их id начинаются с local:…. Перед каждой перезагрузкой индекса их вычищает pruneFoods(): она проходит курсором по store и удаляет всё неподходящее:

const req = t.objectStore('foods').openCursor();
req.onsuccess = () => {
  const cur = req.result;
  if (!cur) return resolve();
  const f = cur.value;
  if (!f || !f.name || String(f.id || '').startsWith('local:')) cur.delete();
  cur.continue();
};

Обошлось без миграций и экрана «почините меня»: любой запуск сам подчищает то, что осталось от старых версий.

Точка, капсула и «Сохранено»

Состояние сети видно в шапке всегда, хотя мешает оно редко. Пока всё в порядке, там просто зелёная точка. Когда очередь отправляет данные, точка пульсирует. Если последняя попытка не удалась, вместо точки появляется жёлтая капсула «N не отправлено», клик по ней повторяет отправку вручную. Совсем без сети капсула меняется на «Офлайн». Если в очереди есть записи, текст длиннее: «Офлайн · N не отправлено».

Капсула «Офлайн · 1 не отправлено» в шапке приложения (светлая и тёмная темы)

Когда сеть возвращается, очередь отправляет отложенные задачи, капсула на 2,5 секунды показывает «Сохранено» и снова сворачивается в точку. Если офлайн тянулся дольше 10 секунд, показываем один тост «Отложенные изменения отправлены» — ровно один за цикл, без спама на каждую задачу.

Осталась и мелкая полировка. Индикатор у нас — настоящая кнопка, до неё доезжает фокус с клавиатуры. aria-label меняется вместе с состоянием («Все изменения сохранены» / «Офлайн, N изменений ждут отправки» / «Синхронизация…»), а aria-live="polite" сообщает скринридеру о смене. Пульс точки гасится глобальным правилом prefers-reduced-motion.

Пользователь всегда видит то, что уже лежит в его IndexedDB; сервер узнаёт остальное, когда появится сеть.

Что дальше. Офлайн-очередь у нас работает. Чтобы приложение открывалось без сети вообще — хоть при выключенном сервере, хоть на холодном старте, — понадобится service worker. В следующей части — «Service worker, который отдаёт флоу»: почему даже /sw.js на этой платформе отдаёт флоу и какой кэш чуть не похоронил обновление.


Все записи