Допустим, у нас есть бекенд, который умеет хранить какие-то сущности. И у него есть апи для создания, чтения, изменения и удаления этих сущностей, сокращенно CRUD. Но апи на сервере, а пользователь забрался куда-то глубоко и половина запросов валится по таймауту. Не хотелось бы показывать бесконечный прелоадер и вообще блокировать действия пользователя. Offline first предполагает загрузку приложения из кеша, так может быть и данные брать оттуда?

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

  1. Если Id сущности генерится на сервере, в базе, то как жить без Id, пока сервер недоступен?

  2. При синхронизации с сервером, как отличать сущности созданные на клиенте от удаленных на сервере другим пользователем?

  3. Как разрешать конфликты?

Идентификация

Идентификатор нужен, так что будем его создавать сами. Для этого прекрасно подходит GUID или `+new Date()` с некоторыми оговорками. Только когда придет ответ от сервера с настоящим Id, надо везде его заменить. Если на эту свежесозданную сущность уже ссылаются другие, то эти ссылки тоже надо поправить.

Синхронизация

Изобретать велосипед не будем, посмотрим на репликацию баз данных. Смотреть на нее можно бесконечно, как на пожар, но вкратце, один из вариантов выглядит так: помимо сохранения сущности в IndexedDB, будем писать лог изменений: [time, 'update', Id=3, Name='Иван'], [time, 'create', Name='Иван', Surname='Петров'], [time, 'delete', Id=3]...

При получении с сервера свежих данных, мы можем сравнить их с существующими и вычислить аналогичный лог изменений на сервере. После этого объединить логи и отправить необходимые изменения на сервер, если интернет еще не пропал, и только после этого записывать изменения в IndexedDB. И не забыть обновить Id.

Конфликты

Конфликт - это не спор между двумя пользователями, чья точка зрения правильная, и поочередное исправление одной и той же записи до посинения. А вот ситуация, когда пользователи довольны и видят каждый свою версию - конфликт, а конкретно неконсистентность. Непрерывной консистентности в веб приложениях достичь несложно - при каждом изменении блокировать всех клиентов, кого это изменение касается, пока они все не подтвердят получение. Это никому не нравится, поэтому приходится идти на компромис: ладно, пусть иногда пользователи видят разное, но если все замрут и перестанут вносить изменения, то через некоторое время у всех будет одно и то же. Для этого придумали термин Eventual Consistency.

Оказалось, что ее можно достичь незаметно для пользователя, но не так просто. Можно использовать Operational Transformations (OT) или Conflict-free Replicated Data Types (CRDT) но для них придется довольно радикально поменять формат обмена данных с сервером. Если это невозможно, то можно на коленке сделать CRDT на минималках: добавить в сущность поле UpdatedAt и записывать в него время последнего изменения. Это не избавит от всех конфликтов, но снизит их количество на порядок.

Итак, при объединении двух логов группируем их по Id сущности и дальше работаем в каждой группе отдельно. Если в одном из логов есть операция удаления, то оставляем только ее. Пользователь, удаливший запись наверняка имел на это веские основания и не хотел бы, чтобы запись вдруг возродилась. Никто не любит зомби кроме зомби. Если в одном из логов есть операция создания сущности, то в другом логе должно быть пусто, ведь Id уникальный, ага. С изменениями немного сложнее - нужно посмотреть на время последнего изменения сущности в каждом из логов. Сравнить. И выбрать тот лог, в который изменение пришло позднее. Last write win. Проверим Eventual Consistency: если все пользователи перестанут вносить изменения и подключатся к интернету, у всех будут сущности последней версии. Отлично.

function mergeLogs(left, right){
    const ids = new Set([
        ...left.map(x => x.id),
        ...right.map(x => x.id)
    ]);
    return [...ids].map(id => mergeIdLogs(
        left.filter(x => x.id == id),
        right.filter(x => x.id ==id)
    )).reduce((a,b) => ({
        left: [...a.left, ...b.left],
        right: [...a.right, ...b.right]
    }), {left: [], right: []});
}

function mergeIdLogs(left,right){
    const isWin = log => log.some(x => ['create','delete'].includes(x.type));
    const getMaxUpdate = log => Math.max(...log.map(x => +x.updatedAt));

    if (isWin(left))
        return {left: [], right: left};
    if (isWin(right))
        return {left: right, right: []};
    if (getMaxUpdate(left) > getMaxUpdate(right))
        return {left: [], right: left};
    else
        return {left: right, right: []};
}

Эпилог

Реализации не будет, потому что в каждом конкретном случае есть свой дьявол в деталях, да и реализовывать тут по большому счету нечего - генерацию идентификатора и запись в indexedDB.

Конечно, CRDT или OT будут лучше, но если нужно сделать быстро, а на бекенд не пускают, то сгодится и это поделие.