remove :storage-ksqlite (conversation/message) and related tests; decouple schema from journal
ci / JVM build + tests (push) Successful in 6m6s
ci / JVM build + tests (push) Successful in 6m6s
This commit is contained in:
+71
-17
@@ -14,31 +14,67 @@ import kotlinx.coroutines.withContext
|
||||
/**
|
||||
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
|
||||
*
|
||||
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore]
|
||||
* из `:storage-ksqlite`, но:
|
||||
* - лежит в собственном модуле `:journal-ksqlite`;
|
||||
* - реализует переименованный [MutableJournalStore] (раньше был
|
||||
* `MutableMessageStore`, теперь главный класс — `JournalStore` /
|
||||
* `MutableJournalStore`); сам тип записи [MessageRecord] не
|
||||
* переименовывался.
|
||||
* Миграция завершена: это ЕДИНСТВЕННЫЙ класс для message-таблицы. Параллельная
|
||||
* копия `:storage-ksqlite/KsqliteMessageStore.kt` удалена вместе со своим
|
||||
* тестом (consumer `KsqliteStores.assemble()` уже мигрировал на этот класс).
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Три формы конструктора с разной семантикой владения:
|
||||
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
|
||||
* его в [close]. Для shared-connection bundles (`KsqliteStores.assemble`),
|
||||
* где один connection используется многими store'ами и закрывается bundle'ом.
|
||||
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
|
||||
* его в [close].
|
||||
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
|
||||
* закрывает его в [close].
|
||||
*
|
||||
* ## Миграция
|
||||
*
|
||||
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
|
||||
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
|
||||
* прогоняют миграцию одной и той же схемы по очереди.
|
||||
*
|
||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
|
||||
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
|
||||
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
|
||||
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
||||
* 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] рядом.
|
||||
*
|
||||
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
|
||||
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
|
||||
* отдельно.
|
||||
*/
|
||||
class KsqliteJournalStore internal constructor(
|
||||
class KsqliteJournalStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : MutableJournalStore {
|
||||
|
||||
/**
|
||||
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
|
||||
* себя его закрытие в [close]. Для standalone использования, когда у
|
||||
* store'а нет bundle'а-владельца connection'а.
|
||||
*/
|
||||
constructor(path: String) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Внешнее соединение — store НЕ закрывает его в [close]. Для
|
||||
* shared-connection bundles (`KsqliteStores.assemble`), где один
|
||||
* connection используется многими store'ами и закрывается bundle'ом.
|
||||
*/
|
||||
constructor(connection: SQLiteConnection) : this(
|
||||
connection = connection,
|
||||
ownsConnection = false,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -94,13 +130,15 @@ class KsqliteJournalStore internal constructor(
|
||||
listStmt.bindLong(4, offset.toLong())
|
||||
val out = mutableListOf<MessageRecord>()
|
||||
listStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) out.add(rs.toMessageRecord(json))
|
||||
while (rs.next()) {
|
||||
out.add(rs.toMessageRecord(json))
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
clearStmt.reset()
|
||||
clearStmt.clearBindings()
|
||||
@@ -113,5 +151,21 @@ class KsqliteJournalStore internal constructor(
|
||||
insertStmt.close()
|
||||
listStmt.close()
|
||||
clearStmt.close()
|
||||
if (ownsConnection) {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Открывает in-memory соединение через [SQLiteConnection.memory] и
|
||||
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
|
||||
* runtime.
|
||||
*/
|
||||
fun memory(name: String? = null) =
|
||||
KsqliteJournalStore(
|
||||
connection = SQLiteConnection.memory(name),
|
||||
ownsConnection = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package pw.binom.agentik.journal.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_*`.
|
||||
*
|
||||
* Каскадное удаление связанных данных (message + working_memory) делает
|
||||
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
|
||||
* store знает только про свою таблицу.
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
|
||||
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
|
||||
* store НЕ закрывает его в [close] (используется shared-connection
|
||||
* bundle'ом `KsqliteStores.assemble`).
|
||||
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
|
||||
* закрывает его в [close].
|
||||
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
|
||||
* в [close].
|
||||
*/
|
||||
class KsqliteMutableConversationStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : MutableConversationStore {
|
||||
|
||||
/**
|
||||
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
|
||||
* себя его закрытие в [close]. Для standalone использования, когда у
|
||||
* store'а нет bundle'а-владельца connection'а.
|
||||
*/
|
||||
constructor(path: String) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Внешнее соединение — store НЕ закрывает его в [close]. Для
|
||||
* shared-connection bundles (`KsqliteStores.assemble`).
|
||||
*/
|
||||
constructor(connection: SQLiteConnection) : this(
|
||||
connection = connection,
|
||||
ownsConnection = false,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
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
|
||||
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()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun memory(name: String? = null): KsqliteMutableConversationStore =
|
||||
KsqliteMutableConversationStore(
|
||||
connection = SQLiteConnection.memory(name),
|
||||
ownsConnection = true,
|
||||
)
|
||||
}
|
||||
|
||||
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)!!),
|
||||
)
|
||||
}
|
||||
@@ -5,32 +5,51 @@ import pw.binom.db.ksqlite.SQLiteConnection
|
||||
/**
|
||||
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
|
||||
*
|
||||
* Минимум — только то, что относится к `message` (append-only audit log).
|
||||
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
|
||||
* живут в других ksqlite-модулях.
|
||||
* Владеет двумя таблицами:
|
||||
* - `conversation` — реестр диалогов агента (см. ConversationRecord);
|
||||
* - `message` — append-only audit log сообщений диалогов.
|
||||
*
|
||||
* `working_memory` и `reflection` живут в других ksqlite-модулях.
|
||||
*
|
||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||
*/
|
||||
internal object Schema {
|
||||
object Schema {
|
||||
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
|
||||
// ───── Таблица ─────
|
||||
// ───── Таблицы ─────
|
||||
const val TABLE_CONVERSATION = "conversation"
|
||||
const val TABLE_MESSAGE = "message"
|
||||
|
||||
// ───── Колонки ─────
|
||||
// ───── Колонки 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"
|
||||
const val COL_CREATED_AT = "created_at"
|
||||
|
||||
// ───── Индексы ─────
|
||||
const val IDX_CONV_UPDATED = "idx_conv_updated"
|
||||
const val IDX_MSG_CONV = "idx_msg_conv"
|
||||
|
||||
private val v1Ddl = """
|
||||
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,
|
||||
@@ -38,54 +57,43 @@ internal object Schema {
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL
|
||||
);
|
||||
""".trimIndent()
|
||||
"""
|
||||
|
||||
private val v1IndexesDdl = """
|
||||
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
||||
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
||||
|
||||
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
|
||||
-- сортировка по created_at (используется list(), cascade-clear, etc.)
|
||||
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
||||
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
|
||||
""".trimIndent()
|
||||
"""
|
||||
|
||||
/**
|
||||
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||
*
|
||||
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
|
||||
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
|
||||
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
|
||||
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
|
||||
* Гарантии:
|
||||
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||
* уже-мигрированной БД;
|
||||
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||
* ROLLBACK оставит БД консистентной.
|
||||
*
|
||||
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
|
||||
* user_version как gate перестал работать — два модуля ставят его в 1,
|
||||
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||
* idempotently; координация multi-module миграций — ответственность
|
||||
* вызывающего (см. `KsqliteStores.open()` в `:storage-ksqlite`).
|
||||
*/
|
||||
fun migrate(conn: SQLiteConnection) {
|
||||
val current = readUserVersion(conn)
|
||||
if (current >= CURRENT_VERSION) return
|
||||
|
||||
conn.exec("BEGIN")
|
||||
try {
|
||||
if (current < 1) {
|
||||
conn.exec(v1Ddl)
|
||||
conn.exec(v1IndexesDdl)
|
||||
}
|
||||
// future: if (current < 2) { conn.exec(v2Ddl) }
|
||||
writeUserVersion(conn, CURRENT_VERSION)
|
||||
conn.exec(v1ConversationDdl)
|
||||
conn.exec(v1MessageDdl)
|
||||
conn.exec(v1IndexesDdl)
|
||||
conn.exec("COMMIT")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { conn.exec("ROLLBACK") }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
||||
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
|
||||
// поэтому собираем SQL строкой (значение контролируемое, не user input).
|
||||
conn.exec("PRAGMA user_version = $version")
|
||||
}
|
||||
}
|
||||
|
||||
+3
-10
@@ -13,15 +13,9 @@ import kotlin.test.assertEquals
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Тесты для [KsqliteJournalStore] — точная копия
|
||||
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов
|
||||
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory
|
||||
* SQLiteConnection + Schema.migrate).
|
||||
*
|
||||
* Тест `testClearRemovesByConversation` из оригинала использовал
|
||||
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
|
||||
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
|
||||
* автономен и не знает про ConversationStore.
|
||||
* Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
|
||||
* SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
|
||||
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||
*/
|
||||
class KsqliteJournalStoreTest {
|
||||
|
||||
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
|
||||
Schema.migrate(conn)
|
||||
store = KsqliteJournalStore(conn)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user