Статья · Личный блог

Local-first на практике: CRDT, IndexedDB и синхронизация без сервера

Как Slate хранит заметки локально и сводит правки с разных устройств — без конфликтов и без бэкенда.

Slate — мой редактор заметок, который обязан работать в самолёте, в метро и при упавшем бэкенде. Разбираю архитектуру local-first: лог операций вместо состояния, CRDT для слияния правок и IndexedDB как основное хранилище.

Почему local-first

Классическое веб-приложение — это тонкий клиент над REST API: источник истины живёт на сервере, у клиента — кэш в памяти. Стоит сети моргнуть, и интерфейс рассыпается: спиннеры, ретраи, «попробуйте позже». Для заметок это особенно обидно — мысль не ждёт, пока поднимется бэкенд.

Local-first переворачивает схему: источник истины — на устройстве, а сервер, если он вообще есть, — лишь транспорт для обмена изменениями. Открыть заметку — это чтение с диска, сохранить — запись на диск. Сеть исчезает из критического пути.

Оффлайн — не деградация, а нормальный режим работы. Синхронизация — фоновый процесс, о котором пользователь не думает.

События, а не состояние

Первое решение определяет всё остальное: хранить не документ, а лог операций. Каждая правка — маленькая запись: что изменилось, кто автор, логический таймстемп. Текущее состояние — это свёртка лога.

Такой формат даёт бесплатную историю изменений и undo, но главное — слияние. Чтобы объединить две реплики, достаточно объединить два лога и свернуть заново. Не нужно сравнивать документы и гадать, чья версия «правильнее».

CRDT без магии

CRDT звучит академично, но для большинства приложений хватает двух примитивов: LWW-регистра («последняя запись побеждает») для скалярных полей и набора с tombstone для коллекций — удалённый элемент не стирается, а помечается.

// LWW-регистр: конфликт решается детерминированно
function merge(a, b) {
  if (a.ts !== b.ts) return a.ts > b.ts ? a : b;
  return a.actor > b.actor ? a : b; // тай-брейк
}

Две реплики, применив одни и те же операции в любом порядке, придут к одинаковому состоянию — без сервера-арбитра. Для совместного редактирования текста этих примитивов уже мало: там нужен полноценный sequence CRDT. Честный совет — берите Yjs или Automerge: свой RGA вы будете отлаживать год.

IndexedDB как хранилище

На вебе для local-first есть ровно одно подходящее хранилище — IndexedDB. localStorage не годится: синхронный, лимит в считанные мегабайты, только строки. В Slate два стора: ops — лог операций, источник истины, и snapshots — свёртки для быстрого старта.

const db = await openDB("slate", 1, {
  upgrade(db) {
    const ops = db.createObjectStore("ops", { keyPath: "id" });
    ops.createIndex("byDoc", "docId");
    db.createObjectStore("snapshots", { keyPath: "docId" });
  },
});
await db.put("ops", op); // лог — источник истины

Ловушки, на которые я наступил:

Синхронизация без сервера

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

Транспорт при этом взаимозаменяем: WebRTC напрямую между устройствами в одной сети, дешёвый relay-сервер, который видит только зашифрованные блобы, хоть файл на флешке. Merge идемпотентен и коммутативен — операции можно получить дважды и в любом порядке, итог одинаковый. Целый класс багов синхронизации снимается на уровне модели данных.

Что в итоге

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