Настройка собственного сервера синхронизации

Сервер синхронизации для Dexie.js обычно строится вокруг модели событийной репликации, где клиентские изменения представляют собой поток операций, а сервер выступает как агрегатор, валидатор и распределитель дельт между устройствами и пользователями. В отличие от классических CRUD-backend’ов, здесь центральной задачей становится не хранение текущего состояния, а корректная обработка истории изменений, включая оффлайн-модификации, конфликты и повторную доставку событий.

Система синхронизации строится вокруг трёх основных компонентов:

  • клиентское локальное хранилище IndexedDB (через Dexie.js)
  • транспортный слой синхронизации (HTTP/WebSocket)
  • серверный слой консенсуса и хранения дельт

Ключевая идея заключается в том, что клиент никогда не зависит от постоянного соединения. Все изменения фиксируются локально и затем отправляются на сервер в виде операций.

Операции обычно имеют форму:

  • создание записи (create)
  • обновление (update)
  • удаление (delete/tombstone)

Каждая операция содержит:

  • уникальный идентификатор
  • версию или timestamp
  • идентификатор устройства
  • payload изменений
  • базовую версию (for conflict detection)

Модель данных для синхронизации

Серверная схема редко совпадает с клиентской 1:1. Чаще используется слой журналирования:

type SyncOperation = {
  id: string;
  userId: string;
  table: string;
  type: "create" | "update" | "delete";
  payload: any;
  updatedAt: number;
  deviceId: string;
  baseVersion?: number;
};

Дополнительно хранится текущая версия состояния:

type ServerEntity = {
  id: string;
  dat a: any;
  version: number;
  deleted: boolean;
};

Важно разделять:

  • журнал операций (append-only log)
  • материализованное состояние (projection)

Такой подход упрощает восстановление и позволяет делать replay при конфликтных ситуациях.

Поток синхронизации

Типичный цикл выглядит следующим образом:

  1. Клиент фиксирует изменение в Dexie.js
  2. Запись помещается в очередь синхронизации
  3. Фоновый процесс отправляет операции на сервер
  4. Сервер валидирует операции
  5. Применяет изменения к журналу
  6. Формирует дельты для других клиентов
  7. Клиенты получают обновления и применяют их локально

Очередь оффлайн-операций

Локальная очередь является критическим компонентом. Она должна обеспечивать:

  • сохранность при перезагрузке браузера
  • идемпотентность отправки
  • повторные попытки при сбоях сети

Пример структуры очереди:

type OutboxItem = {
  op: SyncOperation;
  status: "pending" | "sent" | "failed";
  retryCount: number;
};

Отправка обычно реализуется через batching:

  • группировка операций по 20–100 штук
  • экспоненциальная задержка повторов
  • контроль порядка отправки

Серверная обработка операций

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

Алгоритм обработки:

  1. Проверка, не была ли операция уже применена
  2. Валидация пользователя и прав доступа
  3. Проверка baseVersion (если используется optimistic concurrency)
  4. Применение изменения к состоянию
  5. Запись операции в журнал
  6. Генерация событий для подписчиков

Пример Node.js обработчика:

async function applyOperation(op) {
  const exists = await db.ops.findOne({ id: op.id });
  if (exists) return;

  const entity = await db.entities.findOne({ id: op.payload.id });

  if (op.type === "update") {
    if (entity.version !== op.baseVersion) {
      return handleConflict(op, entity);
    }

    entity.data = { ...entity.data, ...op.payload.data };
    entity.version += 1;
  }

  if (op.type === "create") {
    await db.entities.insert({
      id: op.payload.id,
      data: op.payload.data,
      version: 1,
      deleted: false
    });
  }

  if (op.type === "delete") {
    entity.deleted = true;
    entity.version += 1;
  }

  await db.ops.insert(op);
  await db.entities.save(entity);
}

Конфликты и стратегии разрешения

Конфликты неизбежны при оффлайн-работе. Основные стратегии:

Last write wins (LWW)

Самый простой подход. Побеждает операция с более поздним timestamp.

Недостатки:

  • потеря данных
  • неконсистентность при разных часовых поясах

Version-based concurrency

Используется baseVersion:

  • если версия совпадает — применяем
  • если нет — конфликт

Field-level merge

Более сложная стратегия:

  • сравнение полей отдельно
  • объединение непересекающихся изменений
  • сохранение истории конфликтов

WebSocket канал синхронизации

Для минимизации задержек используется постоянное соединение:

  • сервер отправляет delta-события
  • клиент подписывается на userId channel

Пример события:

{
  "type": "delta",
  "table": "notes",
  "op": "update",
  "payload": {
    "id": "123",
    "changes": {
      "title": "new title"
    },
    "version": 5
  }
}

Клиент применяет изменения только если локальная версия меньше серверной.

Дельта-синхронизация

Вместо полной синхронизации используется механизм “since cursor”:

GET /sync?since=1700000000

Сервер возвращает:

  • список операций после cursor
  • новый cursor

Это уменьшает трафик и ускоряет синхронизацию больших наборов данных.

Tombstones и удаление данных

Удаление не означает физическое удаление записи. Используются tombstones:

{
  id: "123",
  deleted: true,
  version: 10
}

Причины:

  • предотвращение “воскрешения” удалённых данных
  • корректная синхронизация между устройствами
  • возможность аудита

Безопасность и авторизация

Сервер синхронизации обязательно включает:

  • JWT-аутентификацию
  • привязку операций к userId
  • проверку ownership данных
  • rate limiting на sync endpoint

Пример middleware:

function auth(req, res, next) {
  const token = req.headers.authorization;
  const user = verifyJWT(token);
  req.user = user;
  next();
}

Масштабирование системы

При росте нагрузки появляются узкие места:

1. Журнал операций

Решение:

  • партиционирование по userId
  • хранение в event store (Kafka / NATS / custom log)

2. Fan-out обновлений

Проблема:

  • большое количество подписчиков

Решение:

  • pub/sub брокер
  • шардирование WebSocket серверов

3. Материализованные проекции

Используются кэш-слои:

  • Redis для горячих данных
  • периодическая компрессия логов

Оптимизация клиентской синхронизации

На стороне Dexie.js важны:

  • debounce записи в outbox
  • batch apply incoming deltas
  • приоритет локальных изменений над удалёнными до подтверждения сервера

Типичная логика merge:

  1. применить локальные изменения
  2. отправить на сервер
  3. при конфликте пересчитать локальное состояние
  4. повторить отправку

Инкрементальная миграция схемы

Сервер синхронизации должен поддерживать эволюцию структуры данных:

  • versioned schema
  • трансформации операций
  • backward compatibility

Пример:

if (op.schemaVersion < 2) {
  op.payload = migrateV1toV2(op.payload);
}

Обработка повторных доставок

Сетевые сбои приводят к повторной отправке операций. Поэтому:

  • операции должны быть идемпотентными
  • сервер хранит индекс processed operation IDs
  • клиент получает подтверждение commit

Логическая модель консистентности

В распределённой синхронизации обычно используется eventual consistency:

  • локальное состояние мгновенно консистентно
  • глобальное состояние сходится со временем
  • конфликтные изменения разрешаются асинхронно

Такая модель оптимальна для оффлайн-first приложений, построенных на Dexie.js, где приоритет отдан скорости локальной работы, а не мгновенной глобальной согласованности.