# ТЗ: Синхронизация клиент-сервер для чат-приложения с локальными и удалёнными ассистентами ## 0. Контекст и цель Есть приложение-чат. В чатах пользователь общается с ассистентами. Ассистенты бывают: - **удалённые** — работают на сервере; - **локальные** — работают прямо на устройстве клиента. Приложение должно: - работать **офлайн** (пользователь может читать и писать, пока нет сети); - отрисовывать UI **мгновенно**, не дожидаясь сети; - **бесшовно** синхронизироваться, когда сеть появляется; - использовать **один и тот же код** для клиента и сервера там, где это возможно; - не хранить события вечно — журнал событий **компактится**. Ключевая архитектурная идея: **клиент рисует UI исключительно из своей локальной базы**. Сетевые запросы нужны только для синхронизации, а не для отрисовки. --- ## 1. Основные понятия (глоссарий) | Термин | Определение | |---|---| | **Событие (Event)** | Атомарный факт изменения состояния. Append-only. Имеет монотонный курсор `seq`. | | **Журнал событий (Event Log)** | Упорядоченная по `seq` последовательность событий. Append-only. Подвергается компакции. | | **Материализованное состояние (Materialized State)** | Текущее состояние домена (чаты, сообщения), полученное применением событий. Изменяемое. Каждая строка имеет `last_seq` — курсор последнего события, которое её изменило. | | **Курсор (seq)** | Монотонно возрастающее целое число. Глобально уникальное. Присваивается сервером при записи события. | | **Состояние на курсоре N** | Множество строк материализованного состояния, у которых `last_seq <= N`. Это «снимок» состояния на момент N. | | **Апдейты после N** | Множество событий из журнала, у которых `seq > N`. | | **Full resync** | Полная замена локального состояния клиента состоянием с сервера. Не merge, а replace. | | **Live sync** | Инкрементальная догрузка событий после известного курсора. | | **Компакция** | Удаление старых событий из журнала, которые уже «схлопнуты» в материализованное состояние. | --- ## 2. Инварианты системы Эти инварианты должны соблюдаться всегда. Если хоть один нарушен — система некорректна. 1. **Курсор монотонен.** `seq` строго возрастает. Никаких дыр, никаких сбросов. 2. **Журнал append-only.** События не изменяются и не удаляются, кроме как через компакцию. 3. **Материализация консистентна журналу.** Для любого `N`: `apply(events where seq <= N) == SELECT * FROM state WHERE last_seq <= N`. Это значит, что материализованное состояние — это не «что-то отдельное», а результат применения журнала. 4. **События самодостаточны.** Каждое событие несёт **полный payload** изменённой сущности, а не дельту. Это нужно, чтобы клиент мог применить событие к незнакомой сущности. 5. **Full resync = replace.** При полной синхронизации клиент **заменяет** своё локальное состояние, а не мержит. 6. **Клиент отрисовывает только из локальной базы.** Никакой запрос к серверу не блокирует UI. 7. **Компакция не удаляет события, которые ещё нужны активным клиентам.** Либо удаляет, но тогда клиент обязан сделать full resync. --- ## 3. Модель данных ### 3.1. На сервере #### 3.1.1. Журнал событий ``` EventLog: seq : int64, PK, монотонный event_id : UUID, уникальный идентификатор события type : enum (ChatCreated, MessageAppended, MessageEdited, MessageDeleted, ToolCallRequested, ToolCallSucceeded, ToolCallFailed, ...) payload : JSON / бинарный blob с полным состоянием изменённой сущности created_at : timestamp origin : device_id / user_id / assistant_id (кто породил) ``` Индексы: `PK(seq)`, `INDEX(created_at)`. #### 3.1.2. Материализованное состояние Отдельные таблицы под каждую сущность. Примеры: ``` Chat: id : UUID, PK title : string created_at : timestamp deleted : bool last_seq : int64 -- курсор последнего события, изменившего строку Message: id : UUID, PK chat_id : UUID, FK -> Chat.id role : enum (user, assistant, tool_call, tool_result, error) content : blob parent_id : UUID, nullable (для ветвлений) deleted : bool last_seq : int64 created_at : timestamp ``` Индексы: `PK(id)`, `INDEX(chat_id, last_seq)`, `INDEX(last_seq)`. **Важно:** поле `last_seq` — это не «версия строки» в смысле MVCC. Это **«на каком событии строка стала такой, какая она сейчас»**. Строка всегда хранит только актуальную версию. #### 3.1.3. Метаданные компакции ``` CompactionState: min_available_seq : int64 -- самый старый курсор, который ещё можно запросить last_compacted_at : timestamp ``` Если клиент запрашивает курсор `< min_available_seq` — сервер отвечает «курсор протух, делай full resync». ### 3.2. На клиенте Клиент хранит **те же таблицы**, что и сервер (материализованное состояние), плюс дополнительно: ``` SyncState: last_seq : int64 -- курсор, до которого клиент синхронизирован last_sync_at : timestamp PendingEvent: local_id : UUID, PK type : enum payload : JSON created_at : timestamp status : enum (pending, sent, failed) ``` `PendingEvent` — это события, которые клиент сгенерировал локально (пользователь написал сообщение, локальный ассистент ответил), но которые ещё не подтверждены сервером. **Важно:** `PendingEvent` не имеет `seq` — он появится только после подтверждения сервером. --- ## 4. Абстракции кода ### 4.1. Общие для клиента и сервера ```kotlin // Доменные события — общие sealed interface DomainEvent { val eventId: UUID val type: EventType val payload: EventPayload } // Материализованное состояние — общее interface StateStore { fun readState(upToSeq: Long): StateSnapshot fun applyEvent(event: LoggedEvent) // применяет событие к состоянию fun lastSeq(): Long } // Журнал — общий интерфейс чтения interface EventLog { fun readUpdates(afterSeq: Long, limit: Int): List fun lastSeq(): Long } // Событие с назначенным курсором data class LoggedEvent( val seq: Long, val eventId: UUID, val type: EventType, val payload: EventPayload, val createdAt: Instant, ) ``` ### 4.2. Только сервер ```kotlin interface EventLogWriter : EventLog { // Атомарно: назначает seq, пишет в журнал, применяет к материализации fun append(event: DomainEvent): LoggedEvent // Компакция fun compact(upToSeq: Long) } ``` ### 4.3. Только клиент ```kotlin interface EventLogReplica : EventLog { // Применить событие от сервера к локальному состоянию fun applyRemote(event: LoggedEvent) // Заменить всё локальное состояние состоянием с сервера fun replaceState(snapshot: StateSnapshot, upToSeq: Long) // Локальные (ещё не подтверждённые) события fun pendingEvents(): List fun markPendingAsSent(localId: UUID, seq: Long) fun markPendingAsFailed(localId: UUID) } ``` ### 4.4. Ассистент — общая абстракция Ключевая идея прозрачности: **ассистент — это просто генератор событий**. Клиент не знает, локальный он или удалённый. ```kotlin interface Assistant { // Запускает генерацию, возвращает поток событий fun run(input: RunInput): Flow } class RemoteAssistant(...) : Assistant { override fun run(input: RunInput): Flow { // Стримит события от сервера через WebSocket } } class LocalAssistant(...) : Assistant { override fun run(input: RunInput): Flow { // Генерирует события локально (например, вызывает локальную LLM) } } ``` Клиент подписывается на поток событий и применяет их так же, как события от сервера. Локальный ассистент порождает `PendingEvent`, который потом уходит на сервер и подтверждается. --- ## 5. Протокол синхронизации ### 5.1. Точки входа (API) **`GET /sync/state?upToSeq=N`** - Возвращает материализованное состояние на момент N: `SELECT * FROM state WHERE last_seq <= N`. - Если N не указан — возвращает актуальное состояние. - Если N < `min_available_seq` — возвращает ошибку `cursor_expired` с указанием актуального `last_seq`. - Ответ: `{ snapshot: StateSnapshot, upToSeq: N, currentSeq: M }`, где M — текущий максимальный курсор. **`GET /sync/updates?afterSeq=N&limit=K`** - Возвращает события `WHERE seq > N ORDER BY seq LIMIT K`. - Если N < `min_available_seq` — ошибка `cursor_expired`. - Ответ: `{ events: [LoggedEvent], hasMore: bool, currentSeq: M }`. **`WS /sync/live?afterSeq=N`** - WebSocket. Сервер пушит события `seq > N` в реальном времени. - Если N < `min_available_seq` — сервер закрывает соединение с кодом `cursor_expired`. **`POST /sync/events`** - Клиент отправляет `PendingEvent`(ы) на сервер. - Сервер валидирует, назначает `seq`, применяет, возвращает `LoggedEvent`(ы). - Идемпотентность: `eventId` (UUID). Повторная отправка того же `eventId` — no-op, возвращается уже назначенный `seq`. ### 5.2. Алгоритм синхронизации на клиенте ``` sync(): 1. Прочитать local lastSeq. 2. Попробовать GET /sync/updates?afterSeq=lastSeq&limit=K. 3. Если ответ cursor_expired: 3.1. GET /sync/state (без upToSeq) → получить актуальное состояние и currentSeq. 3.2. replaceState(snapshot, currentSeq). 3.3. lastSeq = currentSeq. 3.4. Перейти к шагу 5. 4. Если ответ ок: 4.1. Для каждого события в ответе: applyRemote(event). 4.2. lastSeq = max(seq в ответе). 4.3. Если hasMore — повторить с шага 2. 5. Отправить все PendingEvent через POST /sync/events. 5.1. Для каждого подтверждённого: markPendingAsSent(localId, seq). 5.2. Для каждого неподтверждённого: markPendingAsFailed(localId). 6. Открыть WS /sync/live?afterSeq=lastSeq. 6.1. При получении события: applyRemote(event), lastSeq = event.seq. 6.2. При разрыве: вернуться к шагу 2. ``` **Важно:** шаг 3 (full resync) **заменяет** состояние, а не мержит. Все локальные данные, которых нет в снапшоте, удаляются. Pending-события при этом **сохраняются** и отправляются после resync (шаг 5). ### 5.3. Обработка локальных событий (пользователь пишет сообщение) ``` userSendsMessage(chatId, content): 1. Создать DomainEvent(MessageAppended, {chatId, content, role: user, ...}). 2. Сохранить в PendingEvent со status = pending. 3. Применить событие к локальному состоянию (оптимистично), чтобы UI сразу показал сообщение. 4. Запустить sync() в фоне. 5. Когда сервер подтвердит — markPendingAsSent(localId, seq). Если сервер вернул ошибку — markPendingAsFailed(localId), откатить состояние. ``` ### 5.4. Обработка локального ассистента ``` localAssistantRuns(chatId, input): 1. Запустить Assistant.run(input), получить Flow. 2. Для каждого события в потоке: 2.1. Сохранить в PendingEvent со status = pending. 2.2. Применить к локальному состоянию. 3. Запустить sync() — события уйдут на сервер, получат seq, станут частью журнала. ``` Клиент **не различает** события от пользователя, от локального ассистента и от удалённого. Все они идут через `PendingEvent` → сервер → `LoggedEvent`. --- ## 6. Компакция журнала ### 6.1. Зачем Журнал растёт бесконечно. Старые события уже «схлопнуты» в материализованное состояние. Их можно удалить. ### 6.2. Как Периодический фоновый процесс на сервере: 1. Определить `compaction_seq` = минимальный курсор, который ещё нужен активным клиентам. Если неизвестно — использовать `current_seq - safety_margin`. 2. Удалить события `WHERE seq <= compaction_seq`. 3. Обновить `CompactionState.min_available_seq = compaction_seq + 1`. ### 6.3. Что делать клиенту с протухшим курсором Если `lastSeq < min_available_seq`: - Не пытаться догрузить апдейты (их нет). - Сделать **full resync**: `GET /sync/state` → `replaceState` → продолжить live sync. Это **не ошибка**, это штатный сценарий. Клиент просто получает актуальное состояние и продолжает жить. ### 6.4. Ключевое правило **Full resync = replace, не merge.** Если клиент мержит, удалённые и отредактированные сущности останутся в старом виде. Если заменяет — всё консистентно. --- ## 7. Обработка редактирования и удаления ### 7.1. Редактирование Событие `MessageEdited` несёт **полный новый content** сообщения. - В журнал пишется событие с `seq = S`. - В материализации строка обновляется на месте: `content = newContent`, `last_seq = S`. **Клиент на живом курсоре N < S:** - Получает событие `MessageEdited` из журнала. - Применяет: обновляет `content` у себя, ставит `last_seq = S`. **Клиент на протухшем курсоре, resync на M > S:** - В снапшоте видит сообщение уже с новым content. - Событие `S` не приходит (оно ≤ M). - Всё консистентно. ### 7.2. Удаление Событие `MessageDeleted`. В материализации строка либо **физически удаляется**, либо помечается `deleted = true` с `last_seq = S`. Рекомендуется **физическое удаление**, потому что replace при resync всё равно уберёт строку. Tombstones нужны только если требуется показывать «сообщение удалено» в UI. **Клиент на живом курсоре N < S:** - Получает событие `MessageDeleted`. - Удаляет строку у себя. **Клиент на протухшем курсоре, resync на M > S:** - В снапшоте строки нет. - replace убирает её у клиента. - Событие `S` не приходит. - Всё консистентно. --- ## 8. Гарантии и краевые случаи ### 8.1. Идемпотентность Каждое событие имеет `eventId` (UUID). Применение события с уже известным `eventId` — no-op. Это защищает от: - повторной отправки `PendingEvent`; - повторного применения события при реконнекте; - дублирования в live sync. ### 8.2. Порядок событий Клиент применяет события **в порядке `seq`**. Если пришло событие с `seq > lastSeq + 1`, значит есть пропуск — клиент должен догрузить пропущенные через `GET /sync/updates?afterSeq=lastSeq`. ### 8.3. Конфликты Конфликты не разрешаются «в общем виде». Правила: - Сообщения **append-only** (редактирование = новое событие, не мутация). - Правки от разных устройств одного пользователя — сервер применяет в порядке поступления, последняя побеждает (last-write-wins по `seq`). - Ветвления (regenerate) — через `parent_id`, а не через мутацию. ### 8.4. Офлайн-запись Пользователь пишет офлайн → событие в `PendingEvent` → применяется локально → UI показывает. При появлении сети → sync → сервер назначает `seq` → `markPendingAsSent`. Если сервер отверг событие (валидация не прошла) → `markPendingAsFailed` → клиент откатывает локальное изменение. ### 8.5. Мультиустройство У каждого клиента есть `device_id`. События помечаются `origin`. При синке клиент не применяет свои же события повторно (идемпотентность по `eventId` решает это автоматически). ### 8.6. Большие снапшоты `GET /sync/state` может вернуть много данных. Решения: - Пагинация: `GET /sync/state?upToSeq=N&chatId=X` — по одному чату. - Стриминг: HTTP chunked / gRPC streaming. - Сжатие (gzip / zstd). Для чата с ассистентом обычно достаточно per-chat снапшотов. --- ## 9. Что должен реализовать кодовый агент ### 9.1. Сервер 1. **Хранилище журнала событий** (`EventLog`): append-only, монотонный `seq`, компакция. 2. **Хранилище материализованного состояния** (`StateStore`): таблицы `Chat`, `Message`, поле `last_seq`. 3. **Логика применения события** (`applyEvent`): обновляет материализацию, ставит `last_seq`. 4. **Атомарная операция `append`**: в одной транзакции назначает `seq`, пишет в журнал, применяет к материализации. 5. **HTTP API**: `/sync/state`, `/sync/updates`, `/sync/events`. 6. **WebSocket**: `/sync/live`. 7. **Фоновый компактор**: периодически удаляет старые события, обновляет `min_available_seq`. 8. **Идемпотентность**: таблица `processed_event_ids` или проверка по `eventId`. ### 9.2. Клиент 1. **Локальное хранилище состояния** (`StateStore`): те же таблицы, что на сервере. 2. **Локальное хранилище `PendingEvent`**: очередь несинхронизированных событий. 3. **Логика применения события** (`applyEvent`): общая с сервером (один код). 4. **Логика `replaceState`**: полная замена локального состояния. 5. **Sync-клиент**: реализует алгоритм из раздела 5.2. 6. **WebSocket-клиент**: live sync. 7. **UI**: рисует исключительно из локального `StateStore`. Никогда не ждёт сеть. 8. **Ассистенты**: `LocalAssistant` и `RemoteAssistant` через общий интерфейс `Assistant`, возвращающий `Flow`. ### 9.3. Общее 1. **Модель `DomainEvent`** с самодостаточным payload. 2. **Интерфейсы** `EventLog`, `StateStore`, `Assistant`. 3. **Логика сериализации/десериализации** событий. 4. **UUID-генерация** для `eventId`. 5. **Логика идемпотентности** по `eventId`. --- ## 10. Чего делать НЕ надо 1. **Не делать MVCC.** Строки хранят только текущую версию. История — в журнале, но она компактится. 2. **Не делать tombstones**, если не нужно показывать «удалено». Физическое удаление + replace при resync решают всё. 3. **Не мержить при full resync.** Только replace. 4. **Не запрашивать сервер для отрисовки.** UI читает только локальную базу. 5. **Не различать локального и удалённого ассистента на уровне клиента.** Оба — `Assistant`, возвращающий `Flow`. 6. **Не хранить счётчик в БД отдельно.** `seq` — это либо sequence в БД, либо ULID в событии. Отдельный «счётчик» — лишняя сущность. 7. **Не бояться, что «старый курсор протух».** Это штатный сценарий: full resync. --- ## 11. Критерии готовности 1. Пользователь может писать офлайн, UI обновляется мгновенно. 2. При появлении сети события уходят на сервер, получают `seq`, синхронизируются. 3. Второй клиент видит изменения в реальном времени. 4. Локальный ассистент работает так же, как удалённый, с точки зрения клиента. 5. Компакция журнала не ломает синхронизацию: клиент с протухшим курсором делает full resync и продолжает. 6. Редактирование и удаление сообщений обрабатываются консистентно во всех сценариях. 7. Full resync заменяет состояние, не оставляя «фантомных» строк. 8. Идемпотентность: повторная отправка события не создаёт дублей. --- ## 12. Резюме идеи в одном абзаце Есть **журнал событий** с монотонным курсором и **материализованное состояние**, где каждая строка помечена курсором последнего изменившего её события. Состояние на курсоре N — это строки с `last_seq <= N`. Апдейты после N — это события с `seq > N`. Клиент хранит локальную копию состояния и свой `lastSeq`. Для синхронизации он либо догружает апдейты (если курсор жив), либо заменяет состояние целиком (если курсор протух из-за компакции). В обоих случаях результат консистентен: клиент видит актуальные данные, не видит «шума» про отредактированные/удалённые сущности, которые были до его курсора, и продолжает live sync с нового курсора. Локальный и удалённый ассистенты — просто генераторы событий, неотличимые для клиента.