Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.
Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.
outbox-api:
- Cursor (offset: Long) — курсор в журнале событий агента.
- OffsetSequencer — интерфейс резервирования уникального offset.
- CursorStore — персистентное хранилище текущего offset'а.
- PersistentOffsetSequencer — декоратор над любым OutboxStore,
обновляет CursorStore на каждом append (atomic transaction).
- OutboxGapException — клиент запросил after < earliestCursor() →
сервер не может удовлетворить, клиент обязан делать full resync.
- DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
был общим sealed-типом, теперь это термин из спеки).
- MutableOutboxStore и OutboxStore теперь читают offset через
CursorStore вместо in-memory counter'а.
outbox-inmemory:
- InMemoryOffsetSequencer — для тестов и dev-режима.
- InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.
outbox-ksqlite (новый модуль):
- KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
- KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).
proto + server:
- Snapshot.proto — server-state snapshot endpoint для клиентов,
которым нужна полная материализация (использование TBD).
- Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).
journal-ksqlite:
- KsqliteJournalStore.listFlow/append — без изменений по API,
нотации минимальные (codecs).
standalone:
- DurableLog (бывший ChatAgent-orchestration) — атомарный commit
события в OutboxStore + PersistentOffsetSequencer + materialization
(через Reducer) одной транзакцией.
- SqliteStores — добавляет KsqliteCursorStore в bundle, единая
shared-connection для всех ksqlite-сторов standalone-агента.
- ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
ToolDispatcher — переход на новые абстракции.
- standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
включено (раньше было закомментировано — модуль только создавался).
client:
- AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
ReconnectingOutbox — используют Cursor через transport API.
- client/README.md — синхронизирован с новым поведением (468 строк
diff — это в основном оформление и примеры).
kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).
SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.
Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.
sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
+459
@@ -0,0 +1,459 @@
|
||||
# ТЗ: Синхронизация клиент-сервер для чат-приложения с локальными и удалёнными ассистентами
|
||||
|
||||
## 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<LoggedEvent>
|
||||
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<PendingEvent>
|
||||
fun markPendingAsSent(localId: UUID, seq: Long)
|
||||
fun markPendingAsFailed(localId: UUID)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4. Ассистент — общая абстракция
|
||||
|
||||
Ключевая идея прозрачности: **ассистент — это просто генератор событий**. Клиент не знает, локальный он или удалённый.
|
||||
|
||||
```kotlin
|
||||
interface Assistant {
|
||||
// Запускает генерацию, возвращает поток событий
|
||||
fun run(input: RunInput): Flow<DomainEvent>
|
||||
}
|
||||
|
||||
class RemoteAssistant(...) : Assistant {
|
||||
override fun run(input: RunInput): Flow<DomainEvent> {
|
||||
// Стримит события от сервера через WebSocket
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAssistant(...) : Assistant {
|
||||
override fun run(input: RunInput): Flow<DomainEvent> {
|
||||
// Генерирует события локально (например, вызывает локальную 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<DomainEvent>.
|
||||
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<DomainEvent>`.
|
||||
|
||||
### 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<DomainEvent>`.
|
||||
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 с нового курсора. Локальный и удалённый ассистенты — просто генераторы событий, неотличимые для клиента.
|
||||
Reference in New Issue
Block a user