chore(data): replace normalizeEndpoint shim with Migration(3, 4) (#69)

Pre-#67 builds wrote Conversation.endpoint as the Kotlin enum `.name`
(e.g. "OPENAI"). #67 added a read-side shim in ConversationMapper that
converted those legacy rows back to the wire-format SerialName on read.
Replace the shim with a one-time data migration so the conversion runs
once per user on db upgrade and the read path is plain.

Bump database to v4 and register MIGRATION_3_4 on both Android and iOS
builders. The migration runs nine UPDATE statements per affected column
(conversations.endpoint, conversations.endpointType, presets.endpoint).
The presets table is migrated defensively — PresetDao currently has no
production callers, so the column is empty in the field, but symmetry
is enforced if a future change starts persisting presets.

Schema v4.json is structurally identical to v3.json; identityHash is
unchanged (b4117797915eafc13b5bd956677f83bf). Only the version field
differs.

Pre-#67 ChatPayloadBuilder coerced any unknown endpoint name to
EModelEndpoint.AGENTS, so a legacy "AGENTS" row may have originated
from any custom endpoint. The migration rewrites "AGENTS" -> "agents",
preserving the same lossy outcome the read-side shim already produced.
A dedicated test pins this contract so a future "smart" migration can't
silently change cache/fresh consistency.

Verified via :core:data:connectedDebugAndroidTest (5 tests, all green:
existing v1->v3 helper test, v1->v4 Room API test, v3->v4 endpoint
normalization, v3->v4 lossy AGENTS, v3->v4 preset normalization).
This commit is contained in:
Garfie 2026-04-28 22:03:36 -06:00
parent 2b44937368
commit f0d605a932
8 changed files with 823 additions and 80 deletions

View file

@ -0,0 +1,620 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "b4117797915eafc13b5bd956677f83bf",
"entities": [
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `title` TEXT NOT NULL, `user` TEXT NOT NULL, `endpoint` TEXT, `endpointType` TEXT, `model` TEXT, `agentId` TEXT, `isArchived` INTEGER NOT NULL, `tags` TEXT NOT NULL, `iconURL` TEXT, `greeting` TEXT, `modelParams` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))",
"fields": [
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT"
},
{
"fieldPath": "endpointType",
"columnName": "endpointType",
"affinity": "TEXT"
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT"
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT"
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT"
},
{
"fieldPath": "greeting",
"columnName": "greeting",
"affinity": "TEXT"
},
{
"fieldPath": "modelParams",
"columnName": "modelParams",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"conversationId"
]
},
"indices": [
{
"name": "index_conversations_isArchived_updatedAt",
"unique": false,
"columnNames": [
"isArchived",
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_isArchived_updatedAt` ON `${TABLE_NAME}` (`isArchived`, `updatedAt`)"
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `parentMessageId` TEXT, `sender` TEXT, `text` TEXT, `content` TEXT, `isCreatedByUser` INTEGER NOT NULL, `model` TEXT, `endpoint` TEXT, `iconURL` TEXT, `unfinished` INTEGER NOT NULL, `error` INTEGER NOT NULL, `finishReason` TEXT, `tokenCount` INTEGER, `feedback` TEXT, `files` TEXT, `attachments` TEXT, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`messageId`))",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentMessageId",
"columnName": "parentMessageId",
"affinity": "TEXT"
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT"
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT"
},
{
"fieldPath": "isCreatedByUser",
"columnName": "isCreatedByUser",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT"
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT"
},
{
"fieldPath": "iconURL",
"columnName": "iconURL",
"affinity": "TEXT"
},
{
"fieldPath": "unfinished",
"columnName": "unfinished",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "error",
"columnName": "error",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "finishReason",
"columnName": "finishReason",
"affinity": "TEXT"
},
{
"fieldPath": "tokenCount",
"columnName": "tokenCount",
"affinity": "INTEGER"
},
{
"fieldPath": "feedback",
"columnName": "feedback",
"affinity": "TEXT"
},
{
"fieldPath": "files",
"columnName": "files",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId"
]
},
"indices": [
{
"name": "index_messages_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_messages_parentMessageId",
"unique": false,
"columnNames": [
"parentMessageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_parentMessageId` ON `${TABLE_NAME}` (`parentMessageId`)"
},
{
"name": "index_messages_conversationId_createdAt",
"unique": false,
"columnNames": [
"conversationId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
}
]
},
{
"tableName": "files",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` TEXT NOT NULL, `user` TEXT NOT NULL, `conversationId` TEXT, `messageId` TEXT, `filename` TEXT NOT NULL, `filepath` TEXT NOT NULL, `type` TEXT NOT NULL, `bytes` INTEGER NOT NULL, `source` TEXT NOT NULL, `width` INTEGER, `height` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `localPath` TEXT, PRIMARY KEY(`fileId`))",
"fields": [
{
"fieldPath": "fileId",
"columnName": "fileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "conversationId",
"columnName": "conversationId",
"affinity": "TEXT"
},
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT"
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filepath",
"columnName": "filepath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bytes",
"columnName": "bytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER"
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localPath",
"columnName": "localPath",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"fileId"
]
},
"indices": [
{
"name": "index_files_conversationId",
"unique": false,
"columnNames": [
"conversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
},
{
"name": "index_files_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_files_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
]
},
{
"tableName": "agents",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `description` TEXT, `avatar` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `category` TEXT, `authorName` TEXT, `isPromoted` INTEGER NOT NULL, `conversationStarters` TEXT, `tools` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT"
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT"
},
{
"fieldPath": "authorName",
"columnName": "authorName",
"affinity": "TEXT"
},
{
"fieldPath": "isPromoted",
"columnName": "isPromoted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationStarters",
"columnName": "conversationStarters",
"affinity": "TEXT"
},
{
"fieldPath": "tools",
"columnName": "tools",
"affinity": "TEXT"
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "presets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`presetId` TEXT NOT NULL, `title` TEXT NOT NULL, `endpoint` TEXT, `model` TEXT, `isDefault` INTEGER NOT NULL, `order` INTEGER, `params` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`presetId`))",
"fields": [
{
"fieldPath": "presetId",
"columnName": "presetId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endpoint",
"columnName": "endpoint",
"affinity": "TEXT"
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT"
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER"
},
{
"fieldPath": "params",
"columnName": "params",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"presetId"
]
}
},
{
"tableName": "conversation_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tag` TEXT NOT NULL, `user` TEXT NOT NULL, `description` TEXT, `count` INTEGER NOT NULL, `position` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "count",
"columnName": "count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_conversation_tags_tag_user",
"unique": true,
"columnNames": [
"tag",
"user"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_conversation_tags_tag_user` ON `${TABLE_NAME}` (`tag`, `user`)"
}
]
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversation_id` TEXT NOT NULL, `text` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`))",
"fields": [
{
"fieldPath": "conversationId",
"columnName": "conversation_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"conversation_id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4117797915eafc13b5bd956677f83bf')"
]
}
}

View file

@ -4,8 +4,10 @@ import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.garfiec.librechat.core.data.db.migration.MIGRATION_3_4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.Rule
@ -13,12 +15,9 @@ import org.junit.Test
import org.junit.runner.RunWith
/**
* Validates Room auto-migrations (v1v2v3) preserve all entity data
* including complex JSON-serialized fields.
*
* Strategy: Create a v1 database, populate all 6 v1 entity types with
* realistic data (including JSON columns), run migrations to v3,
* verify every row and field survived intact.
* Validates Room migrations preserve entity data (auto v1v2v3) and that
* the manual v3v4 normalization rewrites legacy enum-name endpoint values
* to their wire-format counterparts.
*/
@RunWith(AndroidJUnit4::class)
class RoomMigrationTest {
@ -223,7 +222,7 @@ class RoomMigrationTest {
}
@Test
fun migrateV1ToV3_viaRoomApi_entitiesReadable() {
fun migrateV1ToV4_viaRoomApi_entitiesReadable() {
// Create and populate a v1 database
val db = helper.createDatabase(testDbName, 1)
db.insert(
@ -242,13 +241,15 @@ class RoomMigrationTest {
)
db.close()
// Open with Room (auto-runs migrations)
// Open with Room (auto-runs auto-migrations 1→2→3 and the manual 3→4)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roomDb = Room.databaseBuilder(
context,
LibreChatDatabase::class.java,
testDbName,
).build()
)
.addMigrations(MIGRATION_3_4)
.build()
// Verify we can read the migrated data via DAO
val conversation = runBlocking {
@ -259,4 +260,146 @@ class RoomMigrationTest {
roomDb.close()
}
/**
* Pre-#67 builds wrote Conversation.endpoint and endpointType as the
* Kotlin enum `.name`. The v3v4 migration rewrites those rows to the
* wire-format SerialName so ConversationMapper can drop its read-side shim.
*/
@Test
fun migrateV3ToV4_normalizesLegacyEndpointEnumNames() {
val db = helper.createDatabase(testDbName, 3)
insertLegacyConversation(db, id = "legacy-openai", endpoint = "OPENAI", endpointType = "OPENAI")
insertLegacyConversation(db, id = "legacy-azure", endpoint = "AZURE_OPENAI", endpointType = "AZURE_OPENAI")
insertLegacyConversation(db, id = "legacy-anthro", endpoint = "ANTHROPIC", endpointType = null)
insertLegacyConversation(db, id = "legacy-google", endpoint = "GOOGLE", endpointType = "GOOGLE")
insertLegacyConversation(db, id = "legacy-bedrock", endpoint = "BEDROCK", endpointType = "BEDROCK")
insertLegacyConversation(db, id = "legacy-assist", endpoint = "ASSISTANTS", endpointType = "ASSISTANTS")
insertLegacyConversation(db, id = "legacy-azassist", endpoint = "AZURE_ASSISTANTS", endpointType = "AZURE_ASSISTANTS")
insertLegacyConversation(db, id = "legacy-custom", endpoint = "CUSTOM", endpointType = "CUSTOM")
// Idempotency: already-wire-format rows are untouched
insertLegacyConversation(db, id = "post-fix", endpoint = "openAI", endpointType = "openAI")
// Custom endpoint name passes through (the issue #60 case)
insertLegacyConversation(db, id = "custom-name", endpoint = "OpenRouter", endpointType = "custom")
// Null endpoints unaffected
insertLegacyConversation(db, id = "null-ep", endpoint = null, endpointType = null)
db.close()
val migrated = helper.runMigrationsAndValidate(testDbName, 4, true, MIGRATION_3_4)
assertEndpointPair(migrated, "legacy-openai", endpoint = "openAI", endpointType = "openAI")
assertEndpointPair(migrated, "legacy-azure", endpoint = "azureOpenAI", endpointType = "azureOpenAI")
assertEndpointPair(migrated, "legacy-anthro", endpoint = "anthropic", endpointType = null)
assertEndpointPair(migrated, "legacy-google", endpoint = "google", endpointType = "google")
assertEndpointPair(migrated, "legacy-bedrock", endpoint = "bedrock", endpointType = "bedrock")
assertEndpointPair(migrated, "legacy-assist", endpoint = "assistants", endpointType = "assistants")
assertEndpointPair(migrated, "legacy-azassist", endpoint = "azureAssistants", endpointType = "azureAssistants")
assertEndpointPair(migrated, "legacy-custom", endpoint = "custom", endpointType = "custom")
assertEndpointPair(migrated, "post-fix", endpoint = "openAI", endpointType = "openAI")
assertEndpointPair(migrated, "custom-name", endpoint = "OpenRouter", endpointType = "custom")
assertEndpointPair(migrated, "null-ep", endpoint = null, endpointType = null)
migrated.close()
}
/**
* Pre-#67 ChatPayloadBuilder silently coerced any unknown endpoint name
* (OpenRouter, Deepseek, xAI, ) to EModelEndpoint.AGENTS, so a legacy
* row with `endpoint = "AGENTS"` may have originated from any custom
* endpoint and the original name is unrecoverable.
*
* The v3v4 migration rewrites "AGENTS" "agents" same lossy outcome
* the read-side shim already produced. This test pins that contract so a
* future "smart" migration can't silently change the cached/fresh mismatch
* behavior without an explicit decision.
*/
@Test
fun migrateV3ToV4_legacyAgentsRowMapsToLowercaseAgents_lossyByDesign() {
val db = helper.createDatabase(testDbName, 3)
insertLegacyConversation(db, id = "lossy-agents", endpoint = "AGENTS", endpointType = "AGENTS")
db.close()
val migrated = helper.runMigrationsAndValidate(testDbName, 4, true, MIGRATION_3_4)
assertEndpointPair(migrated, "lossy-agents", endpoint = "agents", endpointType = "agents")
migrated.close()
}
/**
* `presets` table is migrated defensively. PresetDao currently has no
* production callers (PresetRepositoryImpl is API-only), so the table is
* empty in the field. Test exists so the symmetry is enforced if a future
* change starts persisting presets.
*/
@Test
fun migrateV3ToV4_normalizesLegacyPresetEndpoint() {
val db = helper.createDatabase(testDbName, 3)
db.execSQL(
"INSERT INTO presets (presetId, title, endpoint, model, isDefault, `order`, params, createdAt, updatedAt) " +
"VALUES ('preset-legacy', 'Legacy', 'OPENAI', 'gpt-4o', 0, 0, '{}', 1700000000000, 1700000000000)",
)
db.execSQL(
"INSERT INTO presets (presetId, title, endpoint, model, isDefault, `order`, params, createdAt, updatedAt) " +
"VALUES ('preset-custom', 'Custom', 'OpenRouter', 'llama', 0, 1, '{}', 1700000000000, 1700000000000)",
)
db.close()
val migrated = helper.runMigrationsAndValidate(testDbName, 4, true, MIGRATION_3_4)
migrated.query("SELECT endpoint FROM presets WHERE presetId = 'preset-legacy'").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getString(0)).isEqualTo("openAI")
}
migrated.query("SELECT endpoint FROM presets WHERE presetId = 'preset-custom'").use { c ->
assertThat(c.moveToFirst()).isTrue()
assertThat(c.getString(0)).isEqualTo("OpenRouter")
}
migrated.close()
}
private fun insertLegacyConversation(
db: SupportSQLiteDatabase,
id: String,
endpoint: String?,
endpointType: String?,
) {
db.insert(
"conversations",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("conversationId", id)
put("title", "Title $id")
put("user", "user-001")
put("endpoint", endpoint)
put("endpointType", endpointType)
put("isArchived", 0)
put("tags", "[]")
put("createdAt", 1700000000000L)
put("updatedAt", 1700000000000L)
put("lastSyncedAt", 0L)
},
)
}
private fun assertEndpointPair(
db: SupportSQLiteDatabase,
id: String,
endpoint: String?,
endpointType: String?,
) {
db.query("SELECT endpoint, endpointType FROM conversations WHERE conversationId = '$id'").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
val actualEndpoint = if (cursor.isNull(0)) null else cursor.getString(0)
val actualEndpointType = if (cursor.isNull(1)) null else cursor.getString(1)
assertThat(actualEndpoint).isEqualTo(endpoint)
assertThat(actualEndpointType).isEqualTo(endpointType)
}
}
}

View file

@ -8,6 +8,7 @@ import androidx.room.Room
import com.garfiec.librechat.core.common.di.KoinQualifiers
import com.garfiec.librechat.core.data.datastore.TokenDataStore
import com.garfiec.librechat.core.data.db.LibreChatDatabase
import com.garfiec.librechat.core.data.db.migration.MIGRATION_3_4
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SessionCacheCleaner
@ -28,7 +29,9 @@ actual val dataPlatformModule: Module = module {
// --- Database ---
single {
Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db").build()
Room.databaseBuilder(androidContext(), LibreChatDatabase::class.java, "librechat.db")
.addMigrations(MIGRATION_3_4)
.build()
}
// --- DataStore ---

View file

@ -32,7 +32,7 @@ import com.garfiec.librechat.core.data.db.entity.PresetEntity
ConversationTagEntity::class,
DraftEntity::class,
],
version = 3,
version = 4,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),

View file

@ -0,0 +1,42 @@
package com.garfiec.librechat.core.data.db.migration
import androidx.room.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
/**
* Pre-#67 builds stored Conversation.endpoint and endpointType as the Kotlin
* enum `.name` (e.g. "OPENAI"). #67's writer emits the wire-format SerialName
* ("openAI"); the migration rewrites legacy rows in place so the read-side
* shim in ConversationMapper can be deleted.
*
* Hardcoded pairs (not iterated over EModelEndpoint.entries) keep this a
* frozen historical artifact future enum edits do not change which legacy
* strings get rewritten.
*
* Note on lossy AGENTS: pre-#67 ChatPayloadBuilder silently coerced any
* unknown endpoint name (OpenRouter, Deepseek, xAI, ) to EModelEndpoint.AGENTS
* before persistence. So a legacy row with `endpoint = "AGENTS"` may have
* originated from any custom endpoint and the original name is unrecoverable.
* Rewriting "AGENTS" -> "agents" preserves the same lossy outcome the
* read-side shim already produced. This is by design.
*/
val MIGRATION_3_4: Migration = object : Migration(3, 4) {
override fun migrate(connection: SQLiteConnection) {
listOf(
"AZURE_OPENAI" to "azureOpenAI",
"OPENAI" to "openAI",
"GOOGLE" to "google",
"ANTHROPIC" to "anthropic",
"ASSISTANTS" to "assistants",
"AZURE_ASSISTANTS" to "azureAssistants",
"AGENTS" to "agents",
"CUSTOM" to "custom",
"BEDROCK" to "bedrock",
).forEach { (legacy, wire) ->
connection.execSQL("UPDATE conversations SET endpoint = '$wire' WHERE endpoint = '$legacy'")
connection.execSQL("UPDATE conversations SET endpointType = '$wire' WHERE endpointType = '$legacy'")
connection.execSQL("UPDATE presets SET endpoint = '$wire' WHERE endpoint = '$legacy'")
}
}
}

View file

@ -2,7 +2,6 @@ package com.garfiec.librechat.core.data.mapper
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.EModelEndpoint
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
@ -11,16 +10,6 @@ import kotlin.time.Instant
private val json = Json { ignoreUnknownKeys = true }
// Legacy compat: pre-fix rows stored the Kotlin enum `.name` (e.g. "OPENAI").
// Remove once a Room schema migration normalizes the column to wire format.
private fun normalizeEndpoint(stored: String?): String? {
if (stored == null) return null
if (stored in EModelEndpoint.BUILT_IN_NAMES) return stored
runCatching { EModelEndpoint.valueOf(stored).toSerialName() }
.getOrNull()?.let { return it }
return stored
}
fun Conversation.toEntity(): ConversationEntity = ConversationEntity(
conversationId = conversationId ?: "",
title = title ?: "New Chat",
@ -42,8 +31,8 @@ fun ConversationEntity.toModel(): Conversation = Conversation(
conversationId = conversationId,
title = title,
user = user,
endpoint = normalizeEndpoint(endpoint),
endpointType = normalizeEndpoint(endpointType),
endpoint = endpoint,
endpointType = endpointType,
model = model,
agentId = agentId,
isArchived = isArchived,

View file

@ -1,56 +0,0 @@
package com.garfiec.librechat.core.data.mapper
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ConversationMapperNormalizationTest {
private fun entity(endpoint: String?, endpointType: String? = null) = ConversationEntity(
conversationId = "c1",
title = "t",
user = "u",
endpoint = endpoint,
endpointType = endpointType,
model = null,
agentId = null,
isArchived = false,
tags = "[]",
iconURL = null,
greeting = null,
modelParams = null,
createdAt = 0L,
updatedAt = 0L,
)
@Test
fun legacyEnumNameConvertsToWireFormat() {
assertEquals("openAI", entity("OPENAI").toModel().endpoint)
assertEquals("azureOpenAI", entity("AZURE_OPENAI").toModel().endpoint)
assertEquals("agents", entity("AGENTS").toModel().endpoint)
}
@Test
fun wireFormatPassesThroughUnchanged() {
assertEquals("openAI", entity("openAI").toModel().endpoint)
assertEquals("anthropic", entity("anthropic").toModel().endpoint)
}
@Test
fun customEndpointNamePassesThroughUnchanged() {
assertEquals("OpenRouter", entity("OpenRouter").toModel().endpoint)
assertEquals("Deepseek", entity("Deepseek").toModel().endpoint)
}
@Test
fun nullInputReturnsNull() {
assertNull(entity(null).toModel().endpoint)
assertNull(entity("openAI", endpointType = null).toModel().endpointType)
}
@Test
fun unrecognizedInputPassesThroughAsIs() {
assertEquals("SomeUnknownThing", entity("SomeUnknownThing").toModel().endpoint)
}
}

View file

@ -9,6 +9,7 @@ import com.garfiec.librechat.core.common.di.KoinQualifiers
import com.garfiec.librechat.core.data.datastore.IosTokenDataStore
import com.garfiec.librechat.core.data.datastore.ServerUrlKeychainFallback
import com.garfiec.librechat.core.data.db.LibreChatDatabase
import com.garfiec.librechat.core.data.db.migration.MIGRATION_3_4
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SessionCacheCleaner
@ -43,6 +44,7 @@ actual val dataPlatformModule: Module = module {
Room.databaseBuilder<LibreChatDatabase>(name = "$dbDir/librechat.db")
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.addMigrations(MIGRATION_3_4)
.build()
}