Многопользовательский чат с каналами и инкрементальной синхронизацией данных (Sync Engine).
┌─────────────────────────────────────────────────────────────────┐
│ SYNC5 CHAT │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ Web Client │◄───────►│ Server │ │
│ │ (React + Vite) │ REST │ (Hono + Drizzle) │ │
│ │ │ API │ │ │
│ │ ┌────────────┐ │ │ ┌────────────────────────┐ │ │
│ │ │ Dexie │ │◄───────►│ │ SQLite Database │ │ │
│ │ │ (IndexDB) │ │ WebSocket │ │ │ │
│ │ └────────────┘ │ │ │ - Users │ │ │
│ │ │ │ │ - Channels │ │ │
│ │ ┌────────────┐ │ │ │ - Messages │ │ │
│ │ │ TanStack │ │ │ │ - server_version │ │ │
│ │ │ Query │ │ │ └────────────────────────┘ │ │
│ │ └────────────┘ │ │ │ │
│ └──────────────────┘ └──────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
sync5/
├── apps/
│ ├── server/ # Hono API сервер
│ │ ├── src/
│ │ │ ├── db/ # Drizzle ORM схема и подключение
│ │ │ ├── lib/ # Утилиты, auth, websocket
│ │ │ └── routes/ # API роуты (channels, messages, sync)
│ │ └── data/ # SQLite база данных
│ └── web/ # React клиент
│ └── src/
│ ├── components/ # UI компоненты
│ ├── hooks/ # React хуки (WebSocket)
│ ├── lib/ # DB, auth client
│ ├── pages/ # Страницы (Login, Register)
│ └── stores/ # Zustand stores (sync)
└── packages/
├── eslint-config/ # Общая ESLint конфигурация
└── typescript-config/ # Общие tsconfig
# Установка зависимостей
pnpm install
# Инициализация базы данных
pnpm --filter @sync5/server db:push
# Запуск в режиме разработки
pnpm dev- Клиент: http://localhost:4001
- Сервер API: http://localhost:4000
- Swagger UI: http://localhost:4000/api/docs
- Hono — легковесный веб-фреймворк
- Drizzle ORM — типобезопасный ORM для SQLite
- better-auth — аутентификация (email/password)
- Zod OpenAPI — генерация Swagger документации
- WebSocket (ws) — real-time уведомления
- React 19 + Vite
- TanStack Query — управление серверным состоянием
- Dexie — IndexedDB обёртка для локального хранения
- Zustand — глобальное состояние (sync status)
- TailwindCSS — стилизация
- Vite PWA — offline поддержка
Sync Engine решает проблему синхронизации данных между сервером и множеством клиентов с поддержкой:
- Offline-first — приложение работает без интернета
- Optimistic UI — мгновенная реакция на действия пользователя
- Инкрементальная синхронизация — передача только изменённых данных
- Conflict resolution — разрешение конфликтов "последняя запись побеждает"
Глобальный инкрементальный счётчик на сервере:
CREATE TABLE server_version (
id INTEGER PRIMARY KEY,
version INTEGER NOT NULL DEFAULT 0
);Логика:
- При каждой CRUD операции счётчик увеличивается на 1
- Текущее значение записывается в поле
last_write_server_versionизменённой сущности
// Пример создания сообщения
const serverVer = incrementServerVersion(); // 50123 -> 50124
await db.insert(messages).values({
id: messageId,
content: "Hello!",
lastWriteServerVersion: serverVer, // 50124
// ...
});GET /api/sync?since=50100&channelId=ch123
Ответ:
{
"toVersion": 50124,
"changes": [
{
"entity": "message",
"recordId": "msg456",
"data": {
"id": "msg456",
"content": "Hello!",
"lastWriteServerVersion": 50124
},
"deleted": false
},
{
"entity": "channel",
"recordId": "ch789",
"data": { ... },
"deleted": true
}
]
}// lib/db.ts
class SyncDatabase extends Dexie {
channels!: Table<LocalChannel>;
messages!: Table<LocalMessage>;
syncMetadata!: Table<SyncMetadata>;
constructor() {
super("sync5-chat");
this.version(1).stores({
channels: "id, lastWriteServerVersion, deleted",
messages: "id, channelId, lastWriteServerVersion, deleted, pendingSync",
syncMetadata: "id",
});
}
}┌─────────────────────────────────────────────────────────────────┐
│ SYNC FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Клиент запрашивает: GET /sync?since={lastSyncedVersion} │
│ │
│ 2. Сервер возвращает все записи где: │
│ last_write_server_version > since │
│ │
│ 3. Клиент применяет изменения к локальной БД: │
│ - Если запись новая → INSERT │
│ - Если serverVersion > localVersion → UPDATE │
│ - Если deleted=true → помечаем удалённой │
│ │
│ 4. Клиент сохраняет toVersion как lastSyncedVersion │
│ │
└─────────────────────────────────────────────────────────────────┘
// stores/sync.ts
async function sync(channelId?: string) {
const lastSyncedVersion = await getLastSyncedVersion();
const response = await fetch(`/api/sync?since=${lastSyncedVersion}`);
const { toVersion, changes } = await response.json();
// Применяем изменения
await applySyncChanges(changes);
// Сохраняем версию
await setLastSyncedVersion(toVersion);
}При отправке сообщения:
async function handleSend() {
const tempId = `temp-${Date.now()}`;
// 1. Немедленно добавляем в локальную БД
await db.messages.put({
id: tempId,
content: message,
pendingSync: true, // Флаг ожидания синхронизации
// ...
});
// 2. UI сразу показывает сообщение
// 3. Отправляем на сервер
const response = await fetch("/api/channels/ch1/messages", {
method: "POST",
body: JSON.stringify({ content: message }),
});
// 4. Заменяем временное сообщение на серверное
const serverMessage = await response.json();
await db.messages.delete(tempId);
await db.messages.put({
...serverMessage,
pendingSync: false,
});
}┌─────────────────────────────────────────────────────────────────┐
│ WEBSOCKET FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client A Server Client B │
│ │ │ │ │
│ │──── auth ──────────►│ │ │
│ │◄─── auth_success ───│ │ │
│ │ │ │ │
│ │── subscribe(ch1) ──►│ │ │
│ │◄─── subscribed ─────│ │ │
│ │ │◄── subscribe(ch1) ──│ │
│ │ │──── subscribed ────►│ │
│ │ │ │ │
│ │── POST message ────►│ │ │
│ │ │── new_message ─────►│ │
│ │ │ │ │
│ │ │ Client B applies │ │
│ │ │ change to local DB │ │
│ │
└─────────────────────────────────────────────────────────────────┘
Стратегия: Last Write Wins (LWW)
async function applySyncChanges(changes) {
for (const change of changes) {
const existing = await db.messages.get(change.recordId);
// Обновляем только если серверная версия новее
if (
!existing ||
change.data.lastWriteServerVersion > existing.lastWriteServerVersion
) {
await db.messages.put({
...change.data,
deleted: change.deleted,
});
}
}
}Для синхронизации удалений используется поле deleted_at:
// Сервер: soft delete
await db.update(messages)
.set({
deletedAt: new Date(),
lastWriteServerVersion: incrementServerVersion(),
})
.where(eq(messages.id, messageId));
// Клиент получает при синхронизации
{
"entity": "message",
"recordId": "msg123",
"data": { ... },
"deleted": true // Флаг удаления
}PWA конфигурация для работы без сети:
// vite.config.ts
VitePWA({
registerType: "autoUpdate",
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/localhost:4000\/api\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
networkTimeoutSeconds: 10,
},
},
],
},
});| Endpoint | Method | Description |
|---|---|---|
/api/auth/sign-up/email |
POST | Регистрация |
/api/auth/sign-in/email |
POST | Вход |
/api/auth/sign-out |
POST | Выход |
/api/auth/session |
GET | Текущая сессия |
| Endpoint | Method | Description |
|---|---|---|
/api/channels |
GET | Список каналов пользователя |
/api/channels |
POST | Создать канал |
/api/channels/:id |
GET | Получить канал |
/api/channels/:id |
PATCH | Обновить канал |
/api/channels/:id |
DELETE | Удалить канал (soft delete) |
/api/channels/:id/join |
POST | Присоединиться к каналу |
/api/channels/:id/members |
GET | Участники канала |
| Endpoint | Method | Description |
|---|---|---|
/api/channels/:id/messages |
GET | Сообщения в канале |
/api/channels/:id/messages |
POST | Отправить сообщение |
/api/channels/:id/messages/:msgId |
PATCH | Редактировать |
/api/channels/:id/messages/:msgId |
DELETE | Удалить |
| Endpoint | Method | Description |
|---|---|---|
/api/sync?since=N |
GET | Получить изменения с версии N |
/api/sync/version |
GET | Текущая версия сервера |
# Тест синхронизации
# 1. Откройте приложение в двух браузерах/вкладках
# 2. Войдите под разными пользователями
# 3. Создайте канал и добавьте второго пользователя
# 4. Отправляйте сообщения — они появятся в обоих окнах в реальном времени
# 5. Отключите сеть (DevTools → Network → Offline)
# 6. Отправьте сообщение — оно появится локально с меткой "Sending..."
# 7. Включите сеть — сообщение синхронизируется- Vector Clocks — для более точного разрешения конфликтов
- CRDT — бесконфликтные типы данных для совместного редактирования
- Delta Sync — передача только изменённых полей, а не всей записи
- Compression — сжатие данных при синхронизации
- Pagination — постраничная загрузка при большом количестве изменений
- Retry Queue — очередь повторных попыток для failed операций
MIT