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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user