Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions app/src/main/java/com/aicode/core/db/MigrationLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,24 @@ class FileMigration(
"executed_at INTEGER)"
)
for (sql in sqlStatements) {
if (sql.isNotBlank()) {
if (sql.isBlank()) continue
try {
db.execSQL(sql)
} catch (e: Exception) {
// 旧包把 proxy/sort 编成 42/43,新包改成 44/45 后会重复 ADD COLUMN。
if (SchemaCatchUp.isDuplicateColumnMessage(e.message)) {
FileLogger.w(
"MigrationLoader",
"Skip already-applied column in $scriptName: $sql"
)
} else {
throw e
}
}
}
SchemaCatchUp.ensure(db)
db.execSQL(
"INSERT INTO migration_history (version, script_name, executed_at) VALUES (?, ?, ?)",
"INSERT OR REPLACE INTO migration_history (version, script_name, executed_at) VALUES (?, ?, ?)",
arrayOf<Any>(version, scriptName, System.currentTimeMillis())
)
FileLogger.i("MigrationLoader", "Applied migration: $scriptName")
Expand All @@ -35,27 +47,27 @@ object MigrationLoader {
val assetManager = context.assets
val migrationsDir = "migrations"
val files = runCatching { assetManager.list(migrationsDir) }.getOrNull() ?: emptyArray()

val migrations = mutableListOf<Migration>()

// File format: {version}_{description}.sql, e.g., "7_add_workspace_path.sql"
for (fileName in files) {
if (!fileName.endsWith(".sql")) continue

val versionStr = fileName.substringBefore('_')
val version = versionStr.toIntOrNull() ?: continue

val sqlContent = runCatching {
assetManager.open("$migrationsDir/$fileName").bufferedReader().use { it.readText() }
}.getOrNull() ?: continue

val statements = sqlContent.split(";")
.map { it.trim() }
.filter { it.isNotEmpty() }

migrations.add(FileMigration(version, fileName, statements))
}

return migrations.toTypedArray()
}
}
85 changes: 85 additions & 0 deletions app/src/main/java/com/aicode/core/db/SchemaCatchUp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.aicode.core.db

import androidx.sqlite.db.SupportSQLiteDatabase
import com.aicode.core.util.FileLogger

/**
* 补齐历史上被改号/重排过的加法迁移列。
*
* 1.10.1 曾把 session_parent / provider_proxy / provider_sort_order
* 编成 41/42/43,随后又在中间插入 thinking_blocks / cache_creation,
* 把这三项改成 43/44/45。已升到旧 43 的库再跑新 44 会
* `duplicate column name: proxyEnabled`,同时永远跳过 41/42 的新列。
*/
object SchemaCatchUp {

data class AdditiveColumn(
val table: String,
val name: String,
val definition: String
)

val requiredColumns: List<AdditiveColumn> = listOf(
AdditiveColumn("agent_messages", "thinkingBlocksJson", "TEXT DEFAULT NULL"),
AdditiveColumn("llm_call_records", "cacheCreationTokens", "INTEGER NOT NULL DEFAULT 0"),
AdditiveColumn("chat_sessions", "parentId", "TEXT DEFAULT NULL"),
AdditiveColumn("chat_sessions", "subagentType", "TEXT DEFAULT NULL"),
AdditiveColumn("ai_providers", "proxyEnabled", "INTEGER NOT NULL DEFAULT 0"),
AdditiveColumn("ai_providers", "proxyType", "TEXT NOT NULL DEFAULT 'HTTP'"),
AdditiveColumn("ai_providers", "proxyHost", "TEXT NOT NULL DEFAULT ''"),
AdditiveColumn("ai_providers", "proxyPort", "INTEGER NOT NULL DEFAULT 0"),
AdditiveColumn("ai_providers", "proxyUsername", "TEXT NOT NULL DEFAULT ''"),
AdditiveColumn("ai_providers", "proxyPassword", "TEXT NOT NULL DEFAULT ''"),
AdditiveColumn("ai_providers", "sortOrder", "INTEGER NOT NULL DEFAULT 0")
)

fun isDuplicateColumnMessage(message: String?): Boolean {
return message.orEmpty().contains("duplicate column name", ignoreCase = true)
}

fun existingColumns(db: SupportSQLiteDatabase, table: String): Set<String> {
val result = mutableSetOf<String>()
db.query("PRAGMA table_info(`$table`)").use { cursor ->
val nameIndex = cursor.getColumnIndex("name")
while (cursor.moveToNext()) {
if (nameIndex >= 0) {
result.add(cursor.getString(nameIndex))
}
}
}
return result
}

fun tableExists(db: SupportSQLiteDatabase, table: String): Boolean {
db.query(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
arrayOf(table)
).use { cursor ->
return cursor.moveToFirst()
}
}

fun ensure(db: SupportSQLiteDatabase) {
for ((table, columns) in requiredColumns.groupBy { it.table }) {
if (!tableExists(db, table)) continue
val existing = existingColumns(db, table)
for (column in columns) {
if (column.name in existing) continue
val sql = "ALTER TABLE `$table` ADD COLUMN `${column.name}` ${column.definition}"
try {
db.execSQL(sql)
FileLogger.i("SchemaCatchUp", "Added missing column ${table}.${column.name}")
} catch (e: Exception) {
if (isDuplicateColumnMessage(e.message)) {
FileLogger.w(
"SchemaCatchUp",
"Column already present: ${table}.${column.name}"
)
} else {
throw e
}
}
}
}
}
}
6 changes: 6 additions & 0 deletions app/src/main/java/com/aicode/di/AgentModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import javax.inject.Named
import javax.inject.Singleton

import com.aicode.core.db.MigrationLoader
import com.aicode.core.db.SchemaCatchUp
import com.aicode.feature.agent.domain.checkpoint.CheckpointManager
import com.aicode.feature.agent.domain.session.MessagePersistenceUseCase
import com.aicode.feature.agent.domain.session.SessionUseCase
Expand Down Expand Up @@ -97,6 +98,11 @@ object AgentModule {
AgentDatabase::class.java,
"aicode_agent_db"
).addMigrations(*MigrationLoader.loadMigrations(context))
.addCallback(object : androidx.room.RoomDatabase.Callback() {
override fun onOpen(db: SupportSQLiteDatabase) {
SchemaCatchUp.ensure(db)
}
})
.fallbackToDestructiveMigration(dropAllTables = false)
.build()
}
Expand Down
28 changes: 28 additions & 0 deletions app/src/test/java/com/aicode/core/db/FileMigrationTest.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.aicode.core.db

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class FileMigrationTest {
Expand Down Expand Up @@ -52,4 +54,30 @@ class FileMigrationTest {
)
assertEquals("-- 注释\nALTER TABLE sample ADD COLUMN data TEXT", statements[1])
}

@Test
fun duplicateColumnMessage_matchesSqliteError() {
assertTrue(
SchemaCatchUp.isDuplicateColumnMessage(
"duplicate column name: proxyEnabled (code 1 SQLITE_ERROR)"
)
)
assertTrue(
SchemaCatchUp.isDuplicateColumnMessage(
"SQLiteException: Duplicate column name: sortOrder"
)
)
assertFalse(SchemaCatchUp.isDuplicateColumnMessage("no such table: ai_providers"))
assertFalse(SchemaCatchUp.isDuplicateColumnMessage(null))
}

@Test
fun requiredColumns_coverRenumberedMigrations() {
val names = SchemaCatchUp.requiredColumns.map { "${it.table}.${it.name}" }.toSet()
assertTrue(names.contains("agent_messages.thinkingBlocksJson"))
assertTrue(names.contains("llm_call_records.cacheCreationTokens"))
assertTrue(names.contains("chat_sessions.parentId"))
assertTrue(names.contains("ai_providers.proxyEnabled"))
assertTrue(names.contains("ai_providers.sortOrder"))
}
}
Loading