From f0d605a9328119f5d71278fa45752173121f3c70 Mon Sep 17 00:00:00 2001 From: Garfie Date: Tue, 28 Apr 2026 22:03:36 -0600 Subject: [PATCH] chore(data): replace normalizeEndpoint shim with Migration(3, 4) (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../4.json | 620 ++++++++++++++++++ .../core/data/db/RoomMigrationTest.kt | 161 ++++- .../data/di/DataPlatformModule.android.kt | 5 +- .../core/data/db/LibreChatDatabase.kt | 2 +- .../core/data/db/migration/Migrations.kt | 42 ++ .../core/data/mapper/ConversationMapper.kt | 15 +- .../ConversationMapperNormalizationTest.kt | 56 -- .../core/data/di/DataPlatformModule.ios.kt | 2 + 8 files changed, 823 insertions(+), 80 deletions(-) create mode 100644 core/data/schemas/com.garfiec.librechat.core.data.db.LibreChatDatabase/4.json create mode 100644 core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/migration/Migrations.kt delete mode 100644 core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt diff --git a/core/data/schemas/com.garfiec.librechat.core.data.db.LibreChatDatabase/4.json b/core/data/schemas/com.garfiec.librechat.core.data.db.LibreChatDatabase/4.json new file mode 100644 index 0000000..41a25ff --- /dev/null +++ b/core/data/schemas/com.garfiec.librechat.core.data.db.LibreChatDatabase/4.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/core/data/src/androidInstrumentedTest/kotlin/com/garfiec/librechat/core/data/db/RoomMigrationTest.kt b/core/data/src/androidInstrumentedTest/kotlin/com/garfiec/librechat/core/data/db/RoomMigrationTest.kt index a319c4b..4634a38 100644 --- a/core/data/src/androidInstrumentedTest/kotlin/com/garfiec/librechat/core/data/db/RoomMigrationTest.kt +++ b/core/data/src/androidInstrumentedTest/kotlin/com/garfiec/librechat/core/data/db/RoomMigrationTest.kt @@ -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 (v1→v2→v3) 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 v1→v2→v3) and that + * the manual v3→v4 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 v3→v4 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 v3→v4 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) + } + } } diff --git a/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt b/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt index 654100c..43b89b5 100644 --- a/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt +++ b/core/data/src/androidMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.android.kt @@ -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 --- diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/LibreChatDatabase.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/LibreChatDatabase.kt index a2945af..43e5857 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/LibreChatDatabase.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/LibreChatDatabase.kt @@ -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), diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/migration/Migrations.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/migration/Migrations.kt new file mode 100644 index 0000000..130e94b --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/db/migration/Migrations.kt @@ -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'") + } + } +} diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt index f3880b2..a9cf954 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapper.kt @@ -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, diff --git a/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt b/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt deleted file mode 100644 index 32f53c5..0000000 --- a/core/data/src/commonTest/kotlin/com/garfiec/librechat/core/data/mapper/ConversationMapperNormalizationTest.kt +++ /dev/null @@ -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) - } -} diff --git a/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt b/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt index cdf6ce4..d361f32 100644 --- a/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt +++ b/core/data/src/iosMain/kotlin/com/garfiec/librechat/core/data/di/DataPlatformModule.ios.kt @@ -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(name = "$dbDir/librechat.db") .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .addMigrations(MIGRATION_3_4) .build() }