remove :storage-ksqlite (conversation/message) and related tests; decouple schema from journal
ci / JVM build + tests (push) Successful in 6m6s

This commit is contained in:
2026-09-22 16:01:05 +03:00
parent 639c7d1748
commit 84f5fd84f3
39 changed files with 1569 additions and 616 deletions
+1
View File
@@ -42,6 +42,7 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
api(project(":journal-api"))
api(project(":journal-ksqlite"))
api(project(":reflection-api"))
api(project(":context-api"))
}
@@ -1,105 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableMessageStore] (append-only audit log).
*
* Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
*
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом.
*/
class KsqliteMessageStore internal constructor(
private val connection: SQLiteConnection,
) : MutableJournalStore {
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_MESSAGE}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
ORDER BY ${Schema.COL_CREATED_AT} ASC, ${Schema.COL_ID} ASC
LIMIT ? OFFSET ?
""".trimIndent()
)
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record)
mutex.withLock {
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, record.id)
insertStmt.bindText(2, record.conversationId)
insertStmt.bindText(3, kind)
insertStmt.bindText(4, payload)
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
listStmt.bindLong(2, after.toEpochMilliseconds())
listStmt.bindLong(3, limit.toLong())
listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json))
}
out
}
}
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
}
}
@@ -1,207 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import kotlin.time.Clock
import kotlin.time.Instant
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.db.ksqlite.SQLiteConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
* в [Schema] (миграция через PRAGMA user_version) — этот класс только
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
*/
class KsqliteMutableConversationStore(
private val connection: SQLiteConnection,
private val messageStore: KsqliteMessageStore? = null,
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
) : MutableConversationStore {
private val mutex = Mutex()
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
// KDoc там — почему GC-finalize на StmtHolder'е роняет JVM, если stmt
// живёт после закрытия connection).
private val existsStmt = connection.prepare(
"SELECT 1 FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
)
private val updateStmt = connection.prepare(
"""
UPDATE ${Schema.TABLE_CONVERSATION}
SET ${Schema.COL_TITLE} = ?, ${Schema.COL_IS_TEMPORAL} = ?, ${Schema.COL_UPDATED_AT} = ?
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
private val insertStmt = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_CONVERSATION}
(${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val getStmt = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT}
FROM ${Schema.TABLE_CONVERSATION}
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
private val deleteStmt = connection.prepare(
"DELETE FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
)
private val listStmt = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT}
FROM ${Schema.TABLE_CONVERSATION}
WHERE ${Schema.COL_IS_TEMPORAL} = 0
ORDER BY ${Schema.COL_UPDATED_AT} DESC
LIMIT ? OFFSET ?
""".trimIndent()
)
private val renameStmt = connection.prepare(
"""
UPDATE ${Schema.TABLE_CONVERSATION}
SET ${Schema.COL_TITLE} = ?, ${Schema.COL_UPDATED_AT} = ?
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
private val renameUpdatedAtStmt = connection.prepare(
"SELECT ${Schema.COL_UPDATED_AT} FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
)
private val touchStmt = connection.prepare(
"""
UPDATE ${Schema.TABLE_CONVERSATION}
SET ${Schema.COL_UPDATED_AT} = ?
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
override suspend fun upsert(record: ConversationRecord): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
val exists = execExists(record.id)
if (exists) {
updateStmt.reset()
updateStmt.clearBindings()
val t = record.title
if (t != null) updateStmt.bindText(1, t) else updateStmt.bindNull(1)
updateStmt.bindInt(2, if (record.isTemporal) 1 else 0)
updateStmt.bindLong(3, record.updatedAt.toEpochMilliseconds())
updateStmt.bindText(4, record.id)
updateStmt.executeUpdate()
} else {
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, record.id)
val title = record.title
if (title != null) insertStmt.bindText(2, title) else insertStmt.bindNull(2)
insertStmt.bindInt(3, if (record.isTemporal) 1 else 0)
insertStmt.bindLong(4, record.createdAt.toEpochMilliseconds())
insertStmt.bindLong(5, record.updatedAt.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
}
override suspend fun get(id: String): ConversationRecord? = withContext(Dispatchers.Default) {
mutex.withLock {
getStmt.reset()
getStmt.clearBindings()
getStmt.bindText(1, id)
getStmt.executeQuery().use { rs ->
if (rs.next()) rs.toRecord() else null
}
}
}
override suspend fun delete(id: String): Boolean = withContext(Dispatchers.Default) {
mutex.withLock {
// Проверяем существование через raw query, НЕ через get() — get() тоже
// берёт mutex (не реентрант), что привело бы к deadlock.
if (!execExists(id)) return@withContext false
messageStore?.clear(id)
workingMemoryStore?.clear(id)
deleteStmt.reset()
deleteStmt.clearBindings()
deleteStmt.bindText(1, id)
deleteStmt.executeUpdate()
true
}
}
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindLong(1, limit.toLong())
listStmt.bindLong(2, offset.toLong())
val result = mutableListOf<ConversationRecord>()
listStmt.executeQuery().use { rs ->
while (rs.next()) result.add(rs.toRecord())
}
result
}
}
override suspend fun rename(id: String, title: String?): Instant? = withContext(Dispatchers.Default) {
mutex.withLock {
val nowMs = Clock.System.now().toEpochMilliseconds()
renameStmt.reset()
renameStmt.clearBindings()
if (title != null) renameStmt.bindText(1, title) else renameStmt.bindNull(1)
renameStmt.bindLong(2, nowMs)
renameStmt.bindText(3, id)
renameStmt.executeUpdate()
renameUpdatedAtStmt.reset()
renameUpdatedAtStmt.clearBindings()
renameUpdatedAtStmt.bindText(1, id)
renameUpdatedAtStmt.executeQuery().use { rs ->
if (rs.next()) Instant.fromEpochMilliseconds(rs.getLong(0)!!) else null
}
}
}
override suspend fun touch(id: String, now: Instant): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
touchStmt.reset()
touchStmt.clearBindings()
touchStmt.bindLong(1, now.toEpochMilliseconds())
touchStmt.bindText(2, id)
touchStmt.executeUpdate()
}
}
override fun close() {
existsStmt.close()
updateStmt.close()
insertStmt.close()
getStmt.close()
deleteStmt.close()
listStmt.close()
renameStmt.close()
renameUpdatedAtStmt.close()
touchStmt.close()
}
private fun execExists(id: String): Boolean {
existsStmt.reset()
existsStmt.clearBindings()
existsStmt.bindText(1, id)
existsStmt.executeQuery().use { rs -> return rs.next() }
}
private fun pw.binom.db.ksqlite.SQLiteResultSet.toRecord(): ConversationRecord = ConversationRecord(
id = getText(0)!!,
title = getText(1),
isTemporal = (getInt(2) ?: 0) != 0,
createdAt = Instant.fromEpochMilliseconds(getLong(3)!!),
updatedAt = Instant.fromEpochMilliseconds(getLong(4)!!),
)
}
@@ -16,11 +16,42 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class KsqliteReflectionStore(
/**
* ksqlite-реализация [ReflectionStore].
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteReflectionStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle).
* - `KsqliteReflectionStore(path)` — открывает файловое соединение,
* прогоняет [Schema.migrate], закрывает соединение в [close].
* - `KsqliteReflectionStore.memory(name)` — in-memory, мигрирует,
* закрывает в [close].
*/
class KsqliteReflectionStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
private val clock: Clock = Clock.System,
) : ReflectionStore {
constructor(path: String, clock: Clock = Clock.System) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
clock = clock,
)
constructor(connection: SQLiteConnection, clock: Clock = Clock.System) : this(
connection = connection,
ownsConnection = false,
clock = clock,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16)
@@ -155,6 +186,16 @@ class KsqliteReflectionStore(
listForConvStmt.close()
deleteOlderThanStmt.close()
countStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null, clock: Clock = Clock.System): KsqliteReflectionStore =
KsqliteReflectionStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
clock = clock,
)
}
private fun SQLiteResultSet.toDomain(): Reflection = Reflection(
@@ -3,15 +3,19 @@ package pw.binom.agentik.storage.ksqlite
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore as JournalKsqliteConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Фабрика 4 store'ов поверх ksqlite.
*
* Lifecycle: открывает [SQLiteConnection], прогоняет [Schema.migrate] (создаёт
* таблицы/индексы если их нет, догоняет версию схемы до [Schema.CURRENT_VERSION]),
* возвращает bundle из 4 store'ов. Caller ДОЛЖЕН вызвать [close] при завершении.
* Lifecycle: открывает [SQLiteConnection] и возвращает bundle из 4 store'ов.
* Каждый store сам прогоняет свою схему в конструкторе
* (`Schema.migrate(connection)` — idempotent `CREATE TABLE IF NOT EXISTS`), так
* что явных вызовов миграции в bundle'е нет. Caller ДОЛЖЕН вызвать [close]
* при завершении.
*
* @param path путь к .db файлу, либо URI для in-memory/shared-cache.
*/
@@ -34,28 +38,18 @@ class KsqliteStores internal constructor(
companion object {
fun open(path: String): KsqliteStores {
val conn = SQLiteConnection.open(path)
Schema.migrate(conn)
return assemble(conn)
}
fun open(path: String): KsqliteStores =
assemble(SQLiteConnection.open(path))
fun inMemory(name: String = "agentik-test"): KsqliteStores {
val conn = SQLiteConnection.memory(name)
Schema.migrate(conn)
return assemble(conn)
}
fun inMemory(name: String = "agentik-test"): KsqliteStores =
assemble(SQLiteConnection.memory(name))
private fun assemble(conn: SQLiteConnection): KsqliteStores {
val messages = KsqliteMessageStore(conn)
val working = KsqliteWorkingMemoryStore(conn)
return KsqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn, messages, working),
messages = messages,
workingMemory = working,
reflections = KsqliteReflectionStore(conn),
)
}
private fun assemble(conn: SQLiteConnection): KsqliteStores = KsqliteStores(
connection = conn,
conversations = JournalKsqliteConversationStore(conn),
messages = KsqliteJournalStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
}
}
@@ -14,10 +14,39 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class KsqliteWorkingMemoryStore(
/**
* ksqlite-реализация [ContextStore] для `working_memory`.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteWorkingMemoryStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle).
* - `KsqliteWorkingMemoryStore(path)` — открывает файловое соединение,
* прогоняет [Schema.migrate], закрывает соединение в [close].
* - `KsqliteWorkingMemoryStore.memory(name)` — in-memory, мигрирует,
* закрывает в [close].
*/
class KsqliteWorkingMemoryStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : ContextStore {
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
@@ -166,6 +195,15 @@ class KsqliteWorkingMemoryStore(
maxOrderIdxStmt.close()
dropFromIdxStmt.close()
insertSummaryStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteWorkingMemoryStore =
KsqliteWorkingMemoryStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
private fun maxOrderIdx(conversationId: String): Long {
@@ -3,12 +3,16 @@ package pw.binom.agentik.storage.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда agentik'а.
* Имена таблиц/колонок/индексов для ksqlite-бэкенда agentik'а,
* которыми владеет `:storage-ksqlite`: `working_memory` + `reflection`.
*
* Все DDL/DML через ksqlite должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в каждом
* store'е. Это (1) даёт единую точку правды при будущих миграциях и
* (2) делает rename'ы безопасными (компилятор поймает все использования).
* `conversation` + `message` уехали в `:journal-ksqlite` —
* см. `pw.binom.agentik.journal.ksqlite.Schema`. Управляющий
* [KsqliteStores.open] / [KsqliteStores.inMemory] прогоняет ОБА
* `Schema.migrate(conn)` подряд.
*
* Все DDL/DML в этом модуле ссылаются на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
@@ -16,27 +20,17 @@ internal object Schema {
const val CURRENT_VERSION: Int = 1
// ───── Таблицы ─────
const val TABLE_CONVERSATION = "conversation"
const val TABLE_MESSAGE = "message"
const val TABLE_WORKING_MEMORY = "working_memory"
const val TABLE_REFLECTION = "reflection"
const val TABLE_MIGRATION = "migration" // (резерв на будущее, сейчас версия в user_version)
// ───── Колонки conversation ─────
const val COL_ID = "id"
const val COL_TITLE = "title"
const val COL_IS_TEMPORAL = "is_temporal"
const val COL_CREATED_AT = "created_at"
const val COL_UPDATED_AT = "updated_at"
// ───── Колонки message ─────
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
// ───── Колонки working_memory ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_ORDER_IDX = "order_idx"
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Колонки reflection ─────
const val COL_TURNS_ANALYZED = "turns_analyzed"
@@ -45,8 +39,6 @@ internal object Schema {
const val COL_WEAK_SPOTS_JSON = "weak_spots_json"
// ───── Индексы ─────
const val IDX_CONV_UPDATED = "idx_conv_updated"
const val IDX_MSG_CONV = "idx_msg_conv"
const val IDX_WM_UNIQUE = "idx_wm_unique"
const val IDX_WM_CONV = "idx_wm_conv"
const val IDX_REFLECTION_CREATED = "idx_reflection_created"
@@ -55,90 +47,34 @@ internal object Schema {
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
* блок DDL+данных под номером `fromV+1`, выполняется в транзакции.
*
* Гарантии:
* - идемпотентность: повторный вызов после достижения текущей версии —
* no-op (`PRAGMA user_version` совпадает с CURRENT_VERSION);
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* уже-мигрированной БД;
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* PRAGMA остаётся на предыдущей версии, БД консистентна;
* - CREATE TABLE/INDEX через `IF NOT EXISTS` — безопасно на partially-
* migrated БД (если ручной ROLLBACK оставил схему в полупосаженном виде).
* ROLLBACK оставит БД консистентной.
*
**NOTE**: в сплит-мире user_version как gate перестал работать
* (соседний `:journal-ksqlite` тоже ставит user_version=1, и второй
* вызов short-circuit'ит). Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. [KsqliteStores.open] / [KsqliteStores.inMemory]).
*/
fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
// Миграции строго последовательны — каждая стартует с (current) и
// выставляет user_version = current+1 в конце (внутри транзакции).
if (current < 1) {
conn.exec("BEGIN")
try {
conn.exec(v1ConversationDdl)
conn.exec(v1MessageDdl)
conn.exec(v1WorkingMemoryDdl)
conn.exec(v1ReflectionDdl)
conn.exec(v1IndexesDdl)
writeUserVersion(conn, 1)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
conn.exec("BEGIN")
try {
conn.exec(v1WorkingMemoryDdl)
conn.exec(v1ReflectionDdl)
conn.exec(v1IndexesDdl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
// sanity check — после всех миграций обязаны достичь CURRENT_VERSION
check(readUserVersion(conn) == CURRENT_VERSION) {
"Schema migration failed to reach version $CURRENT_VERSION"
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
// user_version живёт в sqlite_master-метаданных; PRAGMA возвращает его
// как row column "user_version". Используем прямой SELECT к internal
// pragma function через ksqlite (prepared + executeQuery).
var version = 0
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) {
version = (rs.getLong(0) ?: 0L).toInt()
}
}
}
return version
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
// ───── DDL миграций ─────
// Ниже идут блоки по одной миграции. Конкатенация в [v1*] — потому что
// v1 — начальная схема (нет pre-existing DB с user_version=0 в проде,
// но мы поддерживаем эту ветку на случай dev-БД под `agentik-dev.db`).
private val v1ConversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_TITLE TEXT,
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
"""
private val v1MessageDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
"""
// v1 — начальная схема модуля. Пара `CREATE IF NOT EXISTS` →
// миграция idempotent без всяких version-checks.
private val v1WorkingMemoryDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
@@ -165,14 +101,6 @@ internal object Schema {
"""
private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
-- Главный hot-path индекс для list/conversation: фильтр по conv +
-- сортировка по created_at (используется list(), cascade-delete, etc.)
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
-- Working memory: гарантия уникального order_idx внутри conv'а
-- (порядок имеет значение — compaction полагается на монотонность).
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
@@ -1,96 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.TurnTokens
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
class KsqliteMessageStoreTest {
private lateinit var stores: KsqliteStores
@BeforeTest
fun setup() {
stores = KsqliteStores.inMemory("msg-${kotlin.random.Random.nextLong()}")
kotlinx.coroutines.runBlocking {
stores.conversations.upsert(
pw.binom.agentik.journal.ConversationRecord(
"conv1", null, false,
Instant.parse("2026-09-15T10:00:00Z"),
Instant.parse("2026-09-15T10:00:00Z"),
)
)
}
}
@AfterTest
fun tearDown() = stores.close()
@Test
fun testAppendUserAndRetrieve() = runTest {
stores.messages.append(MessageRecord.UserMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hello")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
context = null,
))
val list = stores.messages.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0]
assertEquals("m1", msg.id)
assertEquals(MessageRecord.UserMessage::class, msg::class)
}
@Test
fun testAppendAssistantWithTokens() = runTest {
stores.messages.append(MessageRecord.AssistantMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hi")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
tokens = TurnTokens(input = 50, output = 30),
))
val list = stores.messages.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0] as MessageRecord.AssistantMessage
assertEquals(TurnTokens(input = 50, output = 30), msg.tokens)
}
@Test
fun testListAfterFiltersByTimestamp() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
stores.messages.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
stores.messages.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
stores.messages.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
val after = stores.messages.list("conv1", after = t1, offset = 0, limit = 10)
assertEquals(2, after.size)
assertEquals(listOf("m2", "m3"), after.map { it.id })
}
@Test
fun testListFlowReturnsAllInOrder() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..3) stores.messages.append(
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
)
assertEquals(listOf("m1", "m2", "m3"), stores.messages.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
}
@Test
fun testClearRemovesByConversation() = runTest {
stores.messages.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), Instant.parse("2026-09-15T10:00:00Z"), null))
stores.messages.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), Instant.parse("2026-09-15T10:00:00Z"), null))
stores.conversations.delete("conv1")
assertEquals(emptyList(), stores.messages.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, stores.messages.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
}
}
@@ -1,105 +0,0 @@
package pw.binom.agentik.storage.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.ConversationRecord
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
class KsqliteMutableConversationStoreTest {
private lateinit var stores: KsqliteStores
@BeforeTest
fun setup() {
stores = KsqliteStores.inMemory("conv-${kotlin.random.Random.nextLong()}")
}
@AfterTest
fun tearDown() = stores.close()
private fun rec(id: String, title: String? = null, ts: Instant = Instant.parse("2026-09-15T10:00:00Z")) =
ConversationRecord(id, title, false, ts, ts)
@Test
fun testUpsertAndGetRoundtrip() = runTest {
stores.conversations.upsert(rec("c1", "test"))
assertEquals(rec("c1", "test"), stores.conversations.get("c1"))
}
@Test
fun testGetReturnsNullForMissing() = runTest {
assertNull(stores.conversations.get("nope"))
}
@Test
fun testDeleteRemovesAndReturnsTrue() = runTest {
stores.conversations.upsert(rec("c1"))
assertTrue(stores.conversations.delete("c1"))
assertNull(stores.conversations.get("c1"))
assertEquals(false, stores.conversations.delete("c1"))
}
@Test
fun testListSortsByUpdatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
stores.conversations.upsert(rec("c1", ts = t0))
stores.conversations.upsert(rec("c2", ts = t0))
stores.conversations.upsert(rec("c3", ts = t0))
stores.conversations.upsert(rec("c4", ts = t0))
stores.conversations.touch("c2", t0 + Duration.parse("PT60S"))
stores.conversations.touch("c3", t0 + Duration.parse("PT120S"))
stores.conversations.touch("c4", t0 + Duration.parse("PT180S"))
val page = stores.conversations.list(offset = 0, limit = 4)
assertEquals(listOf("c4", "c3", "c2", "c1"), page.map { it.id })
}
@Test
fun testListRespectsOffsetAndLimit() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..5) stores.conversations.upsert(rec("c$i", ts = t0 + Duration.parse("PT${i}S")))
val p0 = stores.conversations.list(offset = 0, limit = 2)
assertEquals(2, p0.size)
val p2 = stores.conversations.list(offset = 4, limit = 2)
assertEquals(1, p2.size)
}
@Test
fun testRenameUpdatesTitleAndUpdatedAt() = runTest {
stores.conversations.upsert(rec("c1"))
val newTs = stores.conversations.rename("c1", "new title")
assertNotNull(newTs)
assertEquals("new title", stores.conversations.get("c1")?.title)
}
@Test
fun testRenameWithNullClearsTitle() = runTest {
stores.conversations.upsert(rec("c1", "old"))
stores.conversations.rename("c1", null)
assertNull(stores.conversations.get("c1")?.title)
}
@Test
fun testRenameReturnsNullForMissing() = runTest {
assertNull(stores.conversations.rename("nope", "x"))
}
@Test
fun testTouchUpdatesUpdatedAtOnly() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
stores.conversations.upsert(ConversationRecord("c1", "title", false, t0, t0))
stores.conversations.touch("c1", t1)
val got = stores.conversations.get("c1")
assertEquals("title", got?.title)
assertEquals(t1, got?.updatedAt)
assertEquals(t0, got?.createdAt)
}
}
@@ -1,33 +1,35 @@
package pw.binom.agentik.storage.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ksqlite.Schema as JournalSchema
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Тесты на Schema.migrate():
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION;
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на
* повторных CREATE);
* Тесты на Schema.migrate() в `:storage-ksqlite`:
* - fresh DB → создаются `working_memory` + `reflection` + индексы;
* - уже мигрированная БД → migrate() идемпотентен (no-op);
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores),
* migrate() приводит её в боевое состояние.
*
* Также проверяем что наличие индекса idx_msg_conv (conversation_id +
* created_at) — обязательный hot-path для list()/cascade-delete.
* `conversation` + `message` тестируются в
* `pw.binom.agentik.journal.ksqlite.SchemaMigrationTest`.
*/
class SchemaMigrationTest {
@Test
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest {
fun `fresh DB gets working_memory and reflection tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try {
// Поднимаем ОБА schema — то же делает KsqliteStores.open().
JournalSchema.migrate(conn)
Schema.migrate(conn)
for (table in listOf(
Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE,
Schema.TABLE_WORKING_MEMORY,
Schema.TABLE_REFLECTION,
)) {
@@ -35,8 +37,6 @@ class SchemaMigrationTest {
}
for (index in listOf(
Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_CONV,
Schema.IDX_WM_UNIQUE,
Schema.IDX_WM_CONV,
Schema.IDX_REFLECTION_CREATED,
@@ -44,8 +44,6 @@ class SchemaMigrationTest {
)) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
}
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
} finally {
conn.close()
}
@@ -55,13 +53,15 @@ class SchemaMigrationTest {
fun `migrate is idempotent on already-migrated DB`() = runTest {
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
try {
JournalSchema.migrate(conn)
Schema.migrate(conn)
val versionAfterFirst = readUserVersion(conn)
// повторный вызов не должен ни упасть, ни изменить версию, ни
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
// повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn)
assertEquals(versionAfterFirst, readUserVersion(conn))
Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_WORKING_MEMORY))
assertTrue(tableExists(conn, Schema.TABLE_REFLECTION))
} finally {
conn.close()
}
@@ -69,63 +69,31 @@ class SchemaMigrationTest {
@Test
fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest {
// Имитируем сценарий: существующая БД без schema, открываем через
// ksqlite и прогоняем migrate руками (тот же путь, что в
// KsqliteStores.open, но без зависимости от фабрики).
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
JournalSchema.migrate(conn)
Schema.migrate(conn)
// Сборка bundle через internal-конструктор — KsqliteStores primary
// constructor internal, тест в том же модуле и может его звать.
val stores = KsqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteMessageStore(conn),
conversations = pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore(conn),
messages = pw.binom.agentik.journal.ksqlite.KsqliteJournalStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
try {
// bundle работает end-to-end — conversation upsert + message append +
// list. Никаких "no such table" или подобного.
stores.conversations.upsert(
pw.binom.agentik.journal.ConversationRecord(
id = "c1", title = "t", isTemporal = false,
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
)
// bundle работает end-to-end — working memory append. Никаких
// "no such table" или подобного.
stores.workingMemory.append(
conversationId = "c1",
entry = WorkingMemoryEntry.Summary(text = "init"),
now = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
)
stores.messages.append(
pw.binom.agentik.journal.MessageRecord.UserMessage(
id = "m1", conversationId = "c1",
content = listOf(pw.binom.agentik.journal.Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
)
)
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size)
assertEquals("m1", got[0].id)
assertEquals(1, stores.workingMemory.list("c1").size)
} finally {
// Закрываем store'ы → они закроют свои pre-prepared statements
// (StmtHolder.finalize увидит isOpen == false и не полезет в
// нативный sqlite3_finalize с уже-разрушенным db mutex).
stores.close()
}
}
@Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
// Проверяем что индекс действительно покрывает обе колонки — без
// этого list()/cascade-delete будут делать full-scan по message.
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
val cols = indexColumns(conn, Schema.IDX_MSG_CONV)
assertEquals(listOf(Schema.COL_CONVERSATION_ID, Schema.COL_CREATED_AT), cols)
} finally {
conn.close()
}
}
private fun tableExists(conn: SQLiteConnection, name: String): Boolean {
conn.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"
@@ -143,31 +111,4 @@ class SchemaMigrationTest {
stmt.executeQuery().use { rs -> return rs.next() }
}
}
private fun indexColumns(conn: SQLiteConnection, indexName: String): List<String> {
// PRAGMA index_info возвращает одну строку на колонку индекса
// (seqno, cid, name). Параметризовать через `?` нельзя — собираем
// строку (name — контролируемая константа, не user input).
val cols = mutableListOf<String>()
conn.prepare("PRAGMA index_info($indexName)").use { stmt ->
stmt.executeQuery().use { rs ->
while (rs.next()) {
rs.getText(2)?.let(cols::add)
}
}
}
return cols
}
private fun readUserVersion(conn: SQLiteConnection): Int {
var version = 0
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) {
version = (rs.getLong(0) ?: 0L).toInt()
}
}
}
return version
}
}