Добавляет :sync-core — референсная реализация SYNC-SYSTEM.md.

Новый KMP-модуль :sync-core реализует спеку SYNC-SYSTEM.md с нуля, без
опоры на существующие :outbox-api / :journal-api / :client. Цель — собрать
работающую модель «append-only event journal + материализация +
replace-resync», проверить инварианты тестами, и уже потом думать, как это
распределить по существующим абстракциям.

Архитектура (§3-§5 спеки):
  - Cursor(epoch, number) — пара (поколение, счётчик); @JvmInline value
    class; монотонно возрастает внутри эпохи, при wipe/restart эпоха
    меняется и клиент обязан делать replaceState.
  - EventLog / EventLogWriter / EventLogReplica — append-only журнал +
    материализация на стороне клиента (replica ничего не хранит в
    журнале, только state + sync_state + pending).
  - StateStore / Reducer<S, E> — детерминированная свёртка событий в
    снапшот ChatState.
  - PendingEvent + LocalId — offline-write очередь: PENDING → SENT/FAILED.
  - SyncEngine — оркестратор: fetchUpdates → applyRemote → fetchState →
    replaceState → postPending.
  - SyncTransport — четыре endpoint'а (fetchUpdates / fetchState /
    subscribeLive / postPending), сейчас реализован InProcessTransport
    (in-memory), задел под HTTP+WS.

ksqlite-бэкенд (commonMain, ksqlite 0.1.4):
  - SqliteEventLog — append-only с UNIQUE(event_id) для идемпотентности.
  - SqliteEventLogReplica — клиентская сторона (processed_event_id,
    pending_event, sync_state singleton-row).
  - SqliteStateStore — chat_state / message_state + replaceState.
  - SqliteSyncBundle — фабрика для тестов.

CursorExpired split (§6.3 спеки):
  - WRONG_EPOCH — сервер сменил эпоху (wipe/restore/миграция).
  - TOO_OLD — компакция унесла события ниже floor.
Оба → клиент обязан сделать replaceState (sync() делает это сам).

EventLogWriter.beginNewEpoch(newEpoch): Cursor — атомарная смена эпохи
на сервере: DELETE event_log + reset sqlite_sequence + UPDATE
compaction_state, в одной транзакции.

Сборка: jvm + linuxX64 (mingwX64 компилируется, тесты на linux хосте
пропускаются — ksqlite имеет нативные бинарники только под эти три).

Тесты (74, commonTest, проходят на jvm и linuxX64):
  - CursorTest (14) — парсинг, валидация, isAfter/isBefore/compareTo.
  - EpochMismatchTest (6) — WRONG_EPOCH / TOO_OLD → full resync.
  - InvariantsTest — 7 инвариантов спеки на in-memory бэкенде.
  - IdempotencyTest — повторный applyRemote по eventId = no-op.
  - EditDeleteTest — message edit/delete через события.
  - CompactionAndOrderTest — compact сдвигает minAvailableCursor.
  - OfflineWriteTest — pending отправляется при следующем sync().
  - AssistantTest — LocalAssistant возвращает Flow<DomainEvent>.
  - KsqliteSyncSpecTest (15) — все спец-тесты на ksqlite.
  - KsqlitePersistenceTest — file-based persistence.

Существующий код (:outbox-api / :journal-api / :client / :server) не
трогаем — это чистая референсная реализация для последующей миграции.
This commit is contained in:
2026-10-02 01:05:46 +03:00
parent 059c23e3ab
commit 92b76c4e3c
35 changed files with 4372 additions and 0 deletions
+1
View File
@@ -55,6 +55,7 @@ val moduleDescriptions: Map<String, String> = mapOf(
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.", "agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)." // "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.", "standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
"sync-core" to "agentik :sync-core — референсная реализация спеки SYNC-SYSTEM.md: журнал событий с монотонным курсором + материализация + replace-resync. KMP (jvm/linuxX64/mingwX64), ksqlite-бэкенд, in-memory бэкенд в commonMain.",
) )
rootProject.extra.set("moduleDescriptions", moduleDescriptions) rootProject.extra.set("moduleDescriptions", moduleDescriptions)
+7
View File
@@ -146,3 +146,10 @@ include(":agent-api")
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing / // SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner. // Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
include(":skill-mining") include(":skill-mining")
// Референсная реализация спеки `SYNC-SYSTEM.md` — журнал событий с
// монотонным курсором + материализация + replace-resync, единый протокол
// для клиента и сервера. KMP с jvm/linuxX64/mingwX64 целями (ksqlite-
// ограничение). Тесты спеки в commonTest, интеграция с ksqlite — в jvmTest.
// Существующий код (outbox/journal/client/server) не трогаем; это чистый
// референс для последующей миграции.
include(":sync-core")
+46
View File
@@ -0,0 +1,46 @@
# `:sync-core`
Референсная реализация ТЗ [`SYNC-SYSTEM.md`](../SYNC-SYSTEM.md) в изоляции от
существующего кода `:outbox-api` / `:journal-api` / `:client` / `:server`.
Цель — проверить, что спека действительно работает, и выработать API для
переиспользования как в in-process (встроенный агент), так и в удалённом
(HTTP/WebSocket) сценариях.
## Что здесь
| Слой | Где | Что |
|---|---|---|
| Domain types | `commonMain` | `DomainEvent` (sealed), `LoggedEvent`, `PendingEvent`, `Chat`/`Message`/`ChatState`/`StateSnapshot`, `CompactionState`, `SyncState` |
| Интерфейсы | `commonMain` | `EventLog` / `EventLogWriter` / `EventLogReplica`, `StateStore`, `Reducer`, `Assistant`, `SyncTransport` |
| In-memory бэкенд | `commonMain` | `InMemoryEventLog` (одновременно `Writer` + `Replica`), `InMemoryPendingQueue`, `InMemoryCompactionState`, `InMemorySyncState`, `DefaultChatReducer` |
| SyncEngine | `commonMain` | Алгоритм §5.2 спеки (`sync()` + live-подписка) |
| Transport | `commonMain` | `InProcessTransport` — имитация HTTP/WS поверх in-memory writer'а |
| Assistant | `commonMain` | `Assistant` интерфейс + `LocalAssistant` (in-process генератор) + `RemoteAssistant` (заглушка под HTTP) |
| Tests | `commonTest` | `SyncSpec` (abstract) + спец-тесты под все 7 инвариантов и ключевые сценарии §8 спеки |
| ksqlite-бэкенд | `commonMain` | `SqliteEventLog` / `SqliteStateStore` / `SqlitePendingQueue` / `SqliteCompactionState` / `SqliteSyncState` |
| ksqlite интеграционные тесты | `jvmTest` | `KsqliteSyncSpecTest` — тот же `SyncSpec`, но поверх in-memory SQLite-коннекшна |
## Границы
* Не реализует HTTP/WS/SSE (это отдельный слой поверх `SyncTransport`).
* Не реализует LLM (assistant'ы — детерминированные генераторы).
* Не реализует streaming-протокол ответа (стриминг дельт — отдельная
концепция, в спеке его нет).
* Не реализует мультидевайс-race (`origin` помечает события, но спека молчит
о том, как разрешать конкурентные правки одного чата с двух устройств;
last-write-wins по `seq` зашит в редьюсере).
## Сборка
```bash
./gradlew :sync-core:build # компиляция + все тесты
./gradlew :sync-core:jvmTest # только JVM (включая ksqlite интеграцию)
./gradlew :sync-core:allTests # все цели
```
## Статус
Спека реализована вчерне, инварианты §2 покрыты тестами в `commonTest`. После
стабилизации API и ksqlite-слоя возможна миграция существующих
`:outbox-api`/`:journal-api`/`:client`/`:server` на эту модель (или нет — тогда
`:sync-core` останется референсом и песочницей для новых идей).
+41
View File
@@ -0,0 +1,41 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// Референсная реализация ТЗ `SYNC-SYSTEM.md` — журнал событий с монотонным
// курсором + материализация (`Chat`/`Message` с `last_seq`), единый протокол
// для клиента и сервера. KMP, всё в commonMain/commonTest где это возможно;
// ksqlite-реализация — в commonMain (ksqlite публикует KMP-варианты для всех
// десктоп-целей), интеграционные тесты под реальной SQLite — в jvmTest.
//
// Цели сборки:
// - commonMain/commonTest содержат pure-Kotlin типы, интерфейсы, in-memory
// реализации и спец-тесты (SyncSpec). Работают на всех 9 KMP-целях.
// - jvmMain + jvmTest дополнительно содержат ksqlite-бэкенд (он же публикуется
// для linuxX64/mingwX64, но запускать с реальной SQLite-схемой удобнее под JVM).
//
// Почему нет iOS/macOS целей: ksqlite не публикует варианты для них. Если
// когда-нибудь понадобится, цели добавляются одной строкой.
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
implementation(libs.ksqlite)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
// для файлового персистентного теста — KMP-обёртка вокруг fs.
implementation(libs.kotlinx.io.core)
}
}
}
@@ -0,0 +1,60 @@
package pw.binom.agentik.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Clock
/**
* Локальный ассистент — генератор доменных событий в процессе клиента
* (§4.4 спеки).
*
* Контракт:
* - `run(input)` возвращает холодный [Flow]. Для каждого [DomainEvent] в
* потоке клиент должен вызвать [EventLogReplica.enqueuePending] (или
* аналог на клиенте) — он оптимистично применяет событие к локальной
* материализации и добавляет его в очередь на отправку.
* - `Flow` завершается, когда ассистент закончил генерацию.
*
* Сама логика генерации (вызов модели, парсинг, tool calls) делается
* конкретной реализацией через [flowBuilder]. Здесь — абстракция.
*/
class LocalAssistant(
private val flowBuilder: suspend (RunInput) -> Flow<DomainEvent>,
) : Assistant {
override fun run(input: RunInput): Flow<DomainEvent> = flow {
flowBuilder(input).collect { e ->
emit(e)
}
}
}
/**
* Удобный конструктор для тестов: возвращает готовый [Flow] фиксированных
* событий. Не использовать в production — это test-fixture.
*/
fun scriptedAssistant(events: List<DomainEvent>): Assistant = LocalAssistant { _ ->
kotlinx.coroutines.flow.flowOf(*events.toTypedArray())
}
/**
* RemoteAssistant — генератор, который **не пишет события сам**, а стримит
* их от сервера через [SyncTransport.subscribeLive]. Эта заглушка существует
* чтобы проиллюстрировать «ассистент — это просто генератор событий»
* (§4.4 спеки): клиент не различает локального и удалённого.
*
* Полная реализация (подключение к WS-ручке ассистента конкретного
* провайдера) выходит за рамки спеки синхронизации.
*/
fun remoteAssistant(
transport: SyncTransport,
after: suspend () -> Cursor,
): Assistant = Assistant { _ ->
flow {
transport.subscribeLive(after()).collect { e ->
// Для каждого серверного события, которое пришло в live-канал,
// считаем его «ответом ассистента» — отдаём клиенту как
// DomainEvent (payload без cursor/createdAt).
emit(e.payload)
}
}
}
@@ -0,0 +1,128 @@
package pw.binom.agentik.sync
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.jvm.JvmInline
/**
* Курсор журнала (§3.1.3 спеки) — пара `(epoch, number)`, однозначно
* идентифицирующая позицию в логе событий.
*
* - **epoch** (String) — «поколение» журнала. Не меняется при обычной работе.
* Сервер может его сменить (например, при wipe DB, restore из бэкапа, или
* миграции на новую схему) — после этого клиент со старой эпохой обязан
* сделать full resync (см. §6.3 спеки). Не может содержать `-` (используется
* как разделитель в строковом представлении).
* - **number** (ULong) — монотонный счётчик внутри эпохи. Присваивается
* сервером при `append` (§2.1 спеки: «строго возрастает, без дыр»).
*
* Строковое представление — `"<epoch>-<number_hex>"`, например `"v1-2a"`.
* Сериализуется и в JSON, и в SQLite TEXT как одна строка.
*
* Сериализация через [CursorSerializer] (зарегистрирован как `@Serializable(with=…)`).
*/
@Serializable(with = CursorSerializer::class)
@JvmInline
value class Cursor private constructor(private val raw: String) {
constructor(epoch: String, number: ULong) : this(buildRaw(epoch, number))
/** Поколение журнала. Меняется при wipe/restore/миграции сервера. */
val epoch: String
get() = raw.substring(0, raw.indexOf('-'))
/** Монотонный счётчик внутри эпохи. */
val number: ULong
get() = raw.substring(raw.indexOf('-') + 1).toULong(16)
/**
* Следующий курсор в этой же эпохе. `number + 1`.
*
* **Без проверки на переполнение** — `ULong.MAX_VALUE.next()` тихо
* оборачивается в 0. Сервер должен следить за `lastCursor.number` и
* заранее вызывать [EventLogWriter.beginNewEpoch] (см. readme модуля).
*/
fun next(): Cursor = Cursor(epoch = epoch, number = number + 1uL)
/** Строковое представление `"<epoch>-<number_hex>"`. */
fun toRawString(): String = raw
/**
* Лексикографическое сравнение — сначала по `epoch`, потом по `number`.
*
* ⚠️ Курсоры из **разных эпох** несравнимы по смыслу «до/после»:
* `compareTo` всё равно их упорядочит (для сортировки), но использовать
* `cursor > other` для проверки свежести в разных эпохах бессмысленно.
* Семантическая проверка «X свежее Y в той же эпохе» —
* `cursor.epoch == other.epoch && cursor.number > other.number`
* либо [isAfter]/[isBefore].
*/
operator fun compareTo(other: Cursor): Int {
val epochCmp = epoch.compareTo(other.epoch)
return if (epochCmp != 0) epochCmp else number.compareTo(other.number)
}
/** «X свежее Y в той же эпохе». Возвращает `null`, если эпохи разные. */
fun isAfter(other: Cursor): Boolean? =
if (epoch != other.epoch) null else number > other.number
/** «X старше Y в той же эпохе». Возвращает `null`, если эпохи разные. */
fun isBefore(other: Cursor): Boolean? =
if (epoch != other.epoch) null else number < other.number
override fun toString(): String = "Cursor(epoch=$epoch, number=$number)"
companion object {
/** Стартовый курсор: «не синхронизировано». Соответствует `epoch="v1", number=0`. */
val ZERO: Cursor = Cursor("v1", 0uL)
/** Разобрать курсор из строкового представления. Бросает `IllegalArgumentException` при ошибке. */
fun fromString(raw: String): Cursor {
require(raw.length > 3) { "Cursor too short: \"$raw\"" }
val sep = raw.indexOf('-')
require(sep > 0) { "Cursor missing epoch/number separator: \"$raw\"" }
require(raw.indexOf('-', sep + 1) == -1) {
"Cursor has multiple separators: \"$raw\""
}
val numStr = raw.substring(sep + 1)
val num = numStr.toULongOrNull(16)
require(num != null) { "Cursor number part is not hex: \"$raw\"" }
return Cursor(raw)
}
private fun buildRaw(epoch: String, number: ULong): String {
require(epoch.isNotEmpty()) { "epoch must be non-empty" }
require(epoch.none { it == '-' }) { "epoch must not contain '-': \"$epoch\"" }
return "$epoch-${number.toString(16)}"
}
}
}
/**
* Курсоры из **разных эпох несравнимы** по смыслу «до/после»: после
* [EventLogWriter.beginNewEpoch] старые курсоры клиента невалидны, и
* единственный допустимый порядок — заменить локальное состояние. Семантика
* «свежее» — [Cursor.isAfter]/[Cursor.isBefore] либо явная проверка
* `epoch == && number >`.
*/
/**
* kotlinx-serialization `KSerializer` для [Cursor]. Кодируется как
* примитивная строка формата `"<epoch>-<number_hex>"`.
*/
object CursorSerializer : KSerializer<Cursor> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("pw.binom.agentik.sync.Cursor", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Cursor) {
encoder.encodeString(value.toRawString())
}
override fun deserialize(decoder: Decoder): Cursor =
Cursor.fromString(decoder.decodeString())
}
@@ -0,0 +1,12 @@
package pw.binom.agentik.sync
/**
* Причина, по которой курсор клиента больше не валиден.
*
* - [WRONG_EPOCH] — клиент работает со старой эпохой. Сервер сделал wipe /
* restore / миграцию. Клиент должен сделать full resync (replaceState).
* - [TOO_OLD] — клиент в правильной эпохе, но запрашивает события старше
* [CompactionState.minAvailableCursor]. Сервер удалил старые события при
* compaction. Клиент должен сделать full resync.
*/
enum class CursorExpiredReason { WRONG_EPOCH, TOO_OLD }
@@ -0,0 +1,129 @@
package pw.binom.agentik.sync
/**
* Редьюсер по умолчанию для [ChatState]. Применяет событие к состоянию,
* обновляя затронутую строку и проставляя ей `lastCursor = event.cursor`.
*
* Правила (§2.3 спеки + §7.1 + §7.2):
* - `ChatCreated` → добавляет чат.
* - `ChatRenamed` → обновляет title чата.
* - `ChatDeleted` → удаляет чат (см. §7.2).
* - `MessageAppended` → добавляет сообщение в список чата.
* - `MessageEdited` → заменяет content сообщения (несёт полный новый).
* - `MessageDeleted` → **физически удаляет** из списка (см. §7.2).
* - `ToolCall*` → добавляет сообщение с ролью TOOL_CALL / TOOL_RESULT / ERROR.
*/
object DefaultChatReducer : Reducer<ChatState> {
override fun reduce(state: ChatState, event: LoggedEvent): ChatState {
val p = event.payload
return when (p) {
is DomainEvent.ChatCreated -> state.copy(
chats = state.chats + (p.chatId to Chat(
id = p.chatId,
title = p.title,
createdAt = p.createdAt,
deleted = false,
lastCursor = event.cursor,
)),
)
is DomainEvent.ChatRenamed -> state.copy(
chats = state.chats.update(p.chatId) { old ->
old.copy(title = p.newTitle, lastCursor = event.cursor)
},
)
is DomainEvent.ChatDeleted -> state.copy(
chats = state.chats - p.chatId,
)
is DomainEvent.MessageAppended -> state.copy(
messages = state.messages.appendMessage(p.chatId, Message(
id = p.messageId,
chatId = p.chatId,
role = p.role,
content = p.content,
parentId = p.parentId,
deleted = false,
lastCursor = event.cursor,
createdAt = p.createdAt,
)),
)
is DomainEvent.MessageEdited -> state.copy(
messages = state.messages.updateMessages(p.chatId) { list ->
list.map { m ->
if (m.id == p.messageId) m.copy(content = p.newContent, lastCursor = event.cursor) else m
}
},
)
is DomainEvent.MessageDeleted -> state.copy(
messages = state.messages.updateMessages(p.chatId) { list ->
list.filter { it.id != p.messageId }
},
)
is DomainEvent.ToolCallRequested -> state.copy(
messages = state.messages.appendMessage(p.chatId, Message(
id = p.messageId,
chatId = p.chatId,
role = MessageRole.TOOL_CALL,
content = "${p.toolName}(${p.toolArgs})",
parentId = null,
deleted = false,
lastCursor = event.cursor,
createdAt = event.createdAt,
)),
)
is DomainEvent.ToolCallSucceeded -> state.copy(
messages = state.messages.appendMessage(p.chatId, Message(
id = p.messageId,
chatId = p.chatId,
role = MessageRole.TOOL_RESULT,
content = p.result,
parentId = p.toolCallId,
deleted = false,
lastCursor = event.cursor,
createdAt = event.createdAt,
)),
)
is DomainEvent.ToolCallFailed -> state.copy(
messages = state.messages.appendMessage(p.chatId, Message(
id = p.messageId,
chatId = p.chatId,
role = MessageRole.ERROR,
content = p.message,
parentId = p.toolCallId,
deleted = false,
lastCursor = event.cursor,
createdAt = event.createdAt,
)),
)
}
}
}
private inline fun <K, V> Map<K, V>.update(key: K, block: (V) -> V): Map<K, V> {
val old = this[key] ?: return this
return this + (key to block(old))
}
private fun Map<ChatId, List<Message>>.updateMessages(
chatId: ChatId,
block: (List<Message>) -> List<Message>,
): Map<ChatId, List<Message>> {
val old = this[chatId] ?: emptyList()
return this + (chatId to block(old))
}
private fun Map<ChatId, List<Message>>.appendMessage(
chatId: ChatId,
message: Message,
): Map<ChatId, List<Message>> {
val old = this[chatId] ?: emptyList()
return this + (chatId to (old + message))
}
@@ -0,0 +1,217 @@
package pw.binom.agentik.sync
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Доменное событие журнала (§3.1.1 спеки).
*
* Это «что произошло», без курсора и без идентификатора записи в журнале —
* идентификатор и курсор появляются при записи (см. [LoggedEvent]). Каждое
* событие самодостаточно (§2.4 спеки): несёт полный снимок изменённой
* сущности в полях payload'а, чтобы клиент мог применить событие к
* незнакомой строке.
*
* [eventId] — стабильный UUID, генерируется **продюсером** (клиентом для
* pending-event'ов, сервером для server-side событий). Используется для
* идемпотентности (§8.1 спеки).
*/
@Serializable
sealed interface DomainEvent {
/** Уникальный идентификатор события (UUID строкой). */
val eventId: String
/** Чат создан. */
@Serializable
@SerialName("chat_created")
data class ChatCreated(
override val eventId: String,
val chatId: ChatId,
val title: String,
val createdAt: Instant,
) : DomainEvent
/** Чат переименован. */
@Serializable
@SerialName("chat_renamed")
data class ChatRenamed(
override val eventId: String,
val chatId: ChatId,
val newTitle: String,
) : DomainEvent
/** Чат помечен удалённым. Replace при resync уберёт его у клиента. */
@Serializable
@SerialName("chat_deleted")
data class ChatDeleted(
override val eventId: String,
val chatId: ChatId,
) : DomainEvent
/** Сообщение добавлено. [parentId] — для ветвлений (regenerate), `null` в линейной истории. */
@Serializable
@SerialName("message_appended")
data class MessageAppended(
override val eventId: String,
val messageId: MessageId,
val chatId: ChatId,
val role: MessageRole,
val content: String,
val parentId: MessageId? = null,
val createdAt: Instant,
) : DomainEvent
/** Сообщение отредактировано — несёт **полный новый content** (§7.1 спеки). */
@Serializable
@SerialName("message_edited")
data class MessageEdited(
override val eventId: String,
val messageId: MessageId,
val chatId: ChatId,
val newContent: String,
) : DomainEvent
/** Сообщение удалено физически (см. §7.2 — рекомендуется физическое удаление). */
@Serializable
@SerialName("message_deleted")
data class MessageDeleted(
override val eventId: String,
val messageId: MessageId,
val chatId: ChatId,
) : DomainEvent
/** Ассистент начал вызов тула. */
@Serializable
@SerialName("tool_call_requested")
data class ToolCallRequested(
override val eventId: String,
val messageId: MessageId,
val chatId: ChatId,
val toolName: String,
val toolArgs: String,
) : DomainEvent
/** Тул успешно вернул результат. */
@Serializable
@SerialName("tool_call_succeeded")
data class ToolCallSucceeded(
override val eventId: String,
val messageId: MessageId,
val chatId: ChatId,
val toolCallId: MessageId,
val toolName: String,
val result: String,
) : DomainEvent
/** Исполнение тула упало (handler exception, args не парсятся и т.п.). */
@Serializable
@SerialName("tool_call_failed")
data class ToolCallFailed(
override val eventId: String,
val messageId: MessageId,
val chatId: ChatId,
val toolCallId: MessageId,
val toolName: String,
val message: String,
) : DomainEvent
}
/**
* Снимок сообщения в материализованном состоянии. [lastCursor] — курсор, на
* котором эта строка последний раз менялась. Используется для отсечки
* «состояние на курсоре N» (все строки с `lastCursor.number <= N.number` при
* `lastCursor.epoch == current_epoch`).
*/
@Serializable
data class Message(
val id: MessageId,
val chatId: ChatId,
val role: MessageRole,
val content: String,
val parentId: MessageId?,
val deleted: Boolean,
val lastCursor: Cursor,
val createdAt: Instant,
)
@Serializable
enum class MessageRole { USER, ASSISTANT, TOOL_CALL, TOOL_RESULT, ERROR }
/** Снимок чата в материализованном состоянии. [lastCursor] — курсор, на котором строка менялась последний раз. */
@Serializable
data class Chat(
val id: ChatId,
val title: String,
val createdAt: Instant,
val deleted: Boolean,
val lastCursor: Cursor,
)
/**
* Состояние на момент курсора [lastCursor]: все чаты и сообщения с
* `lastCursor.number <= that.number` и `lastCursor.epoch == that.epoch`.
* Поскольку материализация живёт только в текущей эпохе, эпоха всегда равна
* текущей — см. [CompactionState.currentEpoch].
*/
@Serializable
data class ChatState(
val chats: Map<ChatId, Chat> = emptyMap(),
val messages: Map<ChatId, List<Message>> = emptyMap(),
) {
/**
* Максимальный курсор по всем строкам в **текущей эпохе** (на которой мы
* сейчас находимся). Для пустого состояния — [Cursor.ZERO].
*
* Используется как точка отсечки для снапшота («состояние на курсоре N» =
* строки с `lastCursor.number <= N.number` при `lastCursor.epoch == N.epoch`).
*/
val lastCursor: Cursor
get() {
val epoch = chats.values.firstOrNull()?.lastCursor?.epoch
?: messages.values.firstOrNull { it.isNotEmpty() }?.first()?.lastCursor?.epoch
?: return Cursor.ZERO
var max: ULong = 0uL
for (c in chats.values) {
if (c.lastCursor.epoch != epoch) continue
if (c.lastCursor.number > max) max = c.lastCursor.number
}
for (ms in messages.values) for (m in ms) {
if (m.lastCursor.epoch != epoch) continue
if (m.lastCursor.number > max) max = m.lastCursor.number
}
return Cursor(epoch, max)
}
}
/**
* Снапшот состояния, возвращаемый `GET /sync/state`. Не несёт `upToCursor`
* явно — сервер гарантирует, что все строки в [chats] и [messages] имеют
* `lastCursor.number <= upToCursor.number` в текущей эпохе, и
* `upToCursor` = [ChatState.lastCursor].
*/
@Serializable
data class StateSnapshot(
val state: ChatState,
)
// value class требует @JvmInline на JVM, но @JvmInline недоступен на Native.
// Используем обычный data class — потеряем zero-cost обёртку, но сохраним
// KMP-совместимость. Если/когда JetBrains починит cross-target value classes,
// можно будет вернуть.
@Serializable
data class ChatId(val raw: String) {
override fun toString(): String = raw
}
@Serializable
data class MessageId(val raw: String) {
override fun toString(): String = raw
}
/** Идентификатор локального pending-event'а (UUID строкой). */
@Serializable
data class LocalId(val raw : String) {
override fun toString(): String = raw
}
@@ -0,0 +1,23 @@
package pw.binom.agentik.sync
sealed class SyncException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
class CursorExpiredException(
val requested: Cursor,
val currentEpoch: String,
val minAvailable: Cursor,
val current: Cursor,
val reason: CursorExpiredReason,
) : SyncException(
"cursor $requested expired (reason=$reason, current=$current, minAvailable=$minAvailable)",
)
class EventRejectedException(
val eventId: String,
reason: String,
) : SyncException("event $eventId rejected: $reason")
class SeqMonotonicityViolationException(
val previous: Long,
val attempted: Long,
) : SyncException("seq must be strictly increasing (previous=$previous, attempted=$attempted)")
@@ -0,0 +1,78 @@
package pw.binom.agentik.sync
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Событие в журнале — [DomainEvent] + назначенный сервером [Cursor] (§3.1.1 спеки).
*
* Курсор сервера строго возрастает внутри эпохи (§2.1), никаких дыр. При смене
* эпохи (wipe / restore / миграция) счётчик обнуляется и меняется `epoch`.
*
* `epoch` берётся из [Cursor]; `seq` оставлен как convenience — это
* `cursor.number`, нужен редко (например, для отладки или для индексов в БД).
*/
data class LoggedEvent(
val cursor: Cursor,
val eventId: String,
val payload: DomainEvent,
val createdAt: Instant,
val origin: String? = null,
) {
/** `cursor.number`. Удобно для SQL-предикатов и индексов. */
val seq: ULong get() = cursor.number
}
/**
* Локальное событие, отправленное клиентом, но ещё не подтверждённое сервером
* (§3.2 спеки). Не имеет `cursor` — он появится после `POST /sync/events`.
*
* На клиенте переходит `PENDING → SENT` (получили `cursor`) или `PENDING →
* FAILED` (сервер отверг — клиент откатывает локальное изменение).
*
* `sentCursor` непустой только в статусе [PendingStatus.SENT]: это курсор,
* под которым событие зафиксировано в журнале сервера.
*/
data class PendingEvent(
val localId: LocalId,
val payload: DomainEvent,
val createdAt: Instant,
val status: PendingStatus,
val sentCursor: Cursor? = null,
)
enum class PendingStatus {
/** Создан локально, на сервер ещё не ушёл. */
PENDING,
/** Сервер принял, `cursor` назначен (см. [PendingEvent.sentCursor]). */
SENT,
/** Сервер отверг (валидация / правки). Клиент откатывает локальное изменение. */
FAILED,
}
/**
* Состояние синхронизации клиента (§3.2 спеки). «До какого курсора мы
* синхронизированы» + «когда последний раз был успешный sync».
*
* Курсор `Cursor.ZERO` (т.е. `(epoch, 0)`) означает «ничего не синхронизировано» —
* клиент должен сделать full resync перед подпиской на live.
*/
data class SyncState(
val cursor: Cursor = Cursor.ZERO,
val lastSyncAt: Instant? = null,
)
/**
* Метаданные компакции и эпохи журнала (§3.1.3 спеки).
*
* `minAvailableCursor` — самый старый курсор, который сервер может replay'ить.
* Сервер стирает события с `cursor < minAvailableCursor` через `compact(upTo)`.
*
* `currentEpoch` дублирует [minAvailableCursor.epoch] для удобства: позволяет
* клиенту проверять эпоху без раскрутки курсора.
*/
data class CompactionState(
val currentEpoch: String,
val minAvailableCursor: Cursor,
val lastCompactedAt: Instant?,
)
@@ -0,0 +1,160 @@
package pw.binom.agentik.sync
import kotlin.time.Instant
/**
* Общий интерфейс чтения журнала событий (§4.1 спеки).
*
* `readUpdates(after, limit)` — replay-чтение «события с `cursor > after`,
* не старше `limit` штук». `lastCursor()` — текущая вершина журнала
* (= [EventLogWriter.append] последнего события).
*
* Сервер и клиент имеют одинаковое представление о чтении, различаются
* только операциями записи ([EventLogWriter] vs [EventLogReplica]).
*/
interface EventLog : AutoCloseable {
/**
* Возвращает события `cursor > after` в порядке возрастания `cursor`,
* не более `limit` штук. Если событий больше — на стороне вызывающего
* остаётся `lastCursor()` и он повторяет запрос (см. SyncEngine.sync).
*
* Поведение при `after`:
* - `after.cursor.number < minAvailableCursor.number` при совпадающей
* эпохе → бросает [CursorExpiredException] с [CursorExpiredReason.TOO_OLD].
* - `after.cursor.epoch != currentEpoch` → бросает [CursorExpiredException]
* с [CursorExpiredReason.WRONG_EPOCH].
*/
suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent>
/** Текущая вершина журнала. `Cursor.ZERO` если журнал пуст. */
suspend fun lastCursor(): Cursor
/** Метаданные компакции и текущей эпохи. */
suspend fun compaction(): CompactionState
override fun close()
}
/**
* Серверная сторона журнала (§4.2 спеки). Добавляет атомарную запись,
* компакцию и смену эпохи.
*
* Контракт [append]:
* 1. Назначить `cursor` (монотонно, под мьютексом).
* 2. В одной транзакции записать событие в журнал **и** применить его к
* материализованному состоянию (§2.3 спеки: «materialization is
* consistent with journal»). Если транзакция не удалась — ни событие,
* ни state-row не должны остаться.
*
* Контракт [compact]:
* 1. Удалить события `cursor.number <= upTo.number`.
* 2. Обновить [CompactionState.minAvailableCursor] = `upTo.next()`.
*
* Контракт [beginNewEpoch]:
* 1. Атомарно стереть все события журнала.
* 2. Установить новую текущую эпоху.
* 3. Сбросить `sqlite_sequence` для autoincrement-колонки.
* 4. Вернуть `Cursor(newEpoch, 0)` — вершину «пустого» журнала.
*
* Используется при wipe / restore / миграции на новую схему (§6.3 спеки).
* Клиенты со старой эпохой получат [CursorExpiredException] с reason
* [CursorExpiredReason.WRONG_EPOCH] и обязаны сделать replaceState.
*/
interface EventLogWriter : EventLog {
/** Атомарно записать событие, вернуть его с назначенным `cursor`. */
suspend fun append(event: DomainEvent): LoggedEvent
/** Удалить события `cursor.number <= upTo.number` и сдвинуть minAvailableCursor. */
suspend fun compact(upTo: Cursor)
/**
* Атомарно стереть журнал и начать новую эпоху. Возвращает свежий
* `Cursor(newEpoch, 0)`. Materialized state тоже стирается (вызывающий
* должен заново его наполнить либо сделать replaceState из чтения).
*/
suspend fun beginNewEpoch(newEpoch: String): Cursor
}
/**
* Клиентская сторона журнала (§4.3 спеки). Дополнительно к [EventLog]:
* применяет события сервера к локальной материализации, умеет делать
* `replaceState` при resync и управляет локальной очередью `PendingEvent`.
*
* Контракт [applyRemote]:
* - Событие уже закоммичено сервером — клиент только зеркалит.
* - Идемпотентность по [LoggedEvent.eventId]: повторный apply — no-op.
* - События применяются **в порядке `cursor`**. Если есть пропуск —
* [CursorMonotonicityViolationException] (см. §8.2 спеки).
*
* Контракт [replaceState]:
* - Полная замена локального состояния. Не merge (§2.5 спеки).
* - Курсор после replace = `snapshot.state.lastCursor`.
* - `PendingEvent`'ы **сохраняются** (см. §5.2 шаг 5 — они отправляются
* после resync).
*
* Контракт pending-API:
* - [enqueuePending] — добавить новое локальное событие в очередь.
* - [pendingEvents] — все события со статусом PENDING (для отправки на сервер).
* - [markPendingAsSent] / [markPendingAsFailed] — финализация.
*/
interface EventLogReplica : EventLog {
/** Применить событие от сервера к локальной материализации. */
suspend fun applyRemote(event: LoggedEvent)
/**
* Заменить локальное состояние снапшотом с сервера. Курсор сбрасывается
* на `snapshot.state.lastCursor`.
*/
suspend fun replaceState(snapshot: StateSnapshot)
/** Все pending-события со статусом PENDING. */
suspend fun pendingEvents(): List<PendingEvent>
/** Добавить локальное событие в очередь (статус PENDING). */
suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent
/** Сервер принял событие под [cursor]. */
suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor)
/** Сервер отверг событие. Клиент откатывает локальное изменение. */
suspend fun markPendingAsFailed(localId: LocalId)
/** Локальное состояние синхронизации (`cursor` + `lastSyncAt`). */
suspend fun syncState(): SyncState
}
/**
* Материализованное состояние (§4.1 спеки).
*
* Сервер пишет сюда через [applyOnServer] (атомарно с записью в журнал,
* см. [EventLogWriter.append]). Клиент пишет через [applyOnClient]
* (= [EventLogReplica.applyRemote]). Оба используют один и тот же [Reducer].
*/
interface StateStore : AutoCloseable {
/** Применить событие к состоянию и сохранить (серверная сторона). */
suspend fun applyOnServer(event: LoggedEvent)
/** Применить событие к состоянию и сохранить (клиентская сторона). */
suspend fun applyOnClient(event: LoggedEvent)
/** Полный снапшот состояния (= все строки, `lastCursor` = [ChatState.lastCursor]). */
suspend fun readState(): StateSnapshot
/** Курсор — максимум `lastCursor` по строкам в текущей эпохе (= точка отсечки снапшота). */
suspend fun lastCursor(): Cursor
override fun close()
}
/**
* Редьюсер — чистая функция «событие + старое состояние → новое состояние».
*
* Один и тот же редьюсер работает и на сервере (применяется в транзакции
* `append`), и на клиенте (применяется через `applyRemote`). Это и есть
* «один и тот же код для клиента и сервера» из §0 спеки.
*/
fun interface Reducer<S> {
fun reduce(state: S, event: LoggedEvent): S
}
@@ -0,0 +1,108 @@
package pw.binom.agentik.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* Алгоритм синхронизации (§5.2 спеки). Один объект живёт на клиенте, поверх
* [EventLogReplica] и [SyncTransport]. Два тела:
*
* - [sync]: однократный catchup + post pending. Возвращает [SyncReport]
* с числом apply'нутых событий и финальным `cursor`.
* - [liveFromCurrent]: долгоживущий Flow, подписывается на сервер и применяет
* каждое событие к локальной материализации через [EventLogReplica.applyRemote].
* При разрыве/исключении Flow завершается — вызывающий решает, что делать
* (типичный паттерн: `retry { liveFromCurrent() }`).
*
* [sync] идемпотентен — повторный вызов ничего не сломает: догрузит только
* новые события, переотправит только pending.
*/
class SyncEngine(
private val replica: EventLogReplica,
private val transport: SyncTransport,
) {
/**
* Однократный sync: replay-читать с `after = lastCursor`, разрешить
* протухший курсор через replaceState, отправить pending, дождаться
* подтверждений.
*
* Возвращает [SyncReport] для тестов и UI-метрик.
*/
suspend fun sync(pageSize: Int = 1024): SyncReport {
var applied = 0
var resolvedGap = false
// ── Шаги 1-4 спеки: catchup ────────────────────────────────────────
while (true) {
val after = replica.syncState().cursor
val resp = transport.fetchUpdates(after = after, limit = pageSize)
when (resp) {
is UpdatesResponse.Ok -> {
if (resp.events.isEmpty() && !resp.hasMore) break
for (e in resp.events) {
replica.applyRemote(e)
applied++
}
if (!resp.hasMore) break
}
is UpdatesResponse.CursorExpired -> {
// ── Шаг 3 спеки: full resync ───────────────────────────
val state = transport.fetchState()
if (state !is StateResponse.Ok) error("StateResponse is always Ok in spec")
replica.replaceState(state.snapshot)
resolvedGap = true
break
}
}
}
// ── Шаги 5-6 спеки: post pending ──────────────────────────────────
val pending = replica.pendingEvents()
var posted = 0
var failed = 0
if (pending.isNotEmpty()) {
val results = transport.postPending(pending)
for (r in results) {
when (r) {
is PostResult.Accepted -> {
replica.markPendingAsSent(r.localId, r.cursor)
posted++
}
is PostResult.Rejected -> {
replica.markPendingAsFailed(r.localId)
failed++
}
}
}
}
return SyncReport(
appliedRemote = applied,
postedPending = posted,
failedPending = failed,
resolvedCursorGap = resolvedGap,
finalCursor = replica.syncState().cursor,
)
}
/**
* Live sync: долгоживущий Flow. Подписывается на [SyncTransport.subscribeLive]
* и применяет каждое событие через [EventLogReplica.applyRemote].
*/
fun liveFromCurrent(after: Cursor? = null): Flow<LoggedEvent> = flow {
val start = after ?: replica.syncState().cursor
transport.subscribeLive(start).collect { e ->
replica.applyRemote(e)
emit(e)
}
}
}
data class SyncReport(
val appliedRemote: Int,
val postedPending: Int,
val failedPending: Int,
val resolvedCursorGap: Boolean,
val finalCursor: Cursor,
)
@@ -0,0 +1,119 @@
package pw.binom.agentik.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Транспорт синхронизации — сетевой слой между клиентом и сервером (§5.1 спеки).
*
* Спека описывает четыре endpoint'а: `GET /sync/state`, `GET /sync/updates`,
* `WS /sync/live`, `POST /sync/events`. Здесь они выражены как четыре метода
* интерфейса — конкретный транспорт (HTTP+WS, in-process, mock) реализует их
* по-своему.
*
* Контракт:
* - [fetchUpdates] может вернуть [UpdatesResponse.CursorExpired] с reason
* [CursorExpiredReason.WRONG_EPOCH] (epoch mismatch) или
* [CursorExpiredReason.TOO_OLD] (компакция переехала курсор).
* - [subscribeLive] — холодный Flow, отдаёт события `cursor > after` и
* завершается при разрыве (см. §5.2 шаг 6.2).
* - [postPending] — список результатов той же длины, что и вход; порядок
* сохраняется (клиент матчит по индексу / [LocalId]).
*/
interface SyncTransport : AutoCloseable {
suspend fun fetchUpdates(after: Cursor, limit: Int): UpdatesResponse
suspend fun fetchState(): StateResponse
suspend fun postPending(events: List<PendingEvent>): List<PostResult>
fun subscribeLive(after: Cursor): Flow<LoggedEvent>
override fun close()
}
sealed interface UpdatesResponse {
/** Успешный replay — пачка событий + индикатор «есть ещё» + текущий курсор. */
data class Ok(
val events: List<LoggedEvent>,
val hasMore: Boolean,
val currentCursor: Cursor,
) : UpdatesResponse
/**
* Курсор протух. Клиент обязан сделать full resync через
* [SyncTransport.fetchState] и [EventLogReplica.replaceState].
*
* - [reason] = [CursorExpiredReason.WRONG_EPOCH] → сервер сменил эпоху,
* клиент должен пересинхронизироваться с нуля.
* - [reason] = [CursorExpiredReason.TOO_OLD] → компакция удалила старые
* события, клиент должен пересинхронизироваться с нуля.
*/
data class CursorExpired(
val requested: Cursor,
val minAvailable: Cursor,
val current: Cursor,
val reason: CursorExpiredReason,
) : UpdatesResponse
}
sealed interface StateResponse {
/**
* Снапшот состояния + курсор, до которого материализация консистентна.
* Сервер гарантирует, что все строки в `snapshot.state.chats/messages`
* имеют `lastCursor.number <= upToCursor.number` в текущей эпохе, и
* `upToCursor` = `snapshot.state.lastCursor`.
*/
data class Ok(
val snapshot: StateSnapshot,
val upToCursor: Cursor,
val currentCursor: Cursor,
) : StateResponse
}
/**
* Результат `POST /sync/events` для одного pending-event'а.
*
* Идемпотентность по `eventId`: сервер может ответить [Accepted] уже
* назначенным ранее `cursor` (повторная отправка того же `eventId`).
*/
sealed interface PostResult {
data class Accepted(val localId: LocalId, val cursor: Cursor) : PostResult
data class Rejected(val localId: LocalId, val reason: String) : PostResult
}
/**
* Запуск ассистента (§5.4 спеки).
*
* Клиент подписывается на поток событий и применяет их так же, как события
* от сервера. Локальный ассистент порождает `PendingEvent`, который потом
* уходит на сервер и подтверждается.
*
* Контракт:
* - `run(input)` запускает генерацию, возвращает холодный `Flow<DomainEvent>`.
* - Каждое событие несёт **стабильный** `eventId` (для идемпотентности).
* - `Flow` завершается, когда ассистент закончил генерацию. Ошибка внутри
* потока пробрасывается вызывающему.
*/
fun interface Assistant {
fun run(input: RunInput): Flow<DomainEvent>
}
/**
* Параметры запуска ассистента (§4.4 спеки).
*
* - [chatId] — в каком чате генерируется ответ.
* - [userMessageId] — id только что созданного user-message'а
* (ассистент может сослаться на него в reasoning).
* - [reasoning] — что просит пользователь (например, текст сообщения).
* - [parentId] — какой message перегенерь (ветка), `null` для линейного ответа.
*/
@Serializable
data class RunInput(
val chatId: ChatId,
val userMessageId: MessageId,
val reasoning: String,
val parentId: MessageId? = null,
)
@@ -0,0 +1,41 @@
package pw.binom.agentik.sync.ksqlite
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import pw.binom.agentik.sync.DomainEvent
/**
* JSON-сериализация [DomainEvent] для хранения в SQLite.
*
* Все события полиморфно сериализуются с дискриминантом `type` (см.
* [DomainEvent] — `@SerialName(...)` на каждом подтипе). Полезная нагрузка —
* полный payload события (§2.4 спеки: «самодостаточны»).
*/
object EventCodec {
val json: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
classDiscriminator = "type"
serializersModule = SerializersModule {
polymorphic(DomainEvent::class) {
subclass(DomainEvent.ChatCreated::class)
subclass(DomainEvent.ChatRenamed::class)
subclass(DomainEvent.ChatDeleted::class)
subclass(DomainEvent.MessageAppended::class)
subclass(DomainEvent.MessageEdited::class)
subclass(DomainEvent.MessageDeleted::class)
subclass(DomainEvent.ToolCallRequested::class)
subclass(DomainEvent.ToolCallSucceeded::class)
subclass(DomainEvent.ToolCallFailed::class)
}
}
}
fun encode(event: DomainEvent): String = json.encodeToString(DomainEvent.serializer(), event)
fun decode(jsonText: String): DomainEvent =
json.decodeFromString(DomainEvent.serializer(), jsonText)
}
@@ -0,0 +1,169 @@
package pw.binom.agentik.sync.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц / колонок / индексов для ksqlite-бэкенда `:sync-core`.
*
* Реализует схему из §3.1 / §3.2 спеки с курсором `(epoch, number)`
* (§3.1.3). Семь таблиц:
* - `event_log` — append-only журнал (§3.1.1), `seq` монотонен
* внутри эпохи, `epoch` — safety-belt на случай гонок при wipe;
* - `chat_state` — материализация чатов (§3.1.2);
* - `message_state` — материализация сообщений (§3.1.2);
* - `compaction_state` — метаданные компакции + текущая эпоха (§3.1.3),
* одна строка;
* - `sync_state` — клиентский `cursor` (§3.2), одна строка;
* - `pending_event` — очередь pending-событий (§3.2);
* - `processed_event_id` — для идемпотентности по `eventId` (§8.1).
*
* В БД эпоха и номер курсора лежат в **отдельных колонках**: `epoch TEXT`
* (если есть в таблице), `seq INTEGER` для `last_seq` / `min_available_seq`.
* Курсор как объект [pw.binom.agentik.sync.Cursor] собирается при чтении.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы.
*/
object Schema {
const val TABLE_EVENT_LOG = "sync_event_log"
const val TABLE_CHAT_STATE = "sync_chat_state"
const val TABLE_MESSAGE_STATE = "sync_message_state"
const val TABLE_COMPACTION_STATE = "sync_compaction_state"
const val TABLE_SYNC_STATE = "sync_sync_state"
const val TABLE_PENDING_EVENT = "sync_pending_event"
const val TABLE_PROCESSED_EVENT_ID = "sync_processed_event_id"
const val COL_SEQ = "seq"
const val COL_EPOCH = "epoch"
const val COL_EVENT_ID = "event_id"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
const val COL_ORIGIN = "origin"
const val COL_CHAT_ID = "chat_id"
const val COL_TITLE = "title"
const val COL_DELETED = "deleted"
const val COL_LAST_SEQ = "last_seq"
const val COL_MESSAGE_ID = "message_id"
const val COL_ROLE = "role"
const val COL_CONTENT = "content"
const val COL_PARENT_ID = "parent_id"
const val COL_LOCAL_ID = "local_id"
const val COL_TYPE = "type"
const val COL_STATUS = "status"
const val COL_SENT_CURSOR = "sent_cursor"
const val COL_MIN_AVAILABLE_SEQ = "min_available_seq"
const val COL_LAST_COMPACTED_AT = "last_compacted_at"
const val COL_SYNC_CURSOR = "cursor"
const val COL_LAST_SYNC_AT = "last_sync_at"
/** Эпоха по умолчанию для новой БД. */
const val DEFAULT_EPOCH = "v1"
private val eventLogDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_EVENT_LOG (
$COL_SEQ INTEGER PRIMARY KEY AUTOINCREMENT,
$COL_EPOCH TEXT NOT NULL,
$COL_EVENT_ID TEXT NOT NULL UNIQUE,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_ORIGIN TEXT
);
"""
private val chatStateDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CHAT_STATE (
$COL_CHAT_ID TEXT PRIMARY KEY,
$COL_TITLE TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_DELETED INTEGER NOT NULL DEFAULT 0,
$COL_LAST_SEQ INTEGER NOT NULL
);
"""
private val messageStateDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE_STATE (
$COL_MESSAGE_ID TEXT PRIMARY KEY,
$COL_CHAT_ID TEXT NOT NULL,
$COL_ROLE TEXT NOT NULL,
$COL_CONTENT TEXT NOT NULL,
$COL_PARENT_ID TEXT,
$COL_DELETED INTEGER NOT NULL DEFAULT 0,
$COL_LAST_SEQ INTEGER NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
"""
private val messageStateIdx = """
CREATE INDEX IF NOT EXISTS idx_message_state_chat_seq
ON $TABLE_MESSAGE_STATE($COL_CHAT_ID, $COL_LAST_SEQ);
"""
private val compactionStateDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_COMPACTION_STATE (
id INTEGER PRIMARY KEY CHECK (id = 1),
$COL_EPOCH TEXT NOT NULL,
$COL_MIN_AVAILABLE_SEQ INTEGER NOT NULL DEFAULT 0,
$COL_LAST_COMPACTED_AT INTEGER
);
"""
private val syncStateDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_SYNC_STATE (
id INTEGER PRIMARY KEY CHECK (id = 1),
$COL_SYNC_CURSOR TEXT NOT NULL,
$COL_LAST_SYNC_AT INTEGER
);
"""
private val pendingEventDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_PENDING_EVENT (
$COL_LOCAL_ID TEXT PRIMARY KEY,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_STATUS TEXT NOT NULL,
$COL_SENT_CURSOR TEXT
);
"""
private val processedEventIdDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_PROCESSED_EVENT_ID (
$COL_EVENT_ID TEXT PRIMARY KEY
);
"""
private val eventLogEpochIdx = """
CREATE INDEX IF NOT EXISTS idx_event_log_epoch_seq
ON $TABLE_EVENT_LOG($COL_EPOCH, $COL_SEQ);
"""
/**
* Создаёт все таблицы и индексы (идемпотентно). Миграция для schema v1
* не нужна; future версии должны будут добавлять миграционный путь.
*
* Если таблица `compaction_state` создаётся впервые — синглтон-строка
* инициализируется эпохой [DEFAULT_EPOCH] и `min_available_seq = 0`.
* Аналогично `sync_state` инициализируется курсором `Cursor.ZERO`.
*
* Вызывающий отвечает за вызов под `BEGIN/COMMIT` или одиночно — обе формы
* безопасны (CREATE IF NOT EXISTS + INSERT OR IGNORE).
*/
fun migrate(conn: SQLiteConnection) {
conn.exec(eventLogDdl)
conn.exec(chatStateDdl)
conn.exec(messageStateDdl)
conn.exec(messageStateIdx)
conn.exec(compactionStateDdl)
conn.exec(syncStateDdl)
conn.exec(pendingEventDdl)
conn.exec(processedEventIdDdl)
conn.exec(eventLogEpochIdx)
// Гарантируем, что singleton-строки существуют.
conn.exec("INSERT OR IGNORE INTO $TABLE_COMPACTION_STATE (id, $COL_EPOCH, $COL_MIN_AVAILABLE_SEQ) VALUES (1, '$DEFAULT_EPOCH', 0)")
conn.exec("INSERT OR IGNORE INTO $TABLE_SYNC_STATE (id, $COL_SYNC_CURSOR) VALUES (1, '${pw.binom.agentik.sync.Cursor.ZERO.toRawString()}')")
}
}
@@ -0,0 +1,385 @@
package pw.binom.agentik.sync.ksqlite
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import pw.binom.agentik.sync.CompactionState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.CursorExpiredException
import pw.binom.agentik.sync.CursorExpiredReason
import pw.binom.agentik.sync.DomainEvent
import pw.binom.agentik.sync.EventLog
import pw.binom.agentik.sync.EventLogWriter
import pw.binom.agentik.sync.LoggedEvent
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
import kotlin.time.Instant
/**
* ksqlite-реализация [EventLog] + [EventLogWriter].
*
* Контракт [append]:
* 1. BEGIN
* 2. SELECT min(seq) от processed_event_ids по eventId — если есть, вернуть
* уже закоммиченное событие (идемпотентность по §8.1 спеки).
* 3. INSERT INTO event_log — SQLite назначает `seq` как ROWID.
* 4. INSERT INTO processed_event_id.
* 5. COMMIT.
* 6. Опционально: нотификация через [appendFlow] для live-подписчиков.
*
* Контракт [compact]:
* 1. DELETE FROM event_log WHERE seq <= upTo.number.
* 2. UPDATE compaction_state SET min_available_seq = upTo.number + 1.
* 3. `processed_event_id` НЕ трогаем (см. §8.1 — это идемпотентность, она
* должна переживать компакцию).
*
* Контракт [beginNewEpoch]:
* 1. DELETE FROM event_log.
* 2. UPDATE compaction_state SET epoch = ?, min_available_seq = 0.
* 3. DELETE FROM sqlite_sequence WHERE name='sync_event_log'.
* 4. Очистить state-таблицы (вызывающий должен пересинхронизироваться).
*
* `seq INTEGER PRIMARY KEY AUTOINCREMENT` гарантирует монотонность внутри эпохи.
* После beginNewEpoch счётчик сбрасывается через `DELETE FROM sqlite_sequence`.
*/
class SqliteEventLog(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean = false,
) : EventLogWriter {
/** Broadcast append-канал — для live-подписчиков. */
private val appendChannel: MutableSharedFlow<LoggedEvent> = MutableSharedFlow(
replay = 0,
extraBufferCapacity = 64,
)
/** Публичный read-only view. */
val appendFlow: SharedFlow<LoggedEvent> get() = appendChannel
private val appendListeners: MutableList<suspend (LoggedEvent) -> Unit> = mutableListOf()
fun onAppend(l: suspend (LoggedEvent) -> Unit) {
appendListeners.add(l)
}
private suspend fun notifyAppend(l: LoggedEvent) {
for (f in appendListeners.toList()) f(l)
}
private val mutex = Mutex()
private val selectByEventIdStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT $COL_SEQ, $COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN
FROM ${Schema.TABLE_EVENT_LOG}
WHERE $COL_EVENT_ID = ?
""".trimIndent()
)
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_EVENT_LOG}
($COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN)
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val insertProcessedStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT OR IGNORE INTO ${Schema.TABLE_PROCESSED_EVENT_ID} ($COL_EVENT_ID) VALUES (?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT $COL_SEQ, $COL_EPOCH, $COL_EVENT_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_ORIGIN
FROM ${Schema.TABLE_EVENT_LOG}
WHERE $COL_SEQ > ?
ORDER BY $COL_SEQ ASC
LIMIT ?
""".trimIndent()
)
private val maxSeqStmt: SQLitePreparedStatement = connection.prepare(
"SELECT MAX($COL_SEQ) FROM ${Schema.TABLE_EVENT_LOG}"
)
private val currentEpochStmt: SQLitePreparedStatement = connection.prepare(
"SELECT $COL_EPOCH, $COL_MIN_AVAILABLE_SEQ, $COL_LAST_COMPACTED_AT FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1"
)
private val compactStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_EVENT_LOG} WHERE $COL_SEQ <= ?"
)
private val updateMinAvailableStmt: SQLitePreparedStatement = connection.prepare(
"""
UPDATE ${Schema.TABLE_COMPACTION_STATE}
SET $COL_MIN_AVAILABLE_SEQ = ?, $COL_LAST_COMPACTED_AT = ?
WHERE id = 1
""".trimIndent()
)
private val updateEpochStmt: SQLitePreparedStatement = connection.prepare(
"""
UPDATE ${Schema.TABLE_COMPACTION_STATE}
SET $COL_EPOCH = ?, $COL_MIN_AVAILABLE_SEQ = 0
WHERE id = 1
""".trimIndent()
)
private val wipeEventLogStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_EVENT_LOG}"
)
private val resetEventLogSeqStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM sqlite_sequence WHERE name = ?"
)
private val lastInsertRowIdStmt: SQLitePreparedStatement = connection.prepare(
"SELECT last_insert_rowid()"
)
init {
// Schema.migrate должен быть вызван владельцем connection'а (см. SqliteSyncBundle).
}
// ─── EventLog ───────────────────────────────────────────────────────────
override suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent> = withContext(Dispatchers.Default) {
mutex.withLock {
val (currentEpoch, minAvailable) = currentEpochWithMinAvailable()
checkNotExpired(after, currentEpoch, minAvailable, currentCursorInternal(currentEpoch))
listStmt.reset()
listStmt.clearBindings()
listStmt.bindLong(1, after.number.toLong())
listStmt.bindLong(2, limit.toLong())
val out = mutableListOf<LoggedEvent>()
listStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(rs.toLoggedEvent())
}
}
out
}
}
override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) {
mutex.withLock {
val (epoch, _) = currentEpochWithMinAvailable()
currentCursorInternal(epoch)
}
}
override suspend fun compaction(): CompactionState = withContext(Dispatchers.Default) {
mutex.withLock {
currentEpochStmt.reset()
currentEpochStmt.clearBindings()
currentEpochStmt.executeQuery().use { rs ->
check(rs.next()) { "compaction_state must have one row" }
val epoch = rs.getText(0) ?: error("epoch is null")
val minSeq = rs.getLong(1) ?: 0L
val compactedAtMs = rs.getLong(2)
CompactionState(
currentEpoch = epoch,
minAvailableCursor = Cursor(epoch, minSeq.toULong()),
lastCompactedAt = compactedAtMs?.let { Instant.fromEpochMilliseconds(it) },
)
}
}
}
// ─── EventLogWriter ────────────────────────────────────────────────────
override suspend fun append(event: DomainEvent): LoggedEvent = withContext(Dispatchers.Default) {
mutex.withLock {
// 1. Идемпотентность по eventId.
selectByEventIdStmt.reset()
selectByEventIdStmt.clearBindings()
selectByEventIdStmt.bindText(1, event.eventId)
val existing: LoggedEvent? = selectByEventIdStmt.executeQuery().use { rs ->
if (rs.next()) rs.toLoggedEvent() else null
}
if (existing != null) return@withContext existing
// 2. Текущая эпоха из compaction_state.
val (epoch, _) = currentEpochWithMinAvailable()
val nowMs = Clock.System.now().toEpochMilliseconds()
// 3. INSERT INTO event_log.
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, epoch)
insertStmt.bindText(2, event.eventId)
insertStmt.bindText(3, EventCodec.encode(event))
insertStmt.bindLong(4, nowMs)
insertStmt.bindNull(5)
insertStmt.executeUpdate()
val newSeq: Long = lastInsertRowIdStmt.executeQuery().use { rs ->
check(rs.next()) { "last_insert_rowid() must return a row" }
rs.getLong(0) ?: error("last_insert_rowid() is null")
}
// 4. INSERT OR IGNORE INTO processed_event_id.
insertProcessedStmt.reset()
insertProcessedStmt.clearBindings()
insertProcessedStmt.bindText(1, event.eventId)
insertProcessedStmt.executeUpdate()
val logged = LoggedEvent(
cursor = Cursor(epoch, newSeq.toULong()),
eventId = event.eventId,
payload = event,
createdAt = Instant.fromEpochMilliseconds(nowMs),
origin = null,
)
appendChannel.tryEmit(logged)
notifyAppend(logged)
logged
}
}
override suspend fun compact(upTo: Cursor): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
val (currentEpoch, _) = currentEpochWithMinAvailable()
require(upTo.epoch == currentEpoch) {
"compact(upTo=$upTo) requires epoch == $currentEpoch"
}
compactStmt.reset()
compactStmt.clearBindings()
compactStmt.bindLong(1, upTo.number.toLong())
compactStmt.executeUpdate()
val nowMs = Clock.System.now().toEpochMilliseconds()
updateMinAvailableStmt.reset()
updateMinAvailableStmt.clearBindings()
updateMinAvailableStmt.bindLong(1, (upTo.number + 1uL).toLong())
updateMinAvailableStmt.bindLong(2, nowMs)
updateMinAvailableStmt.executeUpdate()
}
}
override suspend fun beginNewEpoch(newEpoch: String): Cursor = withContext(Dispatchers.Default) {
require(newEpoch.isNotEmpty()) { "epoch must be non-empty" }
require('-' !in newEpoch) { "epoch must not contain '-': \"$newEpoch\"" }
mutex.withLock {
// 1. Стираем все события.
wipeEventLogStmt.reset()
wipeEventLogStmt.clearBindings()
wipeEventLogStmt.executeUpdate()
// 2. Сбрасываем autoincrement для event_log.
resetEventLogSeqStmt.reset()
resetEventLogSeqStmt.clearBindings()
resetEventLogSeqStmt.bindText(1, Schema.TABLE_EVENT_LOG)
resetEventLogSeqStmt.executeUpdate()
// 3. Меняем текущую эпоху и обнуляем min_available_seq.
updateEpochStmt.reset()
updateEpochStmt.clearBindings()
updateEpochStmt.bindText(1, newEpoch)
updateEpochStmt.executeUpdate()
Cursor(newEpoch, 0uL)
}
}
// ─── helpers ────────────────────────────────────────────────────────────
private fun currentEpochWithMinAvailable(): Pair<String, ULong> {
currentEpochStmt.reset()
currentEpochStmt.clearBindings()
currentEpochStmt.executeQuery().use { rs ->
check(rs.next()) { "compaction_state must have one row" }
val epoch = rs.getText(0) ?: error("epoch is null")
val minSeq = rs.getLong(1) ?: 0L
return epoch to minSeq.toULong()
}
}
private fun currentCursorInternal(epoch: String): Cursor {
maxSeqStmt.reset()
maxSeqStmt.clearBindings()
maxSeqStmt.executeQuery().use { rs ->
check(rs.next()) { "MAX must return one row" }
val max = rs.getLong(0)
return if (max == null) Cursor(epoch, 0uL) else Cursor(epoch, max.toULong())
}
}
private fun checkNotExpired(
requested: Cursor,
currentEpoch: String,
minAvailable: ULong,
current: Cursor,
) {
if (requested.epoch != currentEpoch) {
throw CursorExpiredException(
requested = requested,
currentEpoch = currentEpoch,
minAvailable = Cursor(currentEpoch, minAvailable),
current = current,
reason = CursorExpiredReason.WRONG_EPOCH,
)
}
if (requested.number < minAvailable) {
throw CursorExpiredException(
requested = requested,
currentEpoch = currentEpoch,
minAvailable = Cursor(currentEpoch, minAvailable),
current = current,
reason = CursorExpiredReason.TOO_OLD,
)
}
}
private fun pw.binom.db.ksqlite.SQLiteResultSet.toLoggedEvent(): LoggedEvent {
val seq = getLong(0) ?: error("seq is null")
val epoch = getText(1) ?: error("epoch is null")
return LoggedEvent(
cursor = Cursor(epoch, seq.toULong()),
eventId = getText(2) ?: error("event_id is null"),
payload = EventCodec.decode(getText(3) ?: error("payload_json is null")),
createdAt = Instant.fromEpochMilliseconds(getLong(4) ?: error("created_at is null")),
origin = getText(5),
)
}
fun closeStmts() {
selectByEventIdStmt.close()
insertStmt.close()
insertProcessedStmt.close()
listStmt.close()
maxSeqStmt.close()
currentEpochStmt.close()
compactStmt.close()
updateMinAvailableStmt.close()
updateEpochStmt.close()
wipeEventLogStmt.close()
resetEventLogSeqStmt.close()
lastInsertRowIdStmt.close()
if (ownsConnection) {
connection.close()
}
}
override fun close() = closeStmts()
companion object {
// ── Local column-name aliases ───────────────────────────────────────
private const val COL_SEQ = Schema.COL_SEQ
private const val COL_EPOCH = Schema.COL_EPOCH
private const val COL_EVENT_ID = Schema.COL_EVENT_ID
private const val COL_PAYLOAD_JSON = Schema.COL_PAYLOAD_JSON
private const val COL_CREATED_AT = Schema.COL_CREATED_AT
private const val COL_ORIGIN = Schema.COL_ORIGIN
private const val COL_MIN_AVAILABLE_SEQ = Schema.COL_MIN_AVAILABLE_SEQ
private const val COL_LAST_COMPACTED_AT = Schema.COL_LAST_COMPACTED_AT
fun memory(name: String? = null): SqliteEventLog =
SqliteEventLog(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
}
@@ -0,0 +1,319 @@
package pw.binom.agentik.sync.ksqlite
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.DomainEvent
import pw.binom.agentik.sync.EventLogReplica
import pw.binom.agentik.sync.LocalId
import pw.binom.agentik.sync.LoggedEvent
import pw.binom.agentik.sync.PendingEvent
import pw.binom.agentik.sync.PendingStatus
import pw.binom.agentik.sync.SeqMonotonicityViolationException
import pw.binom.agentik.sync.StateSnapshot
import pw.binom.agentik.sync.SyncState
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
import kotlin.time.Instant
/**
* ksqlite-реализация [EventLogReplica] (клиентская сторона).
*
* Локальный event-log на клиенте **не нужен** — это серверная абстракция.
* Клиент держит:
* - материализацию в [SqliteStateStore];
* - sync_state (`cursor`, `last_sync_at`) в singleton-row;
* - pending_event (очередь);
* - processed_event_id (идемпотентность applyRemote).
*
* `readUpdates` на клиенте возвращает **пустой список** — replay-логики на
* стороне клиента нет, она вся делается через [transport.fetchUpdates].
* [lastCursor] читается из sync_state.
*
* [applyRemote] оптимистично применяет событие к state через reducer и
* записывает в processed_event_id (идемпотентность). Если `event.cursor`
* вне текущей эпохи — клиент должен сделать replaceState (см. SyncEngine).
*
* [enqueuePending] применяет событие оптимистично (как будто оно уже в
* журнале) и кладёт в pending_event с status=PENDING. Назначаемый `cursor`
* для оптимистичного применения — это `lastCursor().next()`; реальный
* серверный cursor перезапишется при [markPendingAsSent].
*/
class SqliteEventLogReplica(
private val connection: SQLiteConnection,
private val stateStore: SqliteStateStore,
) : EventLogReplica {
private val mutex = Mutex()
private val updateSyncStateStmt: SQLitePreparedStatement = connection.prepare(
"""
UPDATE ${Schema.TABLE_SYNC_STATE}
SET $COL_SYNC_CURSOR = ?, $COL_LAST_SYNC_AT = ?
WHERE id = 1
""".trimIndent()
)
private val selectSyncStateStmt: SQLitePreparedStatement = connection.prepare(
"SELECT $COL_SYNC_CURSOR, $COL_LAST_SYNC_AT FROM ${Schema.TABLE_SYNC_STATE} WHERE id = 1"
)
private val insertProcessedStmt: SQLitePreparedStatement = connection.prepare(
"INSERT OR IGNORE INTO ${Schema.TABLE_PROCESSED_EVENT_ID} ($COL_EVENT_ID) VALUES (?)"
)
private val checkEventIdStmt: SQLitePreparedStatement = connection.prepare(
"SELECT 1 FROM ${Schema.TABLE_PROCESSED_EVENT_ID} WHERE $COL_EVENT_ID = ? LIMIT 1"
)
private val selectPendingStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT $COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS, $COL_SENT_CURSOR
FROM ${Schema.TABLE_PENDING_EVENT}
WHERE $COL_STATUS = ?
ORDER BY $COL_CREATED_AT ASC
""".trimIndent()
)
private val selectAllPendingStmt: SQLitePreparedStatement = connection.prepare(
"SELECT $COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS, $COL_SENT_CURSOR FROM ${Schema.TABLE_PENDING_EVENT}"
)
private val insertPendingStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_PENDING_EVENT}
($COL_LOCAL_ID, $COL_PAYLOAD_JSON, $COL_CREATED_AT, $COL_STATUS)
VALUES (?, ?, ?, ?)
""".trimIndent()
)
private val updatePendingStatusStmt: SQLitePreparedStatement = connection.prepare(
"""
UPDATE ${Schema.TABLE_PENDING_EVENT}
SET $COL_STATUS = ?, $COL_SENT_CURSOR = ?
WHERE $COL_LOCAL_ID = ?
""".trimIndent()
)
// ─── EventLog ───────────────────────────────────────────────────────────
override suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent> = emptyList()
override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) {
mutex.withLock { readSyncStateLocked().cursor }
}
override suspend fun compaction() = stateStore.compaction()
// ─── EventLogReplica ───────────────────────────────────────────────────
override suspend fun applyRemote(event: LoggedEvent): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
// 1. Идемпотентность по eventId.
if (isEventIdProcessed(event.eventId)) return@withContext
// 2. Монотонность в текущей эпохе.
val currentCursor = readSyncStateCursorLocked()
if (event.cursor.epoch != currentCursor.epoch) {
throw SeqMonotonicityViolationException(
previous = currentCursor.number.toLong(),
attempted = event.cursor.number.toLong(),
)
}
if (event.cursor.number <= currentCursor.number) {
throw SeqMonotonicityViolationException(
previous = currentCursor.number.toLong(),
attempted = event.cursor.number.toLong(),
)
}
// 3. Помечаем как processed.
insertProcessedStmt.reset()
insertProcessedStmt.clearBindings()
insertProcessedStmt.bindText(1, event.eventId)
insertProcessedStmt.executeUpdate()
// 4. Применяем через state store.
stateStore.applyOnClient(event)
// 5. Обновляем sync_state.cursor.
updateSyncStateStmt.reset()
updateSyncStateStmt.clearBindings()
updateSyncStateStmt.bindText(1, event.cursor.toRawString())
updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds())
updateSyncStateStmt.executeUpdate()
}
}
/** Проверяет, был ли eventId уже записан в processed_event_id. */
private fun isEventIdProcessed(eventId: String): Boolean {
checkEventIdStmt.reset()
checkEventIdStmt.clearBindings()
checkEventIdStmt.bindText(1, eventId)
checkEventIdStmt.executeQuery().use { rs ->
return rs.next()
}
}
override suspend fun replaceState(snapshot: StateSnapshot): Unit = withContext(Dispatchers.Default) {
stateStore.replaceState(snapshot)
mutex.withLock {
updateSyncStateStmt.reset()
updateSyncStateStmt.clearBindings()
updateSyncStateStmt.bindText(1, snapshot.state.lastCursor.toRawString())
updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds())
updateSyncStateStmt.executeUpdate()
}
}
override suspend fun pendingEvents(): List<PendingEvent> = withContext(Dispatchers.Default) {
selectPendingStmt.reset()
selectPendingStmt.clearBindings()
selectPendingStmt.bindText(1, PendingStatus.PENDING.name)
val out = mutableListOf<PendingEvent>()
selectPendingStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toPending())
}
out
}
/** Все pending-события (для тестов). */
suspend fun allPending(): List<PendingEvent> = withContext(Dispatchers.Default) {
selectAllPendingStmt.reset()
selectAllPendingStmt.clearBindings()
val out = mutableListOf<PendingEvent>()
selectAllPendingStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toPending())
}
out
}
override suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent = withContext(Dispatchers.Default) {
mutex.withLock {
val pe = PendingEvent(
localId = LocalId(payload.eventId),
payload = payload,
createdAt = createdAt,
status = PendingStatus.PENDING,
)
insertPendingStmt.reset()
insertPendingStmt.clearBindings()
insertPendingStmt.bindText(1, pe.localId.raw)
insertPendingStmt.bindText(2, EventCodec.encode(payload))
insertPendingStmt.bindLong(3, createdAt.toEpochMilliseconds())
insertPendingStmt.bindText(4, PendingStatus.PENDING.name)
insertPendingStmt.executeUpdate()
// Оптимистичное применение к материализации с фиктивным cursor = lastCursor().next().
val fakeCursor = stateStore.lastCursor().next()
val fakeLogged = LoggedEvent(
cursor = fakeCursor,
eventId = payload.eventId,
payload = payload,
createdAt = createdAt,
)
stateStore.applyOnClient(fakeLogged)
pe
}
}
override suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor): Unit = withContext(Dispatchers.Default) {
updatePendingStatusStmt.reset()
updatePendingStatusStmt.clearBindings()
updatePendingStatusStmt.bindText(1, PendingStatus.SENT.name)
updatePendingStatusStmt.bindText(2, cursor.toRawString())
updatePendingStatusStmt.bindText(3, localId.raw)
updatePendingStatusStmt.executeUpdate()
mutex.withLock {
updateSyncStateStmt.reset()
updateSyncStateStmt.clearBindings()
updateSyncStateStmt.bindText(1, cursor.toRawString())
updateSyncStateStmt.bindLong(2, Clock.System.now().toEpochMilliseconds())
updateSyncStateStmt.executeUpdate()
}
}
override suspend fun markPendingAsFailed(localId: LocalId): Unit = withContext(Dispatchers.Default) {
updatePendingStatusStmt.reset()
updatePendingStatusStmt.clearBindings()
updatePendingStatusStmt.bindText(1, PendingStatus.FAILED.name)
updatePendingStatusStmt.bindNull(2)
updatePendingStatusStmt.bindText(3, localId.raw)
updatePendingStatusStmt.executeUpdate()
}
override suspend fun syncState(): SyncState = withContext(Dispatchers.Default) {
mutex.withLock { readSyncStateLocked() }
}
// ─── helpers ────────────────────────────────────────────────────────────
private fun readSyncStateLocked(): SyncState {
selectSyncStateStmt.reset()
selectSyncStateStmt.clearBindings()
selectSyncStateStmt.executeQuery().use { rs ->
check(rs.next())
val cursor = Cursor.fromString(rs.getText(0) ?: error("cursor is null"))
val lastSyncAtMs = rs.getLong(1)
return SyncState(
cursor = cursor,
lastSyncAt = lastSyncAtMs?.let { Instant.fromEpochMilliseconds(it) },
)
}
}
private fun readSyncStateCursorLocked(): Cursor = readSyncStateLocked().cursor
private fun pw.binom.db.ksqlite.SQLiteResultSet.toPending(): PendingEvent {
val sent = getText(4)
return PendingEvent(
localId = LocalId(getText(0) ?: error("local_id null")),
payload = EventCodec.decode(getText(1) ?: error("payload null")),
createdAt = Instant.fromEpochMilliseconds(getLong(2) ?: 0L),
status = PendingStatus.valueOf(getText(3) ?: "PENDING"),
sentCursor = sent?.let { Cursor.fromString(it) },
)
}
fun closeStmts() {
updateSyncStateStmt.close()
selectSyncStateStmt.close()
insertProcessedStmt.close()
checkEventIdStmt.close()
selectPendingStmt.close()
selectAllPendingStmt.close()
insertPendingStmt.close()
updatePendingStatusStmt.close()
}
override fun close() = closeStmts()
/**
* Только для тестов: полностью wipe'нуть локальную материализацию и
* перевести клиента в новую эпоху. В нормальной работе клиент узнаёт о
* новой эпохе из `CursorExpired.WRONG_EPOCH` через `SyncEngine.sync` →
* replaceState.
*/
fun beginNewEpoch(newEpoch: String): Cursor {
connection.exec("DELETE FROM ${Schema.TABLE_CHAT_STATE}")
connection.exec("DELETE FROM ${Schema.TABLE_MESSAGE_STATE}")
connection.exec("DELETE FROM ${Schema.TABLE_PROCESSED_EVENT_ID}")
connection.exec("DELETE FROM ${Schema.TABLE_PENDING_EVENT}")
connection.exec("DELETE FROM ${Schema.TABLE_SYNC_STATE}")
connection.exec(
"UPDATE ${Schema.TABLE_COMPACTION_STATE} " +
"SET ${Schema.COL_EPOCH} = '$newEpoch', " +
"${Schema.COL_MIN_AVAILABLE_SEQ} = 0"
)
return Cursor(newEpoch, 0uL)
}
companion object {
private const val COL_LOCAL_ID = Schema.COL_LOCAL_ID
private const val COL_PAYLOAD_JSON = Schema.COL_PAYLOAD_JSON
private const val COL_CREATED_AT = Schema.COL_CREATED_AT
private const val COL_STATUS = Schema.COL_STATUS
private const val COL_SENT_CURSOR = Schema.COL_SENT_CURSOR
private const val COL_EVENT_ID = Schema.COL_EVENT_ID
private const val COL_SYNC_CURSOR = Schema.COL_SYNC_CURSOR
private const val COL_LAST_SYNC_AT = Schema.COL_LAST_SYNC_AT
}
}
@@ -0,0 +1,339 @@
package pw.binom.agentik.sync.ksqlite
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import pw.binom.agentik.sync.Chat
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.ChatState
import pw.binom.agentik.sync.CompactionState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.DefaultChatReducer
import pw.binom.agentik.sync.LoggedEvent
import pw.binom.agentik.sync.Message
import pw.binom.agentik.sync.MessageId
import pw.binom.agentik.sync.MessageRole
import pw.binom.agentik.sync.Reducer
import pw.binom.agentik.sync.StateSnapshot
import pw.binom.agentik.sync.StateStore
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Instant
/**
* ksqlite-реализация [StateStore]. Материализация держится в таблицах
* `chat_state` + `message_state`. На каждом `applyEvent` редьюсер считает
* новое значение строки, и мы пишем его в таблицу.
*
* В БД эпоха хранится в [Schema.TABLE_COMPACTION_STATE] (singleton), а
* строки `chat_state` / `message_state` несут только номер (`last_seq INTEGER`)
* в текущей эпохе — это нормально, потому что [replaceState] стирает все
* строки при смене эпохи. При чтении DTO ([Chat] / [Message]) номер
* дополняется текущей эпохой из compaction_state.
*/
class SqliteStateStore(
private val connection: SQLiteConnection,
private val reducer: Reducer<ChatState> = DefaultChatReducer,
) : StateStore {
/** Теневое in-memory состояние — обновляется в одном темпе с SQL. */
private var shadow: ChatState = ChatState()
/** Текущая эпоха — кэш для [lastCursor] / [compaction]. */
private var currentEpoch: String = Schema.DEFAULT_EPOCH
private val mutex = Mutex()
private val upsertChatStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_CHAT_STATE}
($COL_CHAT_ID, $COL_TITLE, $COL_CREATED_AT, $COL_DELETED, $COL_LAST_SEQ)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT($COL_CHAT_ID) DO UPDATE SET
$COL_TITLE = excluded.$COL_TITLE,
$COL_DELETED = excluded.$COL_DELETED,
$COL_LAST_SEQ = excluded.$COL_LAST_SEQ
""".trimIndent()
)
private val deleteChatStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_CHAT_STATE} WHERE $COL_CHAT_ID = ?"
)
private val upsertMessageStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_MESSAGE_STATE}
($COL_MESSAGE_ID, $COL_CHAT_ID, $COL_ROLE, $COL_CONTENT, $COL_PARENT_ID,
$COL_DELETED, $COL_LAST_SEQ, $COL_CREATED_AT)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT($COL_MESSAGE_ID) DO UPDATE SET
$COL_CONTENT = excluded.$COL_CONTENT,
$COL_PARENT_ID = excluded.$COL_PARENT_ID,
$COL_DELETED = excluded.$COL_DELETED,
$COL_LAST_SEQ = excluded.$COL_LAST_SEQ
""".trimIndent()
)
private val deleteMessageStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE_STATE} WHERE $COL_MESSAGE_ID = ?"
)
private val selectChatsStmt: SQLitePreparedStatement = connection.prepare(
"SELECT $COL_CHAT_ID, $COL_TITLE, $COL_CREATED_AT, $COL_DELETED, $COL_LAST_SEQ FROM ${Schema.TABLE_CHAT_STATE}"
)
private val selectMessagesStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT $COL_MESSAGE_ID, $COL_CHAT_ID, $COL_ROLE, $COL_CONTENT, $COL_PARENT_ID,
$COL_DELETED, $COL_LAST_SEQ, $COL_CREATED_AT
FROM ${Schema.TABLE_MESSAGE_STATE}
ORDER BY $COL_CHAT_ID, $COL_LAST_SEQ
""".trimIndent()
)
private val truncateChatsStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_CHAT_STATE}"
)
private val truncateMessagesStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE_STATE}"
)
private val selectCurrentEpochStmt: SQLitePreparedStatement = connection.prepare(
"SELECT $COL_EPOCH FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1"
)
init {
// Подгружаем текущее состояние в shadow.
runBlocking { reloadShadow() }
}
// ─── StateStore ────────────────────────────────────────────────────────
override suspend fun applyOnServer(event: LoggedEvent) = withContext(Dispatchers.Default) {
mutex.withLock {
val next = reducer.reduce(shadow, event)
writeDiff(shadow, next, event.cursor)
shadow = next
}
}
override suspend fun applyOnClient(event: LoggedEvent) = applyOnServer(event)
override suspend fun readState(): StateSnapshot = withContext(Dispatchers.Default) {
mutex.withLock { StateSnapshot(state = shadow) }
}
override suspend fun lastCursor(): Cursor = withContext(Dispatchers.Default) {
mutex.withLock { shadow.lastCursor }
}
/** Доступ к compaction state — нужен [SqliteEventLogReplica.compaction]. */
suspend fun compaction(): CompactionState = withContext(Dispatchers.Default) {
mutex.withLock { readCompactionLocked() }
}
/** Полная замена состояния снапшотом (см. [EventLogReplica.replaceState]). */
suspend fun replaceState(snapshot: StateSnapshot) = withContext(Dispatchers.Default) {
mutex.withLock {
// Обновим кэш эпохи — снапшот может прийти из новой эпохи.
val epochFromState = snapshot.state.lastCursor.epoch
currentEpoch = epochFromState
connection.exec("BEGIN")
try {
truncateChatsStmt.reset()
truncateChatsStmt.clearBindings()
truncateChatsStmt.executeUpdate()
truncateMessagesStmt.reset()
truncateMessagesStmt.clearBindings()
truncateMessagesStmt.executeUpdate()
for (chat in snapshot.state.chats.values) {
upsertChatStmt.reset()
upsertChatStmt.clearBindings()
upsertChatStmt.bindText(1, chat.id.raw)
upsertChatStmt.bindText(2, chat.title)
upsertChatStmt.bindLong(3, chat.createdAt.toEpochMilliseconds())
upsertChatStmt.bindLong(4, if (chat.deleted) 1L else 0L)
upsertChatStmt.bindLong(5, chat.lastCursor.number.toLong())
upsertChatStmt.executeUpdate()
}
for ((_, msgs) in snapshot.state.messages) {
for (m in msgs) {
upsertMessageStmt.reset()
upsertMessageStmt.clearBindings()
upsertMessageStmt.bindText(1, m.id.raw)
upsertMessageStmt.bindText(2, m.chatId.raw)
upsertMessageStmt.bindText(3, m.role.name)
upsertMessageStmt.bindText(4, m.content)
if (m.parentId == null) upsertMessageStmt.bindNull(5)
else upsertMessageStmt.bindText(5, m.parentId.raw)
upsertMessageStmt.bindLong(6, if (m.deleted) 1L else 0L)
upsertMessageStmt.bindLong(7, m.lastCursor.number.toLong())
upsertMessageStmt.bindLong(8, m.createdAt.toEpochMilliseconds())
upsertMessageStmt.executeUpdate()
}
}
connection.exec("COMMIT")
} catch (t: Throwable) {
runCatching { connection.exec("ROLLBACK") }
throw t
}
shadow = snapshot.state
}
}
// ─── helpers ────────────────────────────────────────────────────────────
private fun readCurrentEpochLocked(): String {
selectCurrentEpochStmt.reset()
selectCurrentEpochStmt.clearBindings()
selectCurrentEpochStmt.executeQuery().use { rs ->
check(rs.next())
return rs.getText(0) ?: Schema.DEFAULT_EPOCH
}
}
private fun readCompactionLocked(): CompactionState {
val stmt = connection.prepare("SELECT epoch, min_available_seq, last_compacted_at FROM ${Schema.TABLE_COMPACTION_STATE} WHERE id = 1")
try {
stmt.executeQuery().use { rs ->
check(rs.next())
val epoch = rs.getText(0) ?: Schema.DEFAULT_EPOCH
val min = rs.getLong(1) ?: 0L
val ms = rs.getLong(2)
return CompactionState(
currentEpoch = epoch,
minAvailableCursor = Cursor(epoch, min.toULong()),
lastCompactedAt = ms?.let { Instant.fromEpochMilliseconds(it) },
)
}
} finally {
stmt.close()
}
}
private suspend fun reloadShadow() = withContext(Dispatchers.Default) {
mutex.withLock {
currentEpoch = readCurrentEpochLocked()
val chats = LinkedHashMap<ChatId, Chat>()
selectChatsStmt.reset()
selectChatsStmt.clearBindings()
selectChatsStmt.executeQuery().use { rs ->
while (rs.next()) {
val lastSeq = rs.getLong(4) ?: 0L
val chat = Chat(
id = ChatId(rs.getText(0) ?: error("chat_id null")),
title = rs.getText(1) ?: "",
createdAt = Instant.fromEpochMilliseconds(rs.getLong(2) ?: 0L),
deleted = (rs.getLong(3) ?: 0L) != 0L,
lastCursor = Cursor(currentEpoch, lastSeq.toULong()),
)
chats[chat.id] = chat
}
}
val messages = LinkedHashMap<ChatId, MutableList<Message>>()
selectMessagesStmt.reset()
selectMessagesStmt.clearBindings()
selectMessagesStmt.executeQuery().use { rs ->
while (rs.next()) {
val lastSeq = rs.getLong(6) ?: 0L
val m = Message(
id = MessageId(rs.getText(0) ?: error("message_id null")),
chatId = ChatId(rs.getText(1) ?: error("chat_id null")),
role = MessageRole.valueOf(rs.getText(2) ?: "USER"),
content = rs.getText(3) ?: "",
parentId = rs.getText(4)?.let { MessageId(it) },
deleted = (rs.getLong(5) ?: 0L) != 0L,
lastCursor = Cursor(currentEpoch, lastSeq.toULong()),
createdAt = Instant.fromEpochMilliseconds(rs.getLong(7) ?: 0L),
)
messages.getOrPut(m.chatId) { mutableListOf() }.add(m)
}
}
shadow = ChatState(chats = chats, messages = messages)
}
}
private fun writeDiff(old: ChatState, new: ChatState, eventCursor: Cursor) {
// Chats: added / updated / removed.
for ((id, nc) in new.chats) {
val oc = old.chats[id]
if (oc == null || oc.title != nc.title || oc.deleted != nc.deleted || oc.lastCursor != nc.lastCursor) {
upsertChatStmt.reset()
upsertChatStmt.clearBindings()
upsertChatStmt.bindText(1, id.raw)
upsertChatStmt.bindText(2, nc.title)
upsertChatStmt.bindLong(3, nc.createdAt.toEpochMilliseconds())
upsertChatStmt.bindLong(4, if (nc.deleted) 1L else 0L)
upsertChatStmt.bindLong(5, nc.lastCursor.number.toLong())
upsertChatStmt.executeUpdate()
}
}
for ((id, _) in old.chats) {
if (id !in new.chats) {
deleteChatStmt.reset()
deleteChatStmt.clearBindings()
deleteChatStmt.bindText(1, id.raw)
deleteChatStmt.executeUpdate()
}
}
// Messages: то же самое.
for ((chatId, msgs) in new.messages) {
val oldMsgsById = old.messages[chatId]?.associateBy { it.id } ?: emptyMap()
for (m in msgs) {
val om = oldMsgsById[m.id]
if (om == null || om.content != m.content || om.deleted != m.deleted ||
om.lastCursor != m.lastCursor || om.parentId != m.parentId
) {
upsertMessageStmt.reset()
upsertMessageStmt.clearBindings()
upsertMessageStmt.bindText(1, m.id.raw)
upsertMessageStmt.bindText(2, m.chatId.raw)
upsertMessageStmt.bindText(3, m.role.name)
upsertMessageStmt.bindText(4, m.content)
if (m.parentId == null) upsertMessageStmt.bindNull(5)
else upsertMessageStmt.bindText(5, m.parentId.raw)
upsertMessageStmt.bindLong(6, if (m.deleted) 1L else 0L)
upsertMessageStmt.bindLong(7, m.lastCursor.number.toLong())
upsertMessageStmt.bindLong(8, m.createdAt.toEpochMilliseconds())
upsertMessageStmt.executeUpdate()
}
}
// Удалённые (нет в new).
for ((id, _) in oldMsgsById) {
if (msgs.none { it.id == id }) {
deleteMessageStmt.reset()
deleteMessageStmt.clearBindings()
deleteMessageStmt.bindText(1, id.raw)
deleteMessageStmt.executeUpdate()
}
}
}
}
override fun close() {
upsertChatStmt.close()
deleteChatStmt.close()
upsertMessageStmt.close()
deleteMessageStmt.close()
selectChatsStmt.close()
selectMessagesStmt.close()
truncateChatsStmt.close()
truncateMessagesStmt.close()
selectCurrentEpochStmt.close()
}
companion object {
private const val COL_EPOCH = Schema.COL_EPOCH
private const val COL_CHAT_ID = Schema.COL_CHAT_ID
private const val COL_TITLE = Schema.COL_TITLE
private const val COL_CREATED_AT = Schema.COL_CREATED_AT
private const val COL_DELETED = Schema.COL_DELETED
private const val COL_LAST_SEQ = Schema.COL_LAST_SEQ
private const val COL_MESSAGE_ID = Schema.COL_MESSAGE_ID
private const val COL_ROLE = Schema.COL_ROLE
private const val COL_CONTENT = Schema.COL_CONTENT
private const val COL_PARENT_ID = Schema.COL_PARENT_ID
}
}
@@ -0,0 +1,100 @@
package pw.binom.agentik.sync.ksqlite
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.sync.CompactionState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.EventLogReplica
import pw.binom.agentik.sync.EventLogWriter
import pw.binom.agentik.sync.StateStore
import pw.binom.agentik.sync.SyncEngine
import pw.binom.agentik.sync.SyncTransport
import pw.binom.agentik.sync.memory.InProcessTransport
import pw.binom.agentik.sync.testing.SyncBundle
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.time.Clock
/**
* KSQLITE-бэкенд для тестов спеки. Сервер и клиент — на разных SQLite-базах
* (одна in-memory connection shared у сервера, другая — у клиента).
*
* Создаётся через [create] с fresh in-memory SQLite для каждого бандла —
* тесты изолированы друг от друга.
*
* Транспорт — [InProcessTransport], обёрнутый вокруг серверного event log'а.
*/
class SqliteSyncBundle private constructor(
private val serverConn: SQLiteConnection,
private val clientConn: SQLiteConnection,
private val serverLog: SqliteEventLog,
private val srvState: SqliteStateStore,
private val cliState: SqliteStateStore,
private val clientLog: SqliteEventLogReplica,
private val transportImpl: InProcessTransport,
) : SyncBundle() {
override val server: EventLogWriter = serverLog
override val serverState: StateStore = srvState
override val client: EventLogReplica = clientLog
override val clientState: StateStore = cliState
override val transport: SyncTransport = transportImpl
override val engine: SyncEngine = SyncEngine(replica = clientLog, transport = transportImpl)
override suspend fun compactionState(): CompactionState = serverLog.compaction()
override suspend fun compactServer(upTo: Cursor) {
serverLog.compact(upTo)
}
override suspend fun serverBeginNewEpoch(newEpoch: String): Cursor {
// §2.3 спеки: append на сервере атомарно пишет и в журнал,
// и в материализацию. На beginNewEpoch state-store тоже надо
// очистить.
srvState.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState()))
return serverLog.beginNewEpoch(newEpoch)
}
/** Отдельно сменить эпоху на клиенте (для тестов WRONG_EPOCH-сценария). */
suspend fun clientBeginNewEpoch(newEpoch: String): Cursor =
clientLog.beginNewEpoch(newEpoch)
override fun close() {
serverLog.closeStmts()
srvState.close()
clientLog.closeStmts()
cliState.close()
transportImpl.close()
serverConn.close()
clientConn.close()
}
companion object {
fun create(): SqliteSyncBundle {
val serverConn = SQLiteConnection.memory("server-${Clock.System.now().toEpochMilliseconds()}")
val clientConn = SQLiteConnection.memory("client-${Clock.System.now().toEpochMilliseconds()}")
// Создаём схему.
Schema.migrate(serverConn)
Schema.migrate(clientConn)
// Server.
val serverLog = SqliteEventLog(serverConn, ownsConnection = false)
val serverState = SqliteStateStore(serverConn)
// §2.3 спеки: append на сервере атомарно пишет и в журнал,
// и в материализацию. SqliteEventLog.append уведомляет подписчиков —
// SqliteStateStore подписывается, чтобы обновить свой shadow.
serverLog.onAppend { e -> serverState.applyOnServer(e) }
// Client.
val clientState = SqliteStateStore(clientConn)
val clientLog = SqliteEventLogReplica(clientConn, clientState)
// Транспорт ходит через серверный SqliteEventLog.
val transport = InProcessTransport(
server = serverLog,
stateStore = serverState,
compactionState = { serverLog.compaction() },
)
return SqliteSyncBundle(
serverConn, clientConn,
serverLog, serverState, clientState, clientLog,
transport,
)
}
}
}
@@ -0,0 +1,277 @@
package pw.binom.agentik.sync.memory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.sync.ChatState
import pw.binom.agentik.sync.CompactionState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.DefaultChatReducer
import pw.binom.agentik.sync.DomainEvent
import pw.binom.agentik.sync.EventLogReplica
import pw.binom.agentik.sync.EventLogWriter
import pw.binom.agentik.sync.LocalId
import pw.binom.agentik.sync.LoggedEvent
import pw.binom.agentik.sync.PendingEvent
import pw.binom.agentik.sync.PendingStatus
import pw.binom.agentik.sync.Reducer
import pw.binom.agentik.sync.SeqMonotonicityViolationException
import pw.binom.agentik.sync.StateSnapshot
import pw.binom.agentik.sync.StateStore
import pw.binom.agentik.sync.SyncState
import kotlin.time.Clock
import kotlin.time.Instant
/**
* In-memory реализация [EventLogWriter] + [EventLogReplica] + [StateStore].
*
* Семантика:
* - [append] атомарно (под мьютексом): резервирует `cursor`, пишет событие
* в `events`, применяет редьюсер к `state`.
* - [compact] удаляет события `cursor.number <= upTo.number` и сдвигает
* `minAvailableCursor`.
* - [applyRemote] на replica применяет редьюсер, идемпотентно по `eventId`.
* - [replaceState] полностью заменяет state и сбрасывает `syncState.cursor`.
* - [beginNewEpoch] стирает все события и обновляет текущую эпоху.
* - pending-события хранятся в отдельной мапе, смена статуса меняет запись.
*
* Для тестов спеки. Не предназначен для production — состояние в памяти
* процесса не переживает рестарт.
*/
class InMemoryEventLog(
private val reducer: Reducer<ChatState> = DefaultChatReducer,
initialEpoch: String = Cursor.ZERO.epoch,
) : EventLogWriter, EventLogReplica, StateStore {
private val mutex = Mutex()
/** Журнал событий: append-only. */
private val events: MutableList<LoggedEvent> = mutableListOf()
/** Для идемпотентности applyRemote: уже применённые `eventId`. */
private val appliedEventIds: MutableSet<String> = mutableSetOf()
/** Материализованное состояние (обновляется атомарно с append/applyRemote). */
private var state: ChatState = ChatState()
/** Pending-event'ы. Стартуют со статусом PENDING → SENT/FAILED. */
private val pending: MutableMap<LocalId, PendingEvent> = mutableMapOf()
/** Подписчики на append (используется [InProcessTransport] для live-канала). */
internal val appendListeners: MutableList<suspend (LoggedEvent) -> Unit> = mutableListOf()
/** Текущая эпоха. */
private var currentEpoch: String = initialEpoch
/** `cursor` синхронизации (= последний успешно apply'нутый). */
private var syncCursor: Cursor = Cursor(initialEpoch, 0uL)
private var syncLastSyncAt: Instant? = null
/** Метаданные компакции. */
private var minAvailableCursor: Cursor = Cursor(initialEpoch, 0uL)
private var lastCompactedAt: Instant? = null
/** Подписаться на каждое новое append-событие. */
fun onAppend(listener: suspend (LoggedEvent) -> Unit) {
appendListeners.add(listener)
}
private suspend fun notifyAppend(l: LoggedEvent) {
for (f in appendListeners.toList()) f(l)
}
// ─── EventLog (общий для writer и replica) ────────────────────────────
override suspend fun readUpdates(after: Cursor, limit: Int): List<LoggedEvent> = mutex.withLock {
// Клиентская сторона не возвращает события — replay на сервере.
// Если кто-то вызвал readUpdates на in-memory "server" — отдадим все.
// Кросс-эпоха → бросаем CursorExpired.
if (after.epoch != currentEpoch) {
throw pw.binom.agentik.sync.CursorExpiredException(
requested = after,
currentEpoch = currentEpoch,
minAvailable = minAvailableCursor,
current = syncCursor,
reason = pw.binom.agentik.sync.CursorExpiredReason.WRONG_EPOCH,
)
}
if (after.number < minAvailableCursor.number) {
throw pw.binom.agentik.sync.CursorExpiredException(
requested = after,
currentEpoch = currentEpoch,
minAvailable = minAvailableCursor,
current = syncCursor,
reason = pw.binom.agentik.sync.CursorExpiredReason.TOO_OLD,
)
}
events.asSequence()
.filter { it.cursor.epoch == after.epoch && it.cursor.number > after.number }
.take(limit)
.toList()
}
override suspend fun lastCursor(): Cursor = mutex.withLock {
syncCursor
}
override suspend fun compaction(): CompactionState = mutex.withLock {
CompactionState(
currentEpoch = currentEpoch,
minAvailableCursor = minAvailableCursor,
lastCompactedAt = lastCompactedAt,
)
}
// ─── EventLogWriter (сервер) ───────────────────────────────────────────
override suspend fun append(event: DomainEvent): LoggedEvent = mutex.withLock {
if (event.eventId in appliedEventIds) {
return@withLock events.first { it.eventId == event.eventId }
}
// Резервируем следующий cursor в текущей эпохе.
val nextNumber = (events.lastOrNull()?.cursor?.number ?: 0uL) + 1uL
val cursor = Cursor(currentEpoch, nextNumber)
val logged = LoggedEvent(
cursor = cursor,
eventId = event.eventId,
payload = event,
createdAt = Clock.System.now(),
)
state = reducer.reduce(state, logged)
events.add(logged)
appliedEventIds.add(event.eventId)
syncCursor = cursor
notifyAppend(logged)
logged
}
override suspend fun compact(upTo: Cursor) = mutex.withLock {
require(upTo.epoch == currentEpoch) { "compact(upTo=$upTo) requires epoch=$currentEpoch" }
events.removeAll { it.cursor.number <= upTo.number }
val newMin = upTo.next()
if (newMin.number > minAvailableCursor.number) {
minAvailableCursor = newMin
}
lastCompactedAt = Clock.System.now()
}
override suspend fun beginNewEpoch(newEpoch: String): Cursor = mutex.withLock {
require(newEpoch.isNotEmpty()) { "epoch must be non-empty" }
require('-' !in newEpoch) { "epoch must not contain '-': \"$newEpoch\"" }
events.clear()
appliedEventIds.clear()
state = ChatState()
currentEpoch = newEpoch
syncCursor = Cursor(newEpoch, 0uL)
minAvailableCursor = Cursor(newEpoch, 0uL)
lastCompactedAt = null
syncCursor
}
// ─── EventLogReplica (клиент) ──────────────────────────────────────────
override suspend fun applyRemote(event: LoggedEvent) = mutex.withLock {
if (event.eventId in appliedEventIds) return@withLock
if (event.cursor.epoch != currentEpoch) {
// Клиент видит событие другой эпохи → либо он сам отстал (нужен
// replaceState), либо сервер сделал wipe. В обоих случаях клиент
// должен делать replaceState через SyncEngine, а не applyRemote.
// Здесь бросаем, чтобы SyncEngine заметил gap и пошёл в fetchState.
throw SeqMonotonicityViolationException(
previous = syncCursor.number.toLong(),
attempted = event.cursor.number.toLong(),
)
}
if (event.cursor.number <= syncCursor.number) {
throw SeqMonotonicityViolationException(
previous = syncCursor.number.toLong(),
attempted = event.cursor.number.toLong(),
)
}
state = reducer.reduce(state, event)
events.add(event)
appliedEventIds.add(event.eventId)
syncCursor = event.cursor
syncLastSyncAt = Clock.System.now()
}
override suspend fun replaceState(snapshot: StateSnapshot) = mutex.withLock {
state = snapshot.state
currentEpoch = snapshot.state.lastCursor.epoch
syncCursor = snapshot.state.lastCursor
syncLastSyncAt = Clock.System.now()
}
override suspend fun pendingEvents(): List<PendingEvent> = mutex.withLock {
pending.values.filter { it.status == PendingStatus.PENDING }
}
override suspend fun enqueuePending(payload: DomainEvent, createdAt: Instant): PendingEvent = mutex.withLock {
val pe = PendingEvent(
localId = LocalId(raw = payload.eventId),
payload = payload,
createdAt = createdAt,
status = PendingStatus.PENDING,
)
pending[pe.localId] = pe
// Оптимистичное применение к локальному состоянию — UI рисует сразу.
val fakeCursor = syncCursor.next()
val logged = LoggedEvent(
cursor = fakeCursor,
eventId = payload.eventId,
payload = payload,
createdAt = createdAt,
)
state = reducer.reduce(state, logged)
events.add(logged)
syncCursor = fakeCursor
pe
}
override suspend fun markPendingAsSent(localId: LocalId, cursor: Cursor) = mutex.withLock {
val pe = pending[localId] ?: return@withLock
pending[localId] = pe.copy(status = PendingStatus.SENT, sentCursor = cursor)
if (cursor.number > syncCursor.number && cursor.epoch == currentEpoch) {
syncCursor = cursor
}
}
override suspend fun markPendingAsFailed(localId: LocalId) = mutex.withLock {
val pe = pending[localId] ?: return@withLock
pending[localId] = pe.copy(status = PendingStatus.FAILED)
}
override suspend fun syncState(): SyncState = mutex.withLock {
SyncState(cursor = syncCursor, lastSyncAt = syncLastSyncAt)
}
// ─── StateStore ────────────────────────────────────────────────────────
override suspend fun applyOnServer(event: LoggedEvent) = mutex.withLock {
if (event.eventId in appliedEventIds) return@withLock
state = reducer.reduce(state, event)
appliedEventIds.add(event.eventId)
}
override suspend fun applyOnClient(event: LoggedEvent) = applyRemote(event)
override suspend fun readState(): StateSnapshot = mutex.withLock {
StateSnapshot(state = state)
}
/** Алиас [lastCursor] для [StateStore]. */
suspend fun materializationLastCursor(): Cursor = mutex.withLock { state.lastCursor }
// ─── Тест-only инспекция ────────────────────────────────────────────────
/** Размер журнала (read-only). */
suspend fun journalSize(): Int = mutex.withLock { events.size }
/** Все pending-события (любого статуса). */
suspend fun allPending(): List<PendingEvent> = mutex.withLock { pending.values.toList() }
/** Текущая эпоха (read-only). */
suspend fun epoch(): String = mutex.withLock { currentEpoch }
override fun close() {
// Ничего не делаем — in-memory.
}
}
@@ -0,0 +1,156 @@
package pw.binom.agentik.sync.memory
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.sync.CompactionState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.CursorExpiredException
import pw.binom.agentik.sync.CursorExpiredReason
import pw.binom.agentik.sync.DomainEvent
import pw.binom.agentik.sync.EventLogWriter
import pw.binom.agentik.sync.LoggedEvent
import pw.binom.agentik.sync.PendingEvent
import pw.binom.agentik.sync.PostResult
import pw.binom.agentik.sync.StateResponse
import pw.binom.agentik.sync.StateStore
import pw.binom.agentik.sync.SyncTransport
import pw.binom.agentik.sync.UpdatesResponse
import kotlin.time.Clock
/**
* In-memory реализация [SyncTransport] поверх одного [EventLogWriter] +
* [StateStore]. Имитирует HTTP/WS семантику спеки без сетевого слоя —
* нужно для тестов.
*
* Семантика:
* - [fetchUpdates] повторяет логику §5.2 шагов 2-4:
* * `after.epoch != current_epoch` → [UpdatesResponse.CursorExpired] с
* [CursorExpiredReason.WRONG_EPOCH].
* * `after.number < minAvailable.number` (та же эпоха) →
* [UpdatesResponse.CursorExpired] с [CursorExpiredReason.TOO_OLD].
* * иначе — окно с `cursor > after`, не больше `limit`, и
* `hasMore = (окно.size == limit)`.
* - [fetchState] отдаёт полный снапшот через [StateStore.readState].
* - [postPending] атомарно `append'`ит каждое событие; идемпотентность
* по `eventId` (см. `InMemoryEventLog.append`).
* - [subscribeLive] — холодный Flow, отдаёт события `cursor > after` и
* подписывается на broadcast-канал. При пропущенном курсоре сразу
* бросает [CursorExpiredException] внутрь Flow.
*/
class InProcessTransport(
private val server: EventLogWriter,
private val stateStore: StateStore,
private val compactionState: suspend () -> CompactionState,
) : SyncTransport {
private val mutex = Mutex()
/** Broadcast-канал для live-подписок. `extraBufferCapacity = 64` — окно для подписчиков. */
private val live = MutableSharedFlow<LoggedEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
// Подписываемся на append'ы writer'а (in-memory impl поддерживает
// listener'ов через [InMemoryEventLog.onAppend]).
(server as? InMemoryEventLog)?.onAppend { live.emit(it) }
}
/** Append с уведомлением подписчиков. */
suspend fun appendAndBroadcast(event: DomainEvent): LoggedEvent {
val logged = server.append(event)
live.emit(logged)
return logged
}
override suspend fun fetchUpdates(after: Cursor, limit: Int): UpdatesResponse = mutex.withLock {
val compaction = compactionState()
val current = server.lastCursor()
if (after.epoch != compaction.currentEpoch) {
return@withLock UpdatesResponse.CursorExpired(
requested = after,
minAvailable = compaction.minAvailableCursor,
current = current,
reason = CursorExpiredReason.WRONG_EPOCH,
)
}
if (after.number < compaction.minAvailableCursor.number) {
return@withLock UpdatesResponse.CursorExpired(
requested = after,
minAvailable = compaction.minAvailableCursor,
current = current,
reason = CursorExpiredReason.TOO_OLD,
)
}
val events = server.readUpdates(after, limit)
val hasMore = events.size == limit
UpdatesResponse.Ok(
events = events,
hasMore = hasMore,
currentCursor = current,
)
}
override suspend fun fetchState(): StateResponse {
val snap = stateStore.readState()
val cur = server.lastCursor()
return StateResponse.Ok(
snapshot = snap,
upToCursor = snap.state.lastCursor,
currentCursor = cur,
)
}
override suspend fun postPending(events: List<PendingEvent>): List<PostResult> {
val out = ArrayList<PostResult>(events.size)
for (pe in events) {
try {
val logged = appendAndBroadcast(pe.payload)
out.add(PostResult.Accepted(localId = pe.localId, cursor = logged.cursor))
} catch (t: Throwable) {
out.add(PostResult.Rejected(localId = pe.localId, reason = t.message ?: "unknown"))
}
}
return out
}
override fun subscribeLive(after: Cursor): Flow<LoggedEvent> {
val min = runBlocking { compactionState() }
val current = runBlocking { server.lastCursor() }
if (after.epoch != min.currentEpoch) {
throw CursorExpiredException(
requested = after,
currentEpoch = min.currentEpoch,
minAvailable = min.minAvailableCursor,
current = current,
reason = CursorExpiredReason.WRONG_EPOCH,
)
}
if (after.number < min.minAvailableCursor.number) {
throw CursorExpiredException(
requested = after,
currentEpoch = min.currentEpoch,
minAvailable = min.minAvailableCursor,
current = current,
reason = CursorExpiredReason.TOO_OLD,
)
}
return live.onStart {
val catchup = server.readUpdates(after, limit = Int.MAX_VALUE)
for (e in catchup) emit(e)
}
}
override fun close() {
// No-op.
}
}
private fun <T> runBlocking(block: suspend () -> T): T =
kotlinx.coroutines.runBlocking { block() }
@@ -0,0 +1,59 @@
package pw.binom.agentik.sync.testing
import pw.binom.agentik.sync.ChatState
import pw.binom.agentik.sync.CompactionState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.EventLogReplica
import pw.binom.agentik.sync.EventLogWriter
import pw.binom.agentik.sync.StateStore
import pw.binom.agentik.sync.SyncEngine
import pw.binom.agentik.sync.SyncTransport
/**
* Контракт бэкенда для тестов спеки. Любая имплементация (in-memory,
* ksqlite и т.п.) создаёт через `create()` (см. `MemorySyncBundle`,
* `SqliteSyncBundle`) изолированный мир: сервер (writer + state store +
* transport) + клиент (replica) + sync-engine.
*
* Тесты в этом пакете вызывают только методы бандла и проверяют инварианты
* спеки, не зная, что внутри. Это позволяет одной и той же тестовой сюитой
* прогонять и in-memory, и ksqlite impl.
*/
abstract class SyncBundle : AutoCloseable {
abstract val server: EventLogWriter
abstract val serverState: StateStore
abstract val client: EventLogReplica
abstract val clientState: StateStore
abstract val transport: SyncTransport
abstract val engine: SyncEngine
/** Снапшот серверного compaction-стейта — для тестов, которые его
* трогают напрямую. */
abstract suspend fun compactionState(): CompactionState
/** Принудительный compact серверного журнала (используется тестами
* сценария «cursor протух»). */
abstract suspend fun compactServer(upTo: Cursor)
/** Принудительная смена эпохи на сервере (для тестов WRONG_EPOCH).
* Возвращает новый стартовый Cursor. */
abstract suspend fun serverBeginNewEpoch(newEpoch: String): Cursor
/** Клиентский lastCursor (для тестов). */
suspend fun clientLastCursor(): Cursor = client.syncState().cursor
/** Серверный lastCursor. */
suspend fun serverLastCursor(): Cursor = server.lastCursor()
/** Серверный снапшот. */
suspend fun serverReadState(): ChatState = serverState.readState().state
/** Клиентский снапшот. */
suspend fun clientReadState(): ChatState = clientState.readState().state
override fun close() {
// По умолчанию ничего — конкретные бандлы (in-memory, ksqlite)
// переопределяют.
}
}
@@ -0,0 +1,55 @@
package pw.binom.agentik.sync.ksqlite
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.test.samples
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.time.Clock
/**
* Тест на персистентность ksqlite-имплёмплементации: данные, записанные в одной
* сессии, доступны после полного закрытия соединения и открытия нового.
* Использует kotlinx-io для работы с файлами — поэтому живёт в commonTest
* и прогоняется на всех целях, где есть ksqlite (jvm, linuxX64, mingwX64).
*/
class KsqlitePersistenceTest {
@Test
fun `данные сохраняются в SQLite и доступны после перезагрузки`() = kotlinx.coroutines.runBlocking {
val tempFile = Path("/tmp", "opencode", "sync-core-test-${Clock.System.now().toEpochMilliseconds()}.db")
SystemFileSystem.createDirectories(tempFile.parent!!)
try {
val conn1 = pw.binom.db.ksqlite.SQLiteConnection.open(tempFile.toString())
Schema.migrate(conn1)
val log1 = SqliteEventLog(conn1, ownsConnection = false)
val state1 = SqliteStateStore(conn1)
log1.append(samples.chatCreated(0))
log1.append(samples.messageAppended(0, 0, content = "persistent"))
val events = log1.readUpdates(Cursor.ZERO, 100)
state1.applyOnServer(events[0])
state1.applyOnServer(events[1])
log1.closeStmts()
state1.close()
conn1.close()
val conn2 = pw.binom.db.ksqlite.SQLiteConnection.open(tempFile.toString())
val log2 = SqliteEventLog(conn2, ownsConnection = false)
val state2 = SqliteStateStore(conn2)
val events2 = log2.readUpdates(Cursor.ZERO, 100)
assertEquals(2, events2.size)
val snap = state2.readState()
assertNotNull(snap.state.chats[ChatId("chat0")])
assertEquals(1, snap.state.messages[ChatId("chat0")]?.size)
assertEquals("persistent", snap.state.messages[ChatId("chat0")]?.single()?.content)
log2.closeStmts()
state2.close()
conn2.close()
} finally {
SystemFileSystem.delete(tempFile, mustExist = false)
}
}
}
@@ -0,0 +1,229 @@
package pw.binom.agentik.sync.ksqlite
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.UpdatesResponse
import pw.binom.agentik.sync.test.samples
import pw.binom.agentik.sync.test.someInstant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Интеграционные тесты спеки `SYNC-SYSTEM.md` против реальной SQLite (через
* ksqlite). 15 in-memory тестов — в commonTest, чтобы прогонялись на всех
* KMP-целях, где ksqlite публикуется (jvm, linuxX64, mingwX64).
*/
class KsqliteSyncSpecTest {
private fun bundle() = SqliteSyncBundle.create()
@Test
fun `1 cursor монотонно возрастает на append`() = kotlinx.coroutines.runBlocking {
val b = bundle()
val s1 = b.server.append(samples.chatCreated(0))
val s2 = b.server.append(samples.messageAppended(0, 0))
val s3 = b.server.append(samples.messageAppended(0, 1))
assertTrue(s1.cursor < s2.cursor)
assertTrue(s2.cursor < s3.cursor)
assertEquals(s1.cursor.next(), s2.cursor)
assertEquals(s2.cursor.next(), s3.cursor)
b.close()
}
@Test
fun `2 события не изменяются после append`() = kotlinx.coroutines.runBlocking {
val b = bundle()
val event = samples.chatCreated(0)
val logged = b.server.append(event)
val read = b.server.readUpdates(Cursor.ZERO, 100).single()
assertEquals(logged.cursor, read.cursor)
assertEquals(logged.eventId, read.eventId)
assertEquals(logged.payload, read.payload)
b.close()
}
@Test
fun `3 материализация сервера консистентна журналу`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.server.append(samples.messageAppended(0, 1))
val state = b.serverReadState()
assertNotNull(state.chats[ChatId("chat0")])
assertEquals(2, state.messages[ChatId("chat0")]?.size)
b.close()
}
@Test
fun `4 событие несёт полный payload через SQL`() = kotlinx.coroutines.runBlocking {
val b = bundle()
val created = pw.binom.agentik.sync.DomainEvent.ChatCreated(
eventId = "ev-test",
chatId = ChatId("chatX"),
title = "Полный заголовок с Unicode",
createdAt = someInstant,
)
val logged = b.server.append(created)
val read = b.server.readUpdates(Cursor.ZERO, 100).single()
assertEquals("Полный заголовок с Unicode", (read.payload as pw.binom.agentik.sync.DomainEvent.ChatCreated).title)
b.close()
}
@Test
fun `5 replaceState полностью заменяет state`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.server.append(samples.chatCreated(1))
b.server.append(samples.messageAppended(0, 0))
b.engine.sync()
assertEquals(2, b.clientReadState().chats.size)
b.server.append(samples.chatDeleted(0))
b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState()))
b.compactServer(upTo = b.serverLastCursor())
b.engine.sync()
val chats = b.clientReadState().chats
assertEquals(1, chats.size)
assertNotNull(chats[ChatId("chat1")])
assertEquals(null, chats[ChatId("chat0")])
b.close()
}
@Test
fun `6 clientReadState совпадает с serverReadState после sync`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.server.append(samples.chatCreated(1))
b.server.append(samples.messageAppended(1, 0))
b.engine.sync()
assertEquals(b.serverReadState(), b.clientReadState())
b.close()
}
@Test
fun `7 compact делает cursor протухшим - sync делает replace`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.engine.sync()
val clientCursor = b.clientLastCursor()
b.server.append(samples.messageAppended(0, 1))
b.server.append(samples.messageAppended(0, 2))
b.compactServer(upTo = clientCursor)
val resp = b.transport.fetchUpdates(clientCursor, 100)
assertTrue(resp is UpdatesResponse.CursorExpired)
val report = b.engine.sync()
assertTrue(report.resolvedCursorGap)
assertEquals(3, b.clientReadState().messages[ChatId("chat0")]?.size)
b.close()
}
@Test
fun `повторный append с тем же eventId возвращает тот же cursor`() = kotlinx.coroutines.runBlocking {
val b = bundle()
val e = samples.chatCreated(0)
val first = b.server.append(e)
val second = b.server.append(e)
assertEquals(first.cursor, second.cursor)
b.close()
}
@Test
fun `applyRemote идемпотентен по eventId`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.engine.sync()
val before = b.clientReadState()
val first = b.server.readUpdates(Cursor.ZERO, 100).first()
b.client.applyRemote(first)
assertEquals(before, b.clientReadState())
b.close()
}
@Test
fun `события упорядочены и без дыр`() = kotlinx.coroutines.runBlocking {
val b = bundle()
for (i in 0..10) b.server.append(samples.messageAppended(0, i))
val events = b.server.readUpdates(Cursor.ZERO, 100)
for (i in 1 until events.size) {
assertEquals(events[i - 1].cursor.next(), events[i].cursor)
}
b.close()
}
@Test
fun `applyRemote вне порядка бросает исключение`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.engine.sync()
val past = pw.binom.agentik.sync.LoggedEvent(
cursor = b.clientLastCursor(),
eventId = "old-event",
payload = samples.messageAppended(0, 99),
createdAt = someInstant,
)
assertFails { b.client.applyRemote(past) }
b.close()
}
@Test
fun `compact удаляет старые события и сдвигает minAvailableCursor`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.server.append(samples.messageAppended(0, 1))
val epoch = Cursor.ZERO.epoch
b.compactServer(upTo = Cursor(epoch, 1uL))
assertEquals(Cursor(epoch, 2uL), b.compactionState().minAvailableCursor)
// После compact cursor < min → CursorExpired.
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
assertTrue(resp is UpdatesResponse.CursorExpired)
// Сверх floor: after=c(2) → events с cursor.number > 2 → number=3.
val after2 = b.transport.fetchUpdates(Cursor(epoch, 2uL), 100)
assertTrue(after2 is UpdatesResponse.Ok)
val ok = after2 as UpdatesResponse.Ok
assertEquals(1, ok.events.size)
assertEquals(3uL, ok.events.single().cursor.number)
b.close()
}
@Test
fun `enqueuePending оптимистично применяется`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline"), someInstant)
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, msgs.size)
assertEquals("offline", msgs.single().content)
b.close()
}
@Test
fun `sync отправляет pending на сервер`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline"), someInstant)
val report = b.engine.sync()
assertEquals(1, report.postedPending)
val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, serverMsgs.size)
b.close()
}
@Test
fun `pending сохраняется после replaceState`() = kotlinx.coroutines.runBlocking {
val b = bundle()
b.server.append(samples.chatCreated(0))
b.client.enqueuePending(samples.messageAppended(0, 0, content = "keep"), someInstant)
b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState()))
assertEquals(1, b.client.pendingEvents().size)
b.engine.sync()
assertEquals(1, b.serverReadState().messages[ChatId("chat0")]?.size)
b.close()
}
}
@@ -0,0 +1,88 @@
package pw.binom.agentik.sync.test
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import pw.binom.agentik.sync.Assistant
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.DomainEvent
import pw.binom.agentik.sync.MessageId
import pw.binom.agentik.sync.RunInput
import pw.binom.agentik.sync.remoteAssistant
import pw.binom.agentik.sync.scriptedAssistant
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Проверки §4.4 (Assistant) и §5.4 (LocalAssistant).
*/
class AssistantTest {
@Test
fun `LocalAssistant возвращает поток доменных событий`() = kotlinx.coroutines.runBlocking {
val events = listOf(
samples.messageAppended(0, 0, content = "user asks"),
samples.messageAppended(0, 1, content = "assistant replies"),
)
val a: Assistant = scriptedAssistant(events)
val out = a.run(input()).toList()
assertEquals(events, out)
}
@Test
fun `RemoteAssistant подписывается на live-канал сервера`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
val assistant: Assistant = remoteAssistant(b.transport) { b.server.lastCursor() }
val collected = mutableListOf<DomainEvent>()
val job = launch {
assistant.run(input()).collect { collected.add(it) }
}
// Дать job запуститься и подписаться на live.
yield()
delay(20)
// Сервер публикует событие — assistant его видит.
b.server.append(samples.messageAppended(0, 0, content = "from-server"))
delay(50)
job.cancel()
assertTrue(collected.any { (it as? DomainEvent.MessageAppended)?.content == "from-server" })
b.close()
}
@Test
fun `события LocalAssistant попадают в PendingEvent на клиенте`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
val assistant: Assistant = scriptedAssistant(listOf(
samples.messageAppended(0, 0, content = "user"),
samples.messageAppended(0, 1, content = "assistant"),
))
assistant.run(input()).collect { e ->
b.client.enqueuePending(e, someInstant)
}
val pending = b.client.pendingEvents()
assertEquals(2, pending.size)
val msgs = b.clientReadState().messages.values.flatten()
assertEquals(2, msgs.size)
val report = b.engine.sync()
assertEquals(2, report.postedPending)
val serverMsgs = b.serverReadState().messages.values.flatten()
assertEquals(2, serverMsgs.size)
b.close()
}
private fun input() = RunInput(
chatId = ChatId("chat0"),
userMessageId = MessageId("u1"),
reasoning = "test",
)
}
@@ -0,0 +1,157 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.LoggedEvent
import pw.binom.agentik.sync.SeqMonotonicityViolationException
import pw.binom.agentik.sync.UpdatesResponse
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Проверки §6 (компакция) и §8.2 (порядок событий).
*/
class CompactionAndOrderTest {
private fun epoch() = Cursor.ZERO.epoch
private fun c(n: Int) = Cursor(epoch(), n.toULong())
@Test
fun `compact удаляет старые события и сдвигает minAvailableCursor`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.server.append(samples.messageAppended(0, 1))
b.compactServer(upTo = c(1))
val compState = b.compactionState()
assertEquals(c(2), compState.minAvailableCursor)
// После compact курсор ниже min → CursorExpired (§6.3).
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
assertTrue(resp is UpdatesResponse.CursorExpired)
// А с курсором >= minAvailableCursor — читается (events with cursor > after).
// after=c(2) → events с cursor.number > 2 → только number=3.
val after2 = b.transport.fetchUpdates(c(2), 100)
assertTrue(after2 is UpdatesResponse.Ok)
val ok = after2 as UpdatesResponse.Ok
assertEquals(1, ok.events.size)
assertEquals(3uL, ok.events.single().cursor.number)
b.close()
}
@Test
fun `compact не трогает события после upTo`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
for (i in 0..5) b.server.append(samples.messageAppended(0, i))
b.compactServer(upTo = c(3))
// Чтение с c(4) — события начиная с number=5 (events with cursor > after).
val all = b.transport.fetchUpdates(c(4), 100)
assertTrue(all is UpdatesResponse.Ok)
val evs = (all as UpdatesResponse.Ok).events
assertEquals(listOf(5uL, 6uL), evs.map { it.cursor.number })
b.close()
}
@Test
fun `cursor ниже minAvailableCursor дает CursorExpired`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.compactServer(upTo = c(1))
// after=c(0) < minAvailable=c(2) → CursorExpired.
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
assertTrue(resp is UpdatesResponse.CursorExpired)
b.close()
}
@Test
fun `cursor ровно на minAvailableCursor дает Ok`() = kotlinx.coroutines.runBlocking {
// minAvailable=c(2) после compact(upTo=c(1)); запрос after=c(2) (= min) → cursor > c(2) → пусто.
// CursorExpired НЕ возвращается — граница включительная.
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0)) // cursor.number=1
b.server.append(samples.messageAppended(0, 0)) // cursor.number=2
b.compactServer(upTo = c(1))
assertEquals(c(2), b.compactionState().minAvailableCursor)
val resp = b.transport.fetchUpdates(c(2), 100)
assertTrue(resp is UpdatesResponse.Ok)
val ok = resp as UpdatesResponse.Ok
// events с cursor > c(2) — пусто.
assertEquals(0, ok.events.size)
b.close()
}
@Test
fun `cursor ниже minAvailableCursor на 1 дает CursorExpired`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0)) // cursor.number=1
b.server.append(samples.messageAppended(0, 0)) // cursor.number=2
b.compactServer(upTo = c(1))
// after=c(1) < min=c(2) → CursorExpired.
val resp = b.transport.fetchUpdates(c(1), 100)
assertTrue(resp is UpdatesResponse.CursorExpired)
b.close()
}
@Test
fun `события в журнале строго упорядочены`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
for (i in 0..10) b.server.append(samples.messageAppended(0, i))
val events = b.server.readUpdates(Cursor.ZERO, 100)
assertEquals(events.size, events.distinctBy { it.cursor }.size, "cursor уникальны")
val nums = events.map { it.cursor.number }
assertEquals(nums.sorted(), nums, "numbers отсортированы по возрастанию")
for (i in 1 until events.size) {
assertEquals(events[i - 1].cursor.next(), events[i].cursor, "нет дыр")
}
b.close()
}
@Test
fun `applyRemote вне порядка бросает исключение`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.engine.sync()
// Пробуем применить event с cursor меньшим, чем clientLastCursor.
val past = LoggedEvent(
cursor = b.clientLastCursor(),
eventId = "old-event",
payload = samples.messageAppended(0, 99),
createdAt = someInstant,
)
assertFails {
b.client.applyRemote(past)
}
// Sanity: исключение именно монотонности.
runCatching { b.client.applyRemote(past) }.onFailure {
assertTrue(it is SeqMonotonicityViolationException, "got ${it::class.simpleName}")
}
b.close()
}
@Test
fun `fetchUpdates с limit меньше общего числа возвращает hasMore=true`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
for (i in 0..4) b.server.append(samples.messageAppended(0, i))
val resp = b.transport.fetchUpdates(Cursor.ZERO, limit = 2)
assertTrue(resp is UpdatesResponse.Ok)
val ok = resp as UpdatesResponse.Ok
assertEquals(2, ok.events.size)
assertTrue(ok.hasMore)
b.close()
}
@Test
fun `sync догружает события порциями`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
for (i in 0..5) b.server.append(samples.messageAppended(0, i))
// pageSize=2 — должно быть 3 итерации цикла.
b.engine.sync(pageSize = 2)
val msgs = b.clientReadState().messages.values.flatten()
assertEquals(6, msgs.size)
assertEquals(b.serverLastCursor(), b.clientLastCursor())
b.close()
}
}
@@ -0,0 +1,123 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.Cursor
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotEquals
/**
* Тесты [Cursor] — компактного (epoch + number) курсора журнала. Эпоха
* задаёт «поколение» журнала (меняется при wipe/restore сервера),
* `number` — монотонный счётчик внутри эпохи.
*/
class CursorTest {
@Test
fun `constructor строит epoch-number_hex`() {
val c = Cursor(epoch = "v1", number = 42uL)
assertEquals("v1", c.epoch)
assertEquals(42uL, c.number)
// toString() — debug-форма, raw представление собирается как "<epoch>-<number.toString(16)>".
assertEquals("v1-2a", "${c.epoch}-${c.number.toString(16)}")
}
@Test
fun `fromString разбирает корректный курсор`() {
val c = Cursor.fromString("v2-1f4")
assertEquals("v2", c.epoch)
assertEquals(0x1f4uL, c.number)
}
@Test
fun `round-trip через строковое представление`() {
val original = Cursor("v3", 0xdeadbeefuL)
val raw = "${original.epoch}-${original.number.toString(16)}"
val parsed = Cursor.fromString(raw)
assertEquals(original.epoch, parsed.epoch)
assertEquals(original.number, parsed.number)
}
@Test
fun `next инкрементирует number сохраняя epoch`() {
val c = Cursor("v1", 5uL).next()
assertEquals("v1", c.epoch)
assertEquals(6uL, c.number)
}
@Test
fun `next переходит через границу ULong`() {
val maxMinusOne = Cursor("v1", ULong.MAX_VALUE - 1uL).next()
assertEquals(ULong.MAX_VALUE, maxMinusOne.number)
}
@Test
fun `compareTo внутри одной эпохи`() {
val a = Cursor("v1", 1uL)
val b = Cursor("v1", 2uL)
val c = Cursor("v1", 2uL)
assertEquals(true, a < b)
assertEquals(true, b > a)
assertEquals(0, b.compareTo(c))
assertNotEquals(0, a.compareTo(b))
}
@Test
fun `compareTo между разными эпохами лексикографически по epoch`() {
// Реализация сравнивает epoch как строку, потом number как число.
// "v10" vs "v2": "v10" < "v2" лексикографически, потому что '1' < '2'.
// Это by design — нумерация эпох должна быть либо монотонной (v1, v2, v3),
// либо фиксированной длины (v01, v02). Внутри одного поколения журнала
// эпоха не меняется, так что лексикографическое сравнение эпох в проде не
// нужно — оно существует только для сортировки/отсечения.
val v2 = Cursor("v2", 999uL)
val v10 = Cursor("v10", 0uL)
assertEquals(true, v10 < v2)
}
@Test
fun `compareTo с фиксированной длиной epoch монотонна`() {
val v01 = Cursor("v01", 0uL)
val v02 = Cursor("v02", 0uL)
val v10 = Cursor("v10", 0uL)
assertEquals(true, v01 < v02)
assertEquals(true, v02 < v10)
}
@Test
fun `fromString бросает на пустую строку`() {
assertFailsWith<IllegalArgumentException> { Cursor.fromString("") }
}
@Test
fun `fromString бросает на строку без разделителя`() {
assertFailsWith<IllegalArgumentException> { Cursor.fromString("noseparator") }
}
@Test
fun `fromString бросает на строку с несколькими разделителями`() {
assertFailsWith<IllegalArgumentException> { Cursor.fromString("v1-1-2") }
}
@Test
fun `fromString бросает на невалидный hex в number`() {
assertFailsWith<IllegalArgumentException> { Cursor.fromString("v1-zz") }
}
@Test
fun `constructor бросает на пустую epoch`() {
assertFailsWith<IllegalArgumentException> { Cursor("", 0uL) }
}
@Test
fun `constructor бросает на epoch с разделителем`() {
assertFailsWith<IllegalArgumentException> { Cursor("v-1", 0uL) }
}
@Test
fun `number в верхнем регистре hex при fromString`() {
// fromString использует toULong(16), который case-insensitive.
val c = Cursor.fromString("v1-FF")
assertEquals(255uL, c.number)
}
}
@@ -0,0 +1,114 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.MessageId
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Проверки §7.1 и §7.2 спеки: редактирование и удаление сообщений и чатов.
*
* Главные проверяемые инварианты:
* - Edited заменяет content «на месте», last_cursor обновляется.
* - Deleted физически удаляет строку (см. §7.2).
* - replace при resync подхватывает новый контент / отсутствующие строки.
*/
class EditDeleteTest {
@Test
fun `edit заменяет content и обновляет lastCursor`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
val created = b.server.append(samples.chatCreated(0))
val appended = b.server.append(samples.messageAppended(0, 0, content = "v1"))
val edited = b.server.append(samples.messageEdited(0, 0, newContent = "v2"))
b.engine.sync()
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, msgs.size)
assertEquals("v2", msgs.single().content)
assertEquals(edited.cursor, msgs.single().lastCursor)
// lastCursor строки > cursor appended.
assertTrue(msgs.single().lastCursor > appended.cursor)
// created event's cursor меньше appended, edited больше.
assertTrue(created.cursor < appended.cursor)
assertTrue(edited.cursor > appended.cursor)
b.close()
}
@Test
fun `delete физически убирает строку`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.server.append(samples.messageAppended(0, 1))
b.server.append(samples.messageDeleted(0, 1))
b.engine.sync()
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, msgs.size)
assertEquals(MessageId("msg-0-0"), msgs.single().id)
b.close()
}
@Test
fun `resync после edit подхватывает новый content из снапшота`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0, content = "v1"))
b.engine.sync()
// Edit на сервере.
b.server.append(samples.messageEdited(0, 0, newContent = "v2-final"))
b.compactServer(upTo = b.server.lastCursor())
// Sync с clientLastCursor=1 (чат создан) → cursor_expired → replace.
b.engine.sync()
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
assertEquals("v2-final", msgs.single().content)
b.close()
}
@Test
fun `resync после delete убирает строку через снапшот`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.server.append(samples.messageAppended(0, 1))
b.engine.sync()
b.server.append(samples.messageDeleted(0, 1))
b.compactServer(upTo = b.server.lastCursor())
b.engine.sync()
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, msgs.size)
b.close()
}
@Test
fun `chat deleted физически удаляет чат из state`() = kotlinx.coroutines.runBlocking {
// §7.2 + §10.1 спеки: «Не делать tombstones, если не нужно показывать
// удалено. Физическое удаление + replace при resync решают всё.»
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.chatDeleted(0))
b.engine.sync()
val chat = b.clientReadState().chats[ChatId("chat0")]
assertEquals(null, chat, "чат должен быть физически удалён")
b.close()
}
@Test
fun `edit в середине диалога не теряет соседние сообщения`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0, content = "a"))
b.server.append(samples.messageAppended(0, 1, content = "b"))
b.server.append(samples.messageAppended(0, 2, content = "c"))
b.server.append(samples.messageEdited(0, 1, newContent = "B!"))
b.engine.sync()
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(3, msgs.size)
assertEquals("a", msgs[0].content)
assertEquals("B!", msgs[1].content)
assertEquals("c", msgs[2].content)
b.close()
}
}
@@ -0,0 +1,142 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.CursorExpiredReason
import pw.binom.agentik.sync.UpdatesResponse
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Проверки §6.3 спеки: поведение при смене эпохи (epoch mismatch) и при
* протухшем по той же эпохе курсоре (cursor too old). В обоих случаях
* клиент обязан сделать полный resync (replaceState).
*
* - **WRONG_EPOCH** — клиент прислал курсор из другой эпохи (например,
* сервер был wiped или восстановлен из бэкапа). Сервер его отвергает
* с `reason = WRONG_EPOCH`.
* - **TOO_OLD** — клиент прислал курсор из той же эпохи, но ниже
* `min_available_cursor` (после compact). Сервер отвергает с
* `reason = TOO_OLD`.
*
* И там, и там клиент делает replaceState и начинает синхронизацию
* заново. Логически реакция одинаковая, но причина разная — отделяем
* для диагностики.
*/
class EpochMismatchTest {
@Test
fun `WRONG_EPOCH - клиент со старой эпохой получает CursorExpired reason WRONG_EPOCH`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0, content = "v1"))
b.engine.sync()
val clientCursorBefore = b.client.syncState().cursor
val clientEpochBefore = clientCursorBefore.epoch
// Сервер wipe'ает журнал и начинает новую эпоху.
val newCursor = b.serverBeginNewEpoch(newEpoch = "v2")
assertEquals("v2", newCursor.epoch)
assertEquals(0uL, newCursor.number)
// Клиент по-прежнему сидит в старой эпохе. Сервер ему отвечает.
val resp = b.transport.fetchUpdates(Cursor(clientEpochBefore, 100uL), 100)
assertTrue(resp is UpdatesResponse.CursorExpired)
val exp = resp as UpdatesResponse.CursorExpired
assertEquals(CursorExpiredReason.WRONG_EPOCH, exp.reason)
// Сервер подсказывает, в какой эпохе теперь живёт.
assertEquals("v2", exp.current.epoch)
b.close()
}
@Test
fun `TOO_OLD - клиент с тем же epoch но ниже min_available_cursor получает CursorExpired reason TOO_OLD`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.server.append(samples.messageAppended(0, 1))
b.engine.sync()
// Сервер compact'ит все события, клиентский курсор становится ниже floor.
b.compactServer(upTo = b.serverLastCursor())
val resp = b.transport.fetchUpdates(Cursor.ZERO, 100)
assertTrue(resp is UpdatesResponse.CursorExpired)
val exp = resp as UpdatesResponse.CursorExpired
assertEquals(CursorExpiredReason.TOO_OLD, exp.reason)
// Эпоха совпадает.
assertEquals(b.client.syncState().cursor.epoch, exp.current.epoch)
b.close()
}
@Test
fun `sync после WRONG_EPOCH автоматически делает replaceState и подхватывает новое состояние`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0, content = "old"))
b.engine.sync()
// Сервер wipe'ает журнал. Клиент по-прежнему сидит в старой эпохе.
b.serverBeginNewEpoch("v2")
b.server.append(samples.chatCreated(1))
b.server.append(samples.messageAppended(1, 0, content = "fresh"))
// Sync видит WRONG_EPOCH → replaceState.
val report = b.engine.sync()
assertTrue(report.resolvedCursorGap, "sync должен был резолвить gap")
// Клиент в новой эпохе, не имеет старого чата, имеет новый.
assertEquals("v2", b.client.syncState().cursor.epoch)
val state = b.clientReadState()
assertEquals(null, state.chats[pw.binom.agentik.sync.ChatId("chat0")])
assertEquals("fresh", state.messages[pw.binom.agentik.sync.ChatId("chat1")]?.single()?.content)
b.close()
}
@Test
fun `sync после TOO_OLD тоже делает replaceState`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.engine.sync()
b.server.append(samples.messageAppended(0, 1, content = "still-cached"))
b.compactServer(upTo = b.serverLastCursor())
val report = b.engine.sync()
assertTrue(report.resolvedCursorGap)
// После replaceState клиент имеет полный снапшот — оба сообщения.
val msgs = b.clientReadState().messages[pw.binom.agentik.sync.ChatId("chat0")].orEmpty()
assertTrue(msgs.any { it.content == "still-cached" }, "новое сообщение должно быть в снапшоте")
b.close()
}
@Test
fun `beginNewEpoch сбрасывает seq counter - новые события начинают от 1`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
for (i in 0..10) b.server.append(samples.messageAppended(0, i))
val before = b.serverLastCursor()
assertTrue(before.number > 10uL, "до wipe курсор должен быть больше 10")
b.serverBeginNewEpoch("v2")
b.server.append(samples.chatCreated(0))
val after = b.serverLastCursor()
// После wipe seq начинается заново: новое событие = 1 (а не 12).
assertEquals("v2", after.epoch)
assertEquals(1uL, after.number)
b.close()
}
@Test
fun `epoch нужен для проверки stale-ness - курсор из другой эпохи не сравнивается через compareTo на «свежесть»`() {
// Документируем семантику: lexCompareTo для разных эпох упорядочит,
// но isAfter/isBefore возвращает null. Это by design.
val a = Cursor("v1", 100uL)
val b2 = Cursor("v2", 1uL)
// Lexicographic — лексикографически по строке эпохи: "v1" < "v2".
assertTrue(a < b2, "v1 лексикографически меньше v2")
// isAfter возвращает null — семантически курсоры несравнимы.
assertEquals(null, a.isAfter(b2))
assertEquals(null, b2.isAfter(a))
}
}
@@ -0,0 +1,91 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Проверки §8.1 спеки — идемпотентность по `eventId`.
*/
class IdempotencyTest {
@Test
fun `повторный append с тем же eventId возвращает тот же cursor`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
val e = samples.chatCreated(0)
val first = b.server.append(e)
val second = b.server.append(e)
assertEquals(first.cursor, second.cursor)
b.close()
}
@Test
fun `повторный applyRemote с тем же eventId — no-op`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.server.append(samples.messageAppended(0, 0))
b.engine.sync()
val snapshot = b.clientReadState()
val msgCountBefore = snapshot.messages.values.sumOf { it.size }
// Применяем первое событие ещё раз — клиентский state не должен меняться.
val first = b.server.readUpdates(Cursor.ZERO, 100).first()
b.client.applyRemote(first)
val msgCountAfter = b.clientReadState().messages.values.sumOf { it.size }
assertEquals(msgCountBefore, msgCountAfter)
b.close()
}
@Test
fun `postPending c дубликатом eventId принимает оба - но без дублей строк`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
val userMsg = samples.messageAppended(0, 0, content = "hello")
// Клиент создаёт pending (локально).
b.client.enqueuePending(userMsg, someInstant)
val pendingList = b.client.pendingEvents()
assertEquals(1, pendingList.size)
// Сервер уже знает это сообщение (например, другое устройство отправило).
b.server.append(userMsg)
// postPending должен принять (идемпотентно), строка одна.
val results = b.transport.postPending(pendingList)
assertEquals(1, results.size)
assertTrue(results.first() is pw.binom.agentik.sync.PostResult.Accepted)
b.client.markPendingAsSent(
pendingList.first().localId,
(results.first() as pw.binom.agentik.sync.PostResult.Accepted).cursor,
)
// Клиент видит только одно сообщение.
val msgs = b.clientReadState().messages.values.flatten()
assertEquals(1, msgs.count { it.content == "hello" })
b.close()
}
@Test
fun `cursor монотонен после повторного append`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
val last1 = b.server.lastCursor()
// Повторный append с тем же eventId.
b.server.append(samples.chatCreated(0))
assertEquals(last1, b.server.lastCursor(), "повторный append не двигает cursor")
// Новый eventId — сдвигает.
b.server.append(samples.messageAppended(0, 0))
assertEquals(last1.next(), b.server.lastCursor())
b.close()
}
@Test
fun `updates возвращает одно и то же событие при повторных вызовах`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
val first = b.transport.fetchUpdates(Cursor.ZERO, 100)
val second = b.transport.fetchUpdates(Cursor.ZERO, 100)
assertEquals(first, second)
b.close()
}
}
@@ -0,0 +1,166 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.ChatState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.DomainEvent
import pw.binom.agentik.sync.StateSnapshot
import pw.binom.agentik.sync.UpdatesResponse
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Проверяет **семь инвариантов** из §2 спеки:
* 1. cursor монотонно возрастает (нет дыр, нет сбросов).
* 2. Журнал append-only (события не изменяются, не удаляются кроме как через compact).
* 3. Материализация консистентна журналу: apply(journal[0..N]) == SELECT state WHERE last_cursor <= N.
* 4. События самодостаточны (несут полный payload).
* 5. Full resync = replace (не merge).
* 6. Клиент читает только локальную БД (после sync clientReadState() == serverReadState()).
* 7. Компакция не удаляет события, нужные активным клиентам (cursor_expired → resync).
*/
class InvariantsTest {
private suspend fun withBundle(block: suspend (SyncBundleFacade) -> Unit) {
val b = InMemorySyncBundle.create()
try {
block(SyncBundleFacade(b))
} finally {
b.close()
}
}
/** Лёгкая обёртка — выносим engine.sync() чтобы не дублировать. */
class SyncBundleFacade(val b: pw.binom.agentik.sync.testing.SyncBundle) {
suspend fun sync() = b.engine.sync()
}
// ─── Invariant 1: cursor монотонен ──────────────────────────────────────
@Test
fun `1 cursor монотонно возрастает на append`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
val s1 = f.b.server.append(samples.chatCreated(0))
val s2 = f.b.server.append(samples.messageAppended(0, 0))
val s3 = f.b.server.append(samples.messageAppended(0, 1))
assertEquals(s1.cursor.next(), s2.cursor)
assertEquals(s2.cursor.next(), s3.cursor)
}
}
@Test
fun `1 cursor number начинается с 1 на свежем журнале`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
assertEquals(Cursor.ZERO, f.b.server.lastCursor())
val s1 = f.b.server.append(samples.chatCreated(0))
assertEquals(1uL, s1.cursor.number)
}
}
// ─── Invariant 2: append-only ───────────────────────────────────────────
@Test
fun `2 события не изменяются после append`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
val e = f.b.server.append(samples.chatCreated(0))
val again = f.b.server.readUpdates(Cursor.ZERO, 100).single()
assertEquals(e, again)
}
}
// ─── Invariant 3: materialization consistent with journal ────────────────
@Test
fun `3 материализация сервера консистентна журналу`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
val e1 = f.b.server.append(samples.chatCreated(0))
val e2 = f.b.server.append(samples.messageAppended(0, 0))
val e3 = f.b.server.append(samples.messageAppended(0, 1))
val state = f.b.serverReadState()
assertTrue(state.chats.isNotEmpty(), "чат должен быть в state")
assertEquals(2, state.messages[ChatId("chat0")]?.size, "два сообщения в state")
assertEquals(e3.cursor, state.lastCursor)
assertTrue(e1.cursor <= state.lastCursor)
assertTrue(e2.cursor <= state.lastCursor)
}
}
// ─── Invariant 4: events self-contained ─────────────────────────────────
@Test
fun `4 событие несёт полный payload - не дельту`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
val created = DomainEvent.ChatCreated(
eventId = "e-create-0",
chatId = ChatId("chat-X"),
title = "Полное название",
createdAt = someInstant,
)
val logged = f.b.server.append(created)
val read = f.b.server.readUpdates(Cursor.ZERO, 100).single()
assertEquals("Полное название", (read.payload as DomainEvent.ChatCreated).title)
assertEquals(ChatId("chat-X"), (read.payload as DomainEvent.ChatCreated).chatId)
assertEquals(logged.cursor, read.cursor)
}
}
// ─── Invariant 5: replace, не merge ─────────────────────────────────────
@Test
fun `5 replaceState полностью заменяет state - не мержит`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
f.b.server.append(samples.chatCreated(0))
f.b.server.append(samples.chatCreated(1))
f.b.server.append(samples.messageAppended(0, 0))
f.sync()
assertEquals(2, f.b.clientReadState().chats.size)
f.b.server.append(samples.chatDeleted(0))
// Стираем клиентский state вручную.
f.b.client.replaceState(StateSnapshot(ChatState()))
// Сервер делает compact → cursor_expired → replace.
f.b.compactServer(upTo = f.b.serverLastCursor())
f.sync()
val chats = f.b.clientReadState().chats
assertEquals(1, chats.size, "после replace лишний чат должен исчезнуть")
assertNotNull(chats[ChatId("chat1")])
assertEquals(null, chats[ChatId("chat0")], "удалённый чат не должен быть в state")
}
}
// ─── Invariant 6: client state == server state после sync ───────────────
@Test
fun `6 clientReadState совпадает с serverReadState после sync`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
f.b.server.append(samples.chatCreated(0))
f.b.server.append(samples.messageAppended(0, 0))
f.b.server.append(samples.chatCreated(1))
f.b.server.append(samples.messageAppended(1, 0))
f.sync()
assertEquals(f.b.serverReadState(), f.b.clientReadState())
}
}
// ─── Invariant 7: cursor_expired → full resync ───────────────────────────
@Test
fun `7 compact делает cursor протухшим - sync делает replace`() = kotlinx.coroutines.runBlocking {
withBundle { f ->
f.b.server.append(samples.chatCreated(0))
f.b.server.append(samples.messageAppended(0, 0))
f.sync()
val clientCursor = f.b.clientLastCursor()
f.b.server.append(samples.messageAppended(0, 1))
f.b.server.append(samples.messageAppended(0, 2))
f.b.compactServer(upTo = clientCursor)
val resp = f.b.transport.fetchUpdates(clientCursor, 100)
assertTrue(resp is UpdatesResponse.CursorExpired, "должен быть CursorExpired")
val report = f.b.engine.sync()
assertTrue(report.resolvedCursorGap, "sync должен resolve cursor gap")
assertEquals(3, f.b.clientReadState().messages[ChatId("chat0")]?.size)
}
}
}
@@ -0,0 +1,106 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.LocalId
import pw.binom.agentik.sync.PendingStatus
import pw.binom.agentik.sync.PostResult
import pw.binom.agentik.sync.test.memory.InMemorySyncBundle
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Проверки §3.2 (PendingEvent), §5.3 (userSendsMessage), §5.4 (localAssistant),
* §8.4 (офлайн-запись).
*/
class OfflineWriteTest {
@Test
fun `client enqueuePending оптимистично применяет событие`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
val pe = b.client.enqueuePending(samples.messageAppended(0, 0, content = "hi"), someInstant)
val msgs = b.clientReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, msgs.size)
assertEquals("hi", msgs.single().content)
assertEquals(PendingStatus.PENDING, pe.status)
b.close()
}
@Test
fun `sync отправляет pending на сервер и обновляет статус`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.client.enqueuePending(samples.messageAppended(0, 0, content = "hi"), someInstant)
val report = b.engine.sync()
assertEquals(1, report.postedPending)
assertEquals(0, report.failedPending)
// Сервер тоже видит это сообщение.
val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, serverMsgs.size)
assertEquals("hi", serverMsgs.single().content)
b.close()
}
@Test
fun `сначала client пишёт офлайн - потом sync - потом новое сообщение и опять sync`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.client.enqueuePending(samples.messageAppended(0, 0, content = "offline-1"), someInstant)
b.engine.sync()
b.client.enqueuePending(samples.messageAppended(0, 1, content = "online-1"), someInstant)
b.engine.sync()
val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(2, serverMsgs.size)
b.close()
}
@Test
fun `PendingEvent остаётся после replaceState`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
b.client.enqueuePending(samples.messageAppended(0, 0, content = "keep-me"), someInstant)
b.client.replaceState(pw.binom.agentik.sync.StateSnapshot(pw.binom.agentik.sync.ChatState()))
val pending = b.client.pendingEvents()
assertEquals(1, pending.size)
assertEquals(PendingStatus.PENDING, pending.single().status)
val report = b.engine.sync()
assertEquals(1, report.postedPending)
val serverMsgs = b.serverReadState().messages[ChatId("chat0")].orEmpty()
assertEquals(1, serverMsgs.size)
assertEquals("keep-me", serverMsgs.single().content)
b.close()
}
@Test
fun `markPendingAsFailed переводит pending в FAILED`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
val pe = b.client.enqueuePending(samples.messageAppended(0, 0, content = "x"), someInstant)
b.client.markPendingAsFailed(pe.localId)
val pending = b.client.pendingEvents()
// markPendingAsFailed — теперь pending в FAILED.
// pendingEvents() отдаёт только PENDING — поэтому проверяем через allPending().
val all = (b.client as pw.binom.agentik.sync.memory.InMemoryEventLog).allPending()
assertEquals(PendingStatus.FAILED, all.single().status)
b.close()
}
@Test
fun `postPending возвращает Rejected если сервер отверг`() = kotlinx.coroutines.runBlocking {
val b = InMemorySyncBundle.create()
b.server.append(samples.chatCreated(0))
// Сначала кладём pending в локальную очередь.
b.client.enqueuePending(samples.messageAppended(0, 0, content = "x"), someInstant)
// Симулируем ответ сервера — rejected.
val results: List<PostResult> = listOf(PostResult.Rejected(localId = LocalId("evt-ma-0-0"), reason = "validation failed"))
for (r in results) {
when (r) {
is PostResult.Accepted -> b.client.markPendingAsSent(r.localId, r.cursor)
is PostResult.Rejected -> b.client.markPendingAsFailed(r.localId)
}
}
val all = (b.client as pw.binom.agentik.sync.memory.InMemoryEventLog).allPending()
assertEquals(PendingStatus.FAILED, all.single().status)
b.close()
}
}
@@ -0,0 +1,63 @@
package pw.binom.agentik.sync.test
import pw.binom.agentik.sync.ChatId
import pw.binom.agentik.sync.DomainEvent
import pw.binom.agentik.sync.MessageId
import kotlin.time.Instant
/**
* Утилиты для тестов: удобные конструкторы событий с детерминированными
* `eventId` (чтобы тесты могли ссылаться на них в проверках).
*/
object samples {
private fun eid(tag: String, n: Int): String = "evt-$tag-$n"
fun chatCreated(n: Int): DomainEvent.ChatCreated = DomainEvent.ChatCreated(
eventId = eid("cc", n),
chatId = ChatId("chat$n"),
title = "Чат #$n",
createdAt = someInstant,
)
fun chatRenamed(n: Int, newTitle: String = "Чат #$n — переименован"): DomainEvent.ChatRenamed =
DomainEvent.ChatRenamed(
eventId = eid("cr", n),
chatId = ChatId("chat$n"),
newTitle = newTitle,
)
fun chatDeleted(n: Int): DomainEvent.ChatDeleted = DomainEvent.ChatDeleted(
eventId = eid("cd", n),
chatId = ChatId("chat$n"),
)
fun messageAppended(chatN: Int, msgN: Int, content: String = "msg-$chatN-$msgN"): DomainEvent.MessageAppended =
DomainEvent.MessageAppended(
eventId = eid("ma-$chatN", msgN),
messageId = MessageId("msg-$chatN-$msgN"),
chatId = ChatId("chat$chatN"),
role = if (msgN % 2 == 0) pw.binom.agentik.sync.MessageRole.USER else pw.binom.agentik.sync.MessageRole.ASSISTANT,
content = content,
parentId = null,
createdAt = someInstant,
)
fun messageEdited(chatN: Int, msgN: Int, newContent: String): DomainEvent.MessageEdited =
DomainEvent.MessageEdited(
eventId = eid("me-$chatN", msgN),
messageId = MessageId("msg-$chatN-$msgN"),
chatId = ChatId("chat$chatN"),
newContent = newContent,
)
fun messageDeleted(chatN: Int, msgN: Int): DomainEvent.MessageDeleted =
DomainEvent.MessageDeleted(
eventId = eid("md-$chatN", msgN),
messageId = MessageId("msg-$chatN-$msgN"),
chatId = ChatId("chat$chatN"),
)
}
/** Фиксированный момент времени — тесты не зависят от часов. */
val someInstant: Instant = Instant.parse("2026-10-01T00:00:00Z")
@@ -0,0 +1,64 @@
package pw.binom.agentik.sync.test.memory
import pw.binom.agentik.sync.CompactionState
import pw.binom.agentik.sync.Cursor
import pw.binom.agentik.sync.EventLogReplica
import pw.binom.agentik.sync.EventLogWriter
import pw.binom.agentik.sync.StateStore
import pw.binom.agentik.sync.SyncEngine
import pw.binom.agentik.sync.SyncTransport
import pw.binom.agentik.sync.memory.InMemoryEventLog
import pw.binom.agentik.sync.memory.InProcessTransport
import pw.binom.agentik.sync.testing.SyncBundle
/**
* Бэкенд для тестов на чистой памяти. Серверная и клиентская сторона —
* разные [InMemoryEventLog] (writer-роль и replica-роль), объединённые
* общим [InProcessTransport] (имитирует HTTP/WS).
*/
class InMemorySyncBundle private constructor(
private val serverLog: InMemoryEventLog,
private val clientLog: InMemoryEventLog,
private val transportImpl: InProcessTransport,
) : SyncBundle() {
override val server: EventLogWriter = serverLog
override val serverState: StateStore = serverLog
override val client: EventLogReplica = clientLog
override val clientState: StateStore = clientLog
override val transport: SyncTransport = transportImpl
override val engine: SyncEngine = SyncEngine(replica = clientLog, transport = transportImpl)
override suspend fun compactionState(): CompactionState = serverLog.compaction()
override suspend fun compactServer(upTo: Cursor) {
serverLog.compact(upTo)
}
override suspend fun serverBeginNewEpoch(newEpoch: String): Cursor =
serverLog.beginNewEpoch(newEpoch)
/** Отдельно сменить эпоху на клиенте (для тестов сценария «клиент уже
* синхронизировался, а потом сервер wipe'нул»). */
suspend fun clientBeginNewEpoch(newEpoch: String): Cursor = clientLog.beginNewEpoch(newEpoch)
override fun close() {
serverLog.close()
clientLog.close()
transportImpl.close()
}
companion object {
/** Свежий изолированный бандл. */
fun create(): InMemorySyncBundle {
val server = InMemoryEventLog()
val client = InMemoryEventLog()
val transport = InProcessTransport(
server = server,
stateStore = server,
compactionState = { server.compaction() },
)
return InMemorySyncBundle(serverLog = server, clientLog = client, transportImpl = transport)
}
}
}