refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api

Семантика outbox'а — теперь чистый live-канал:
  * OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
    Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
    на live. Outbox ответственен только за уведомления «что-то произошло».
  * OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
    Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
    рестарт и инкремент для каждого commit.
  * DurableEvent: commit принимает блок { cursor -> MessageRecord }
    (cursor выдаёт CursorStore; клиент не вычисляет offset сам).
  * MessageRecord больше не несёт cursor — это не его ответственность.

Новые модули:
  * :cursor-api         — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
  * :cursor-ksqlite     — KsqliteCursorHolder (персистентный)
  * :cursor-inmemory    — для тестов
  * :client-sync        — LocalSyncAgent (мини-агент поверх :client для десктопа)

Удалены:
  * :sync-core          — старая референсная реализация, заменена
                           cursor-разделением и :client.
  * outbox-ksqlite      — CursorStore/Schema уехали в :cursor-ksqlite.
  * OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.

standalone:
  * ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
    ConversationEvents — подписка через push-паттерн (collect событий).
  * A2aBridge — currentCursor() и conversationEvents(after=) убраны.
  * SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
    читает MAX(created_at).
  * Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
    transport удалён; debug-routes удалены; A2A остался.
  * Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
    (subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅

server / client:
  * Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
  * AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
  * ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
  * JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
2026-10-05 23:33:35 +03:00
parent 22a167ed03
commit 9e888227a3
115 changed files with 2147 additions and 6025 deletions
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
// Полный набор KMP-целей. Зеркалит :proto.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.protobuf)
api(libs.ksqlite)
api(project(":sync"))
api(project(":content-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,414 @@
package pw.binom
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoBuf
import pw.binom.agentik.content.Content
import pw.binom.agentik.cursor.Cursor
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import pw.binom.db.ksqlite.transaction
import kotlin.time.Instant
@Serializable
sealed interface MessageRecord {
@Serializable
data class Conversation(val id: String, val title: String?) : MessageRecord
interface Message {
val id: String
val date: Instant
}
@Serializable
data class UserMessage(
override val id: String,
override val date: Instant,
val content: List<Content>,
val conversationId: String,
) : MessageRecord, Message
@Serializable
data class AssistantMessage(
override val id: String,
override val date: Instant,
val conversationId: String,
val content: List<Content>,
) : MessageRecord, Message
@Serializable
data class ToolCall(
override val id: String,
override val date: Instant,
val conversationId: String,
val name: String,
val args: String,
) : MessageRecord, Message
@Serializable
data class ToolResponse(
override val id: String,
override val date: Instant,
val conversationId: String,
val callId: String,
val response: String,
) : MessageRecord, Message
@Serializable
data class ToolFail(
override val id: String,
override val date: Instant,
val conversationId: String,
val callId: String,
val response: String,
) : MessageRecord, Message
}
@Serializable
sealed interface MessageEvent {
interface Conversation {
val conversationId: String
}
interface Message {
val id: String
val date: Instant
val conversationId: String
}
@Serializable
class CreateConversation(override val conversationId: String, val title: String?) : MessageEvent, Conversation
@Serializable
class DeleteConversation(override val conversationId: String) : MessageEvent, Conversation
@Serializable
class RenameConversation(override val conversationId: String, val title: String?) : MessageEvent, Conversation
@Serializable
class AddUserMessage(
override val id: String,
override val date: Instant,
override val conversationId: String,
val content: List<Content>,
) : MessageEvent, Message
@Serializable
class AddAssistantMessage(
override val id: String,
override val date: Instant,
override val conversationId: String,
val content: List<Content>,
) : MessageEvent, Message
@Serializable
class AddToolCall(
override val id: String,
override val date: Instant,
override val conversationId: String,
val name: String,
val args: String,
) : MessageEvent, Message
@Serializable
class AddToolResponse(
override val id: String,
override val date: Instant,
override val conversationId: String,
val callId: String,
val response: String,
) : MessageEvent, Message
@Serializable
class AddToolFail(
override val id: String,
override val date: Instant,
override val conversationId: String,
val callId: String,
val response: String,
) : MessageEvent, Message
}
/**
* Локальное хранилище сообщений: журнал событий [MessageEvent] плюс материализованные таблицы
* `conversations` и `messages`, из которых читается состояние через [getState].
*
* Модель состояния — **last-write-cursor**: каждая строка проекции хранит курсор последнего
* события, которое её изменило (create/rename для беседы; add для сообщения). [getState] с
* курсором `C` отдаёт строки, которых не касались после `C`, — то есть строка, изменённая позже
* `C`, выпадает целиком. Это не «замороженный снимок значений», но после применения всех событий
* до `C` состояние сходится, поэтому два реплики, применившие один и тот же префикс журнала,
* получают одинаковое состояние.
*
* Инварианты:
* - `push` применяет событие к проекции и дозаписывает его в журнал **в одной транзакции**;
* - курсор строго возрастает (наследуется от [KSqliteMutableEventStore]) — повторный `push` с тем же
* или меньшим курсором бросает [IllegalArgumentException] и откатывает проекцию;
* - удаление беседы каскадно удаляет её сообщения из проекции (события в журнале остаются).
*
* **Не потокобезопасно.** Общие prepared statements и одно соединение — работать строго в одном
* потоке. Коллекции [getState] готовят собственные statements, поэтому безопасны относительно
* [push]/[clear], но НЕ относительно [close] во время активной коллекции.
*/
@OptIn(ExperimentalSerializationApi::class)
class MessageStore(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableStorage<Flow<MessageRecord>, MessageEvent> {
private val events = KSqliteMutableEventStore(
connection = connection,
tableName = "events",
serializer = MessageEvent.serializer(),
)
private val proto = ProtoBuf
init {
connection.transaction {
connection.exec(
"""
CREATE TABLE IF NOT EXISTS conversations (
ID TEXT NOT NULL PRIMARY KEY,
CREATED_AT INTEGER NOT NULL,
OFFSET INTEGER NOT NULL,
PAYLOAD BLOB NOT NULL
)
""".trimIndent()
)
connection.exec(
"""
CREATE TABLE IF NOT EXISTS messages (
ID TEXT NOT NULL PRIMARY KEY,
CONVERSATION_ID TEXT NOT NULL,
CREATED_AT INTEGER NOT NULL,
OFFSET INTEGER NOT NULL,
PAYLOAD BLOB NOT NULL
)
""".trimIndent()
)
connection.exec(
"CREATE INDEX IF NOT EXISTS messages_by_conversation ON messages (CONVERSATION_ID)"
)
}
}
private val upsertConversationStatement = connection.prepare(
"insert or replace into conversations (ID, CREATED_AT, OFFSET, PAYLOAD) values (?,?,?,?)"
)
private val deleteConversationStatement = connection.prepare(
"delete from conversations where ID = ?"
)
private val deleteMessagesByConversationStatement = connection.prepare(
"delete from messages where CONVERSATION_ID = ?"
)
private val upsertMessageStatement = connection.prepare(
"""
insert or replace into messages (ID, CONVERSATION_ID, CREATED_AT, OFFSET, PAYLOAD)
values (?,?,?,?,?)
""".trimIndent()
)
override fun push(event: MessageEvent, cursor: Cursor) {
connection.transaction {
when (event) {
is MessageEvent.CreateConversation ->
upsertConversation(
event.conversationId, cursor,
MessageRecord.Conversation(event.conversationId, event.title),
)
is MessageEvent.RenameConversation ->
upsertConversation(
event.conversationId, cursor,
MessageRecord.Conversation(event.conversationId, event.title),
)
is MessageEvent.DeleteConversation -> {
deleteMessagesByConversationStatement.bindText(1, event.conversationId)
deleteMessagesByConversationStatement.executeUpdate()
deleteConversationStatement.bindText(1, event.conversationId)
deleteConversationStatement.executeUpdate()
}
is MessageEvent.AddUserMessage ->
upsertMessage(
event.id, event.conversationId, cursor,
MessageRecord.UserMessage(event.id, event.date, event.content, event.conversationId),
)
is MessageEvent.AddAssistantMessage ->
upsertMessage(
event.id, event.conversationId, cursor,
MessageRecord.AssistantMessage(event.id, event.date, event.conversationId, event.content),
)
is MessageEvent.AddToolCall ->
upsertMessage(
event.id, event.conversationId, cursor,
MessageRecord.ToolCall(event.id, event.date, event.conversationId, event.name, event.args),
)
is MessageEvent.AddToolResponse ->
upsertMessage(
event.id, event.conversationId, cursor,
MessageRecord.ToolResponse(
event.id, event.date, event.conversationId, event.callId, event.response,
),
)
is MessageEvent.AddToolFail ->
upsertMessage(
event.id, event.conversationId, cursor,
MessageRecord.ToolFail(
event.id, event.date, event.conversationId, event.callId, event.response,
),
)
}
events.push(event, cursor)
}
}
override fun clear() {
connection.transaction {
events.clear()
connection.exec("delete from conversations")
connection.exec("delete from messages")
}
}
override fun getState(cursor: Cursor): Flow<MessageRecord> = flow {
// Statements готовятся на каждую коллекцию: две одновременные коллекции не мешают друг
// другу, а закрытие стора во время коллекции — единственный оставшийся небезопасный сценарий.
val conversationsStatement = connection.prepare(SELECT_CONVERSATIONS)
val messagesStatement = connection.prepare(SELECT_MESSAGES)
try {
val conversations = RecordSource(conversationsStatement, cursor)
val messages = RecordSource(messagesStatement, cursor)
while (true) {
val a = conversations.peek()
val b = messages.peek()
if (a == null && b == null) break
val takeConversation = b == null || (a != null && a.first <= b.first)
val payload = if (takeConversation) conversations.poll()!!.second else messages.poll()!!.second
emit(proto.decodeFromByteArray(MessageRecord.serializer(), payload))
}
} finally {
conversationsStatement.close()
messagesStatement.close()
}
}
override fun close() {
upsertConversationStatement.close()
deleteConversationStatement.close()
deleteMessagesByConversationStatement.close()
upsertMessageStatement.close()
events.close()
if (ownsConnection) {
connection.close()
}
}
override val minCursor: Cursor?
get() = events.minCursor
override val maxCursor: Cursor?
get() = events.maxCursor
override fun get(cursor: Cursor): EventSeqResult<MessageEvent> = events.get(cursor)
private fun upsertConversation(id: String, cursor: Cursor, record: MessageRecord.Conversation) {
upsertConversationStatement.bindText(1, id)
upsertConversationStatement.bindLong(2, cursor.createdAt)
upsertConversationStatement.bindLong(3, cursor.offset.toLong())
upsertConversationStatement.bindBlob(4, proto.encodeToByteArray(MessageRecord.serializer(), record))
upsertConversationStatement.executeUpdate()
}
private fun upsertMessage(id: String, conversationId: String, cursor: Cursor, record: MessageRecord) {
upsertMessageStatement.bindText(1, id)
upsertMessageStatement.bindText(2, conversationId)
upsertMessageStatement.bindLong(3, cursor.createdAt)
upsertMessageStatement.bindLong(4, cursor.offset.toLong())
upsertMessageStatement.bindBlob(5, proto.encodeToByteArray(MessageRecord.serializer(), record))
upsertMessageStatement.executeUpdate()
}
/**
* Источник строк одной таблицы с keyset-пагинацией: отдаёт строки в порядке
* `(CREATED_AT, OFFSET)` не позже [upper] и подгружает следующую страницу по мере расхода
* буфера.
*/
private class RecordSource(
private val statement: SQLitePreparedStatement,
private val upper: Cursor,
) {
private val buffer = ArrayDeque<Pair<Cursor, ByteArray>>()
private var after: Cursor? = null
private var exhausted = false
fun peek(): Pair<Cursor, ByteArray>? {
if (buffer.isEmpty() && !exhausted) load()
return buffer.firstOrNull()
}
fun poll(): Pair<Cursor, ByteArray>? {
val row = peek() ?: return null
buffer.removeFirst()
after = row.first
return row
}
private fun load() {
val from = after ?: FIRST
statement.bindLong(1, upper.createdAt)
statement.bindLong(2, upper.createdAt)
statement.bindLong(3, upper.offset.toLong())
statement.bindLong(4, from.createdAt)
statement.bindLong(5, from.createdAt)
statement.bindLong(6, from.offset.toLong())
statement.bindLong(7, PAGE_SIZE.toLong())
var count = 0
statement.executeQuery().use { rs ->
while (rs.next()) {
buffer.addLast(
Cursor(rs.getLong(0)!!, rs.getLong(1)!!.toULong()) to rs.getBlob(2)!!
)
count++
}
}
if (count < PAGE_SIZE) exhausted = true
}
companion object {
private val FIRST = Cursor(createdAt = Long.MIN_VALUE, offset = 0uL)
private const val PAGE_SIZE = 100
}
}
private companion object {
private val SELECT_CONVERSATIONS = """
select CREATED_AT, OFFSET, PAYLOAD from conversations
where (CREATED_AT < ? or (CREATED_AT = ? and OFFSET <= ?))
and (CREATED_AT > ? or (CREATED_AT = ? and OFFSET > ?))
order by CREATED_AT, OFFSET
limit ?
""".trimIndent()
private val SELECT_MESSAGES = """
select CREATED_AT, OFFSET, PAYLOAD from messages
where (CREATED_AT < ? or (CREATED_AT = ? and OFFSET <= ?))
and (CREATED_AT > ? or (CREATED_AT = ? and OFFSET > ?))
order by CREATED_AT, OFFSET
limit ?
""".trimIndent()
}
}
@@ -0,0 +1,382 @@
package pw.binom
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.content.Content
import pw.binom.agentik.cursor.Cursor
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
private const val EPOCH = 1_700_000_000_000L
/**
* Полный набор тестов [MessageStore] на in-memory SQLite: все ветки push, проекция,
* курсорная фильтрация, слияние двух таблиц, пагинация, каскадное удаление, откат транзакции,
* границы журнала и round-trip содержимого.
*/
class MessageStoreTest {
private var counter = 0UL
private val t0 = Instant.fromEpochMilliseconds(EPOCH)
private fun nextCursor() = Cursor(createdAt = EPOCH, offset = ++counter)
private fun cursorAt(offset: ULong) = Cursor(createdAt = EPOCH, offset = offset)
private suspend fun withStore(block: suspend (MessageStore) -> Unit) {
val conn = SQLiteConnection.memory("message-store-${kotlin.random.Random.nextLong()}")
val store = MessageStore(connection = conn, ownsConnection = false)
try {
block(store)
} finally {
store.close()
conn.close()
}
}
// ---- пустое хранилище -------------------------------------------------------------------
@Test
fun emptyStoreHasNoStateAndEmptyStreams() = runTest {
withStore { store ->
assertNull(store.minCursor)
assertNull(store.maxCursor)
assertTrue(store.getState(nextCursor()).toList().isEmpty())
assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty())
}
}
@Test
fun clearOnEmptyStoreIsNoop() = runTest {
withStore { store ->
store.clear()
assertNull(store.minCursor)
assertNull(store.maxCursor)
assertTrue(store.getState(nextCursor()).toList().isEmpty())
}
}
// ---- курсоры ---------------------------------------------------------------------------
@Test
fun minAndMaxCursorTrackPushes() = runTest {
withStore { store ->
assertNull(store.minCursor)
assertNull(store.maxCursor)
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(3u))
assertEquals(cursorAt(3u), store.minCursor)
assertEquals(cursorAt(3u), store.maxCursor)
store.push(MessageEvent.RenameConversation("c", "T2"), cursorAt(7u))
assertEquals(cursorAt(3u), store.minCursor)
assertEquals(cursorAt(7u), store.maxCursor)
}
}
@Test
fun pushRejectsNonIncreasingCursorAndRollsBackProjection() = runTest {
withStore { store ->
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("a"))), cursorAt(5u))
assertFailsWith<IllegalArgumentException> {
store.push(
MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("b"))),
cursorAt(5u),
)
}
assertFailsWith<IllegalArgumentException> {
store.push(
MessageEvent.AddUserMessage("m3", t0, "c", listOf(Content.Text("c"))),
cursorAt(4u),
)
}
// Проекция откатилась: только первое сообщение, курсор на месте.
assertEquals(
listOf<MessageRecord>(MessageRecord.UserMessage("m1", t0, listOf(Content.Text("a")), "c")),
store.getState(cursorAt(5u)).toList(),
)
assertEquals(cursorAt(5u), store.maxCursor)
}
}
@Test
fun clearResetsCursorSoLowerCursorAccepted() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(9u))
store.clear()
assertNull(store.minCursor)
assertNull(store.maxCursor)
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u))
assertEquals(cursorAt(1u), store.minCursor)
assertEquals(cursorAt(1u), store.maxCursor)
}
}
// ---- беседа: create / rename / delete --------------------------------------------------
@Test
fun conversationLifecycleUpdatesCursorOnChange() = runTest {
withStore { store ->
val created = nextCursor()
store.push(MessageEvent.CreateConversation("c1", "First"), created)
assertEquals(
listOf<MessageRecord>(MessageRecord.Conversation("c1", "First")),
store.getState(created).toList(),
)
val renamed = nextCursor()
store.push(MessageEvent.RenameConversation("c1", "Renamed"), renamed)
assertEquals(
listOf<MessageRecord>(MessageRecord.Conversation("c1", "Renamed")),
store.getState(renamed).toList(),
)
// Изменённая позже строка выпадает из состояния на более раннем курсоре.
assertTrue(store.getState(created).toList().isEmpty())
val deleted = nextCursor()
store.push(MessageEvent.DeleteConversation("c1"), deleted)
assertTrue(store.getState(deleted).toList().isEmpty())
}
}
@Test
fun deletingConversationRemovesItsMessages() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor())
store.push(MessageEvent.CreateConversation("b", "B"), nextCursor())
store.push(MessageEvent.AddUserMessage("m2", t0, "b", listOf(Content.Text("2"))), nextCursor())
store.push(MessageEvent.DeleteConversation("a"), nextCursor())
// Сообщения удалённой беседы не остаются сиротами в состоянии.
assertEquals(
listOf<MessageRecord>(
MessageRecord.Conversation("b", "B"),
MessageRecord.UserMessage("m2", t0, listOf(Content.Text("2")), "b"),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
@Test
fun deletingConversationKeepsJournalEvents() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("1"))), nextCursor())
store.push(MessageEvent.DeleteConversation("a"), nextCursor())
// Журнал append-only: удаление проекции не трогает историю событий.
val events = store.get(cursorAt(1u)).getOrException().toList()
assertEquals(3, events.size)
assertTrue(events.last().data is MessageEvent.DeleteConversation)
}
}
@Test
fun deletingUnknownConversationIsNoop() = runTest {
withStore { store ->
store.push(MessageEvent.DeleteConversation("nope"), nextCursor())
assertTrue(store.getState(store.maxCursor!!).toList().isEmpty())
}
}
// ---- сообщения: все типы + содержимое --------------------------------------------------
@Test
fun allMessageTypesInCursorOrder() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor())
store.push(MessageEvent.AddAssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))), nextCursor())
store.push(MessageEvent.AddToolCall("m3", t0, "c", "search", "{}"), nextCursor())
store.push(MessageEvent.AddToolResponse("m4", t0, "c", "m3", "ok"), nextCursor())
store.push(MessageEvent.AddToolFail("m5", t0, "c", "m3", "err"), nextCursor())
assertEquals(
listOf<MessageRecord>(
MessageRecord.Conversation("c", "T"),
MessageRecord.UserMessage("m1", t0, listOf(Content.Text("hi")), "c"),
MessageRecord.AssistantMessage("m2", t0, "c", listOf(Content.Text("yo"))),
MessageRecord.ToolCall("m3", t0, "c", "search", "{}"),
MessageRecord.ToolResponse("m4", t0, "c", "m3", "ok"),
MessageRecord.ToolFail("m5", t0, "c", "m3", "err"),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
@Test
fun imageContentRoundTrips() = runTest {
withStore { store ->
val image = Content.Image(byteArrayOf(1, 2, 3, -1, 0, 7), "image/png")
store.push(
MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("see"), image)),
nextCursor(),
)
assertEquals(
listOf<MessageRecord>(
MessageRecord.UserMessage("m", t0, listOf(Content.Text("see"), image), "c"),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
@Test
fun duplicateMessageIdIsReplacedAtLaterCursor() = runTest {
withStore { store ->
store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v1"))), nextCursor())
store.push(MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("v2"))), nextCursor())
assertEquals(
listOf<MessageRecord>(MessageRecord.UserMessage("m", t0, listOf(Content.Text("v2")), "c")),
store.getState(store.maxCursor!!).toList(),
)
}
}
// ---- состояние: фильтр по курсору и слияние --------------------------------------------
@Test
fun stateIsFilteredByCursor() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("1"))), nextCursor())
store.push(MessageEvent.AddUserMessage("m2", t0, "c", listOf(Content.Text("2"))), nextCursor())
assertEquals(3, store.getState(cursorAt(3u)).toList().size)
assertEquals(2, store.getState(cursorAt(2u)).toList().size)
assertTrue(store.getState(cursorAt(0u)).toList().isEmpty())
}
}
@Test
fun stateCursorAboveMaxReturnsEverything() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("x"))), nextCursor())
assertEquals(2, store.getState(cursorAt(999u)).toList().size)
// Курсор из другой (более старой) эпохи не видит строк текущей.
assertTrue(store.getState(Cursor(EPOCH - 1, 999u)).toList().isEmpty())
}
}
@Test
fun interleavedTablesMergeInCursorOrder() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("a", "A"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "a", listOf(Content.Text("x"))), nextCursor())
store.push(MessageEvent.CreateConversation("b", "B"), nextCursor())
store.push(MessageEvent.AddAssistantMessage("m2", t0, "b", listOf(Content.Text("y"))), nextCursor())
assertEquals(
listOf<MessageRecord>(
MessageRecord.Conversation("a", "A"),
MessageRecord.UserMessage("m1", t0, listOf(Content.Text("x")), "a"),
MessageRecord.Conversation("b", "B"),
MessageRecord.AssistantMessage("m2", t0, "b", listOf(Content.Text("y"))),
),
store.getState(store.maxCursor!!).toList(),
)
}
}
// ---- пагинация -------------------------------------------------------------------------
@Test
fun getStatePaginatesAcrossPageBoundary() = runTest {
withStore { store ->
repeat(100) { i ->
store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor())
}
assertEquals(100, store.getState(store.maxCursor!!).toList().size)
store.push(MessageEvent.CreateConversation("c100", "T100"), nextCursor())
assertEquals(101, store.getState(store.maxCursor!!).toList().size)
}
}
@Test
fun interleavedTablesMergeAcrossPages() = runTest {
withStore { store ->
repeat(120) { i ->
store.push(MessageEvent.CreateConversation("c$i", "T$i"), nextCursor())
store.push(MessageEvent.AddUserMessage("m$i", t0, "c$i", listOf(Content.Text("$i"))), nextCursor())
}
val records = store.getState(store.maxCursor!!).toList()
assertEquals(240, records.size)
for (i in 0 until 120) {
assertEquals(
MessageRecord.Conversation("c$i", "T$i"),
records[i * 2],
)
assertEquals(
MessageRecord.UserMessage("m$i", t0, listOf(Content.Text("$i")), "c$i"),
records[i * 2 + 1],
)
}
}
}
// ---- журнал: get / expired -------------------------------------------------------------
@Test
fun journalKeepsEventsInOrder() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), cursorAt(1u))
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(2u))
val events = store.get(cursorAt(1u)).getOrException().toList()
assertEquals(listOf(1uL, 2uL), events.map { it.seq.offset })
assertTrue(events[0].data is MessageEvent.CreateConversation)
assertTrue(events[1].data is MessageEvent.AddUserMessage)
}
}
@Test
fun journalGetIsInclusiveAtMinAndExpiredBelow() = runTest {
withStore { store ->
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), cursorAt(5u))
val atMin = store.get(cursorAt(5u)).getOrException().toList()
assertEquals(1, atMin.size)
assertEquals(5uL, atMin[0].seq.offset)
assertTrue(store.get(cursorAt(1u)).isExpired)
}
}
// ---- clear -----------------------------------------------------------------------------
@Test
fun clearRemovesStateAndJournal() = runTest {
withStore { store ->
store.push(MessageEvent.CreateConversation("c", "T"), nextCursor())
store.push(MessageEvent.AddUserMessage("m1", t0, "c", listOf(Content.Text("hi"))), nextCursor())
store.clear()
assertNull(store.minCursor)
assertNull(store.maxCursor)
assertTrue(store.getState(nextCursor()).toList().isEmpty())
assertTrue(store.get(nextCursor()).getOrException().toList().isEmpty())
}
}
}
@@ -0,0 +1,88 @@
package pw.binom
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.content.Content
import pw.binom.agentik.cursor.Cursor
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLiteException
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.time.Instant
private const val FILE_EPOCH = 1_700_000_000_000L
/**
* JVM-тесты [MessageStore] на файловой БД: durable-сохранение между открытиями и владение
* соединением.
*/
class MessageStoreFileTest {
@Test
fun persistsStateAndCursorsAcrossReopen() = runTest {
val dir = Files.createTempDirectory("message-store")
val path = dir.resolve("db.sqlite").toString()
val t0 = Instant.fromEpochMilliseconds(FILE_EPOCH)
try {
val conn1 = SQLiteConnection.open(path)
val store1 = MessageStore(connection = conn1, ownsConnection = true)
store1.push(MessageEvent.CreateConversation("c", "T"), Cursor(FILE_EPOCH, 1uL))
store1.push(
MessageEvent.AddUserMessage("m", t0, "c", listOf(Content.Text("hi"))),
Cursor(FILE_EPOCH, 2uL),
)
store1.close()
val conn2 = SQLiteConnection.open(path)
val store2 = MessageStore(connection = conn2, ownsConnection = true)
try {
assertEquals(
listOf<MessageRecord>(
MessageRecord.Conversation("c", "T"),
MessageRecord.UserMessage("m", t0, listOf(Content.Text("hi")), "c"),
),
store2.getState(Cursor(FILE_EPOCH, 2uL)).toList(),
)
assertEquals(Cursor(FILE_EPOCH, 1uL), store2.minCursor)
assertEquals(Cursor(FILE_EPOCH, 2uL), store2.maxCursor)
assertEquals(2, store2.get(Cursor(FILE_EPOCH, 1uL)).getOrException().toList().size)
} finally {
store2.close()
}
} finally {
dir.toFile().deleteRecursively()
}
}
@Test
fun ownsConnectionTrueClosesConnection() {
val dir = Files.createTempDirectory("message-store-owned")
val path = dir.resolve("db.sqlite").toString()
try {
val conn = SQLiteConnection.open(path)
val store = MessageStore(connection = conn, ownsConnection = true)
store.close()
assertFailsWith<SQLiteException> { conn.prepare("select 1") }
} finally {
dir.toFile().deleteRecursively()
}
}
@Test
fun ownsConnectionFalseLeavesConnectionOpen() {
val dir = Files.createTempDirectory("message-store-borrowed")
val path = dir.resolve("db.sqlite").toString()
try {
val conn = SQLiteConnection.open(path)
val store = MessageStore(connection = conn, ownsConnection = false)
store.close()
// Соединение живо и пригодно к работе.
conn.prepare("select 1").close()
conn.close()
} finally {
dir.toFile().deleteRecursively()
}
}
}