From 9571d60c08d1a41b94d9df4baa0a29ea5f5e2475 Mon Sep 17 00:00:00 2001 From: panzeyu2013 <1971614652@qq.com> Date: Tue, 2 Jun 2026 21:58:53 +0800 Subject: [PATCH 1/3] fix: persist user model mapping intent across prune/bootstrap cycles (#294) Add model_source_mapping_preferences tombstone table to record user unlink/disable intent, preventing auto_associate_source_models from recreating deleted mappings and resetting disabled state after source recovery from rate limiting. - New migration 064 for preferences table - 4 CRUD methods in model_sources.rs storage layer - W1: delete_managed writes 'unlinked' before deleting mapping - W2: save_managed writes/clears preference based on enabled state - W3/W4: delete_account/delete_aggregate_api cascade clean preferences - W5: stale model cleanup in upsert_discovered cascades to preferences - W6: prune path preserves preferences (survive temporary unavailability) - W7: specific-source sync path deletes preferences (known inactive) - R1: auto_associate bulk-loads preferences, skips unlinked, disables - RPC enhanced to pass sourceKind/sourceId/upstreamModel alongside id - delete_aggregate_api rewritten with transaction and 3-level cascade - Frontend/Tauri/web transport payload pattern aligned with save command --- apps/src-tauri/src/commands/apikey.rs | 5 +- apps/src/app/models/page.tsx | 9 +- apps/src/hooks/useManagedModels.ts | 22 +++- apps/src/lib/api/account-client.ts | 9 +- apps/src/lib/api/transport-web-commands.ts | 1 + .../064_model_source_mapping_preferences.sql | 11 ++ crates/core/src/storage/accounts.rs | 5 + crates/core/src/storage/aggregate_apis.rs | 24 +++- crates/core/src/storage/mod.rs | 14 +++ crates/core/src/storage/model_sources.rs | 114 +++++++++++++++++- crates/service/src/apikey/apikey_models.rs | 61 +++++++++- crates/service/src/rpc_dispatch/apikey.rs | 7 +- crates/service/src/tests/lib_tests.rs | 7 +- 13 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 crates/core/migrations/064_model_source_mapping_preferences.sql diff --git a/apps/src-tauri/src/commands/apikey.rs b/apps/src-tauri/src/commands/apikey.rs index 94ce312d6..e138252a4 100644 --- a/apps/src-tauri/src/commands/apikey.rs +++ b/apps/src-tauri/src/commands/apikey.rs @@ -210,10 +210,9 @@ pub async fn service_model_source_mapping_save( #[tauri::command] pub async fn service_model_source_mapping_delete( addr: Option, - id: String, + payload: serde_json::Value, ) -> Result { - let params = serde_json::json!({ "id": id }); - rpc_call_in_background("apikey/modelSourceMappingDelete", addr, Some(params)).await + rpc_call_in_background("apikey/modelSourceMappingDelete", addr, Some(payload)).await } /// 函数 `service_apikey_usage_stats` diff --git a/apps/src/app/models/page.tsx b/apps/src/app/models/page.tsx index 7e173a983..1413e3c7e 100644 --- a/apps/src/app/models/page.tsx +++ b/apps/src/app/models/page.tsx @@ -1087,7 +1087,14 @@ export default function ModelsPage() { size="icon" aria-label={t("删除映射")} disabled={isRoutingSaving} - onClick={() => void deleteSourceMapping(mapping.id)} + onClick={() => + void deleteSourceMapping( + mapping.id, + mapping.sourceKind, + mapping.sourceId, + mapping.upstreamModel, + ) + } > diff --git a/apps/src/hooks/useManagedModels.ts b/apps/src/hooks/useManagedModels.ts index 1ae158f20..28c6f011e 100644 --- a/apps/src/hooks/useManagedModels.ts +++ b/apps/src/hooks/useManagedModels.ts @@ -352,8 +352,12 @@ export function useManagedModels() { }); const sourceMappingDeleteMutation = useMutation({ - mutationFn: (mappingId: string) => - accountClient.deleteManagedModelSourceMapping(mappingId), + mutationFn: (params: { + id: string; + sourceKind: string; + sourceId: string; + upstreamModel: string; + }) => accountClient.deleteManagedModelSourceMapping(params), onSuccess: async () => { await reloadManagedRouting(); await invalidateAll(); @@ -433,9 +437,19 @@ export function useManagedModels() { if (!ensureServiceReady("保存模型映射")) return null; return sourceMappingMutation.mutateAsync(params); }, - deleteSourceMapping: async (mappingId: string) => { + deleteSourceMapping: async ( + mappingId: string, + sourceKind: string, + sourceId: string, + upstreamModel: string, + ) => { if (!ensureServiceReady("删除模型映射")) return false; - await sourceMappingDeleteMutation.mutateAsync(mappingId); + await sourceMappingDeleteMutation.mutateAsync({ + id: mappingId, + sourceKind, + sourceId, + upstreamModel, + }); return true; }, isRefreshing: refreshMutation.isPending, diff --git a/apps/src/lib/api/account-client.ts b/apps/src/lib/api/account-client.ts index 5b887d7cf..2187412b8 100644 --- a/apps/src/lib/api/account-client.ts +++ b/apps/src/lib/api/account-client.ts @@ -838,8 +838,13 @@ export const accountClient = { if (!item) throw new Error("模型映射保存结果为空"); return item; }, - deleteManagedModelSourceMapping: (id: string) => - invoke("service_model_source_mapping_delete", withAddr({ id })), + deleteManagedModelSourceMapping: (params: { + id: string; + sourceKind: string; + sourceId: string; + upstreamModel: string; + }) => + invoke("service_model_source_mapping_delete", withAddr({ payload: params })), async saveManagedModel(params: ManagedModelPayload): Promise { const payload = { previousSlug: params.previousSlug || null, diff --git a/apps/src/lib/api/transport-web-commands.ts b/apps/src/lib/api/transport-web-commands.ts index 2e796bbe5..7d3c7baa3 100644 --- a/apps/src/lib/api/transport-web-commands.ts +++ b/apps/src/lib/api/transport-web-commands.ts @@ -543,6 +543,7 @@ export function createWebCommandMap( }, service_model_source_mapping_delete: { rpcMethod: "apikey/modelSourceMappingDelete", + mapParams: (params) => asRecord(asRecord(params)?.payload) ?? {}, }, service_apikey_read_secret: { rpcMethod: "apikey/readSecret", diff --git a/crates/core/migrations/064_model_source_mapping_preferences.sql b/crates/core/migrations/064_model_source_mapping_preferences.sql new file mode 100644 index 000000000..db7cbe37f --- /dev/null +++ b/crates/core/migrations/064_model_source_mapping_preferences.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS model_source_mapping_preferences ( + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + upstream_model TEXT NOT NULL, + preference TEXT NOT NULL CHECK (preference IN ('unlinked', 'disabled')), + updated_at INTEGER NOT NULL, + PRIMARY KEY (source_kind, source_id, upstream_model) +); + +CREATE INDEX IF NOT EXISTS idx_model_source_mapping_preferences_source + ON model_source_mapping_preferences(source_kind, source_id); diff --git a/crates/core/src/storage/accounts.rs b/crates/core/src/storage/accounts.rs index 791b1f892..b274e44ed 100644 --- a/crates/core/src/storage/accounts.rs +++ b/crates/core/src/storage/accounts.rs @@ -488,6 +488,11 @@ impl Storage { WHERE source_kind = 'openai_account' AND source_id = ?1", [account_id], )?; + tx.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = 'openai_account' AND source_id = ?1", + [account_id], + )?; tx.execute("DELETE FROM accounts WHERE id = ?1", [account_id])?; tx.commit()?; Ok(()) diff --git a/crates/core/src/storage/aggregate_apis.rs b/crates/core/src/storage/aggregate_apis.rs index 2ba7dba14..640bc0244 100644 --- a/crates/core/src/storage/aggregate_apis.rs +++ b/crates/core/src/storage/aggregate_apis.rs @@ -351,16 +351,32 @@ impl Storage { /// # 返回 /// 返回函数执行结果 pub fn delete_aggregate_api(&self, api_id: &str) -> Result<()> { - self.conn.execute( + let tx = self.conn.unchecked_transaction()?; + tx.execute( "DELETE FROM aggregate_api_balance_secrets WHERE aggregate_api_id = ?1", [api_id], )?; - self.conn.execute( + tx.execute( "DELETE FROM aggregate_api_secrets WHERE aggregate_api_id = ?1", [api_id], )?; - self.conn - .execute("DELETE FROM aggregate_apis WHERE id = ?1", [api_id])?; + tx.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = 'aggregate_api' AND source_id = ?1", + [api_id], + )?; + tx.execute( + "DELETE FROM model_source_mappings + WHERE source_kind = 'aggregate_api' AND source_id = ?1", + [api_id], + )?; + tx.execute( + "DELETE FROM model_source_models + WHERE source_kind = 'aggregate_api' AND source_id = ?1", + [api_id], + )?; + tx.execute("DELETE FROM aggregate_apis WHERE id = ?1", [api_id])?; + tx.commit()?; Ok(()) } diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index d0ec5164b..524c25a25 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -96,6 +96,15 @@ pub struct ModelSourceMapping { pub updated_at: i64, } +#[derive(Debug, Clone)] +pub struct ModelSourceMappingPreference { + pub source_kind: String, + pub source_id: String, + pub upstream_model: String, + pub preference: String, + pub updated_at: i64, +} + #[derive(Debug, Clone)] pub struct AccountQuotaCapacityTemplate { pub plan_type: String, @@ -1027,6 +1036,11 @@ impl Storage { self.apply_compat_migration("063_account_subscriptions_account_plan_type", |s| { s.ensure_account_subscriptions_table() })?; + self.apply_sql_or_compat_migration( + "064_model_source_mapping_preferences", + include_str!("../../migrations/064_model_source_mapping_preferences.sql"), + |s| s.ensure_model_source_tables(), + )?; self.ensure_api_key_rotation_columns()?; self.ensure_aggregate_apis_table()?; self.ensure_aggregate_api_supplier_model_tables()?; diff --git a/crates/core/src/storage/model_sources.rs b/crates/core/src/storage/model_sources.rs index ea123a1d7..5c1394331 100644 --- a/crates/core/src/storage/model_sources.rs +++ b/crates/core/src/storage/model_sources.rs @@ -1,4 +1,4 @@ -use super::{now_ts, ModelSourceMapping, ModelSourceModel, Storage}; +use super::{now_ts, ModelSourceMapping, ModelSourceMappingPreference, ModelSourceModel, Storage}; use rusqlite::{params, OptionalExtension, Result, Row}; fn map_source_model(row: &Row<'_>) -> Result { @@ -74,7 +74,17 @@ impl Storage { CREATE INDEX IF NOT EXISTS idx_model_source_mappings_platform ON model_source_mappings(platform_model_slug, enabled, priority DESC); CREATE INDEX IF NOT EXISTS idx_model_source_mappings_source - ON model_source_mappings(source_kind, source_id, enabled);", + ON model_source_mappings(source_kind, source_id, enabled); + CREATE TABLE IF NOT EXISTS model_source_mapping_preferences ( + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + upstream_model TEXT NOT NULL, + preference TEXT NOT NULL CHECK (preference IN ('unlinked', 'disabled')), + updated_at INTEGER NOT NULL, + PRIMARY KEY (source_kind, source_id, upstream_model) + ); + CREATE INDEX IF NOT EXISTS idx_model_source_mapping_preferences_source + ON model_source_mapping_preferences(source_kind, source_id);", ) } @@ -205,6 +215,13 @@ impl Storage { AND discovery_kind = ?4", params![&source_kind, &source_id, &upstream_model, &discovery_kind], )?; + self.conn.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = ?1 + AND source_id = ?2 + AND upstream_model = ?3", + params![&source_kind, &source_id, &upstream_model], + )?; } Ok(out) } @@ -344,4 +361,97 @@ impl Storage { )?; Ok(()) } + + pub fn upsert_model_source_mapping_preference( + &self, + source_kind: &str, + source_id: &str, + upstream_model: &str, + preference: &str, + ) -> Result<()> { + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + let upstream_model = normalize_text(upstream_model); + if source_kind.is_empty() || source_id.is_empty() || upstream_model.is_empty() { + return Ok(()); + } + self.conn.execute( + "INSERT OR REPLACE INTO model_source_mapping_preferences + (source_kind, source_id, upstream_model, preference, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + &source_kind, + &source_id, + &upstream_model, + normalize_text(preference), + now_ts(), + ], + )?; + Ok(()) + } + + pub fn delete_model_source_mapping_preference( + &self, + source_kind: &str, + source_id: &str, + upstream_model: &str, + ) -> Result<()> { + self.conn.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = ?1 AND source_id = ?2 AND upstream_model = ?3", + params![ + normalize_text(source_kind), + normalize_text(source_id), + normalize_text(upstream_model), + ], + )?; + Ok(()) + } + + pub fn delete_model_source_mapping_preferences_for_source( + &self, + source_kind: &str, + source_id: &str, + ) -> Result<()> { + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + if source_kind.is_empty() || source_id.is_empty() { + return Ok(()); + } + self.conn.execute( + "DELETE FROM model_source_mapping_preferences + WHERE source_kind = ?1 AND source_id = ?2", + params![&source_kind, &source_id], + )?; + Ok(()) + } + + pub fn list_model_source_mapping_preferences( + &self, + source_kind: &str, + source_id: &str, + ) -> Result> { + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + if source_kind.is_empty() || source_id.is_empty() { + return Ok(Vec::new()); + } + let mut stmt = self.conn.prepare( + "SELECT source_kind, source_id, upstream_model, preference, updated_at + FROM model_source_mapping_preferences + WHERE source_kind = ?1 AND source_id = ?2", + )?; + let rows = stmt.query_map(params![&source_kind, &source_id], map_preference)?; + rows.collect() + } +} + +fn map_preference(row: &Row<'_>) -> Result { + Ok(ModelSourceMappingPreference { + source_kind: row.get(0)?, + source_id: row.get(1)?, + upstream_model: row.get(2)?, + preference: row.get(3)?, + updated_at: row.get(4)?, + }) } diff --git a/crates/service/src/apikey/apikey_models.rs b/crates/service/src/apikey/apikey_models.rs index c7bffb181..e384b9f12 100644 --- a/crates/service/src/apikey/apikey_models.rs +++ b/crates/service/src/apikey/apikey_models.rs @@ -405,13 +405,42 @@ pub(crate) fn save_managed_model_source_mapping( storage .upsert_model_source_mapping(&mapping) .map_err(|err| format!("save model mapping failed: {err}"))?; + if mapping.enabled { + storage + .delete_model_source_mapping_preference( + &mapping.source_kind, + &mapping.source_id, + &mapping.upstream_model, + ) + .map_err(|err| format!("clear preference failed: {err}"))?; + } else { + storage + .upsert_model_source_mapping_preference( + &mapping.source_kind, + &mapping.source_id, + &mapping.upstream_model, + "disabled", + ) + .map_err(|err| format!("save disable preference failed: {err}"))?; + } Ok(source_mapping_entry(mapping)) } -pub(crate) fn delete_managed_model_source_mapping(id: &str) -> Result<(), String> { +pub(crate) fn delete_managed_model_source_mapping( + id: &str, + source_kind: &str, + source_id: &str, + upstream_model: &str, +) -> Result<(), String> { let storage = storage_helpers::open_storage().ok_or_else(|| "storage unavailable".to_string())?; let id = normalize_required("id", id)?; + let source_kind = normalize_routing_source_kind(source_kind)?; + let source_id = normalize_required("sourceId", source_id)?; + let upstream_model = normalize_required("upstreamModel", upstream_model)?; + storage + .upsert_model_source_mapping_preference(&source_kind, &source_id, &upstream_model, "unlinked") + .map_err(|err| format!("save unlink preference failed: {err}"))?; storage .delete_model_source_mapping(id.as_str()) .map_err(|err| format!("delete model mapping failed: {err}")) @@ -502,6 +531,12 @@ fn sync_openai_account_source_models_with_options( .collect::>(); if let Some(source_id) = requested_source_id.as_deref() { if !active_source_ids.contains(source_id) { + storage + .delete_model_source_mapping_preferences_for_source( + ROUTING_SOURCE_KIND_OPENAI_ACCOUNT, + source_id, + ) + .map_err(|err| format!("delete account preferences failed: {err}"))?; storage .delete_model_source_routes_for_source( ROUTING_SOURCE_KIND_OPENAI_ACCOUNT, @@ -608,6 +643,12 @@ where ROUTING_SOURCE_KIND_AGGREGATE_API, source_id, )?; + storage + .delete_model_source_mapping_preferences_for_source( + ROUTING_SOURCE_KIND_AGGREGATE_API, + source_id, + ) + .map_err(|err| format!("delete api preferences failed: {err}"))?; storage .delete_model_source_routes_for_source(ROUTING_SOURCE_KIND_AGGREGATE_API, source_id) .map_err(|err| format!("delete stale aggregate api source routes failed: {err}"))?; @@ -832,6 +873,13 @@ fn auto_associate_source_models( .map(|mapping| mapping.platform_model_slug) .collect::>(); + let prefs: std::collections::HashMap = storage + .list_model_source_mapping_preferences(source_kind, source_id) + .map_err(|err| format!("list preferences failed: {err}"))? + .into_iter() + .map(|p| (p.upstream_model, p.preference)) + .collect(); + let source_models = storage .list_model_source_models(Some(source_kind), Some(source_id)) .map_err(|err| format!("list source models failed: {err}"))? @@ -901,13 +949,22 @@ fn auto_associate_source_models( if existing_source_platform_mappings.contains(source_model.upstream_model.as_str()) { continue; } + if prefs + .get(source_model.upstream_model.as_str()) + .map_or(false, |v| v == "unlinked") + { + continue; + } + let enabled = prefs + .get(source_model.upstream_model.as_str()) + .map_or(true, |v| v != "disabled"); let mapping = ModelSourceMapping { id: generate_mapping_id(), platform_model_slug: source_model.upstream_model.clone(), source_kind: source_kind.to_string(), source_id: source_id.to_string(), upstream_model: source_model.upstream_model, - enabled: true, + enabled, priority: 0, weight: 1, billing_model_slug: None, diff --git a/crates/service/src/rpc_dispatch/apikey.rs b/crates/service/src/rpc_dispatch/apikey.rs index 6b126be19..59f152226 100644 --- a/crates/service/src/rpc_dispatch/apikey.rs +++ b/crates/service/src/rpc_dispatch/apikey.rs @@ -233,7 +233,12 @@ pub(super) fn try_handle(req: &JsonRpcRequest, actor: &RpcActor) -> Option { let id = super::str_param(req, "id").unwrap_or(""); - super::ok_or_error(apikey_models::delete_managed_model_source_mapping(id)) + let source_kind = super::str_param(req, "sourceKind").unwrap_or(""); + let source_id = super::str_param(req, "sourceId").unwrap_or(""); + let upstream_model = super::str_param(req, "upstreamModel").unwrap_or(""); + super::ok_or_error(apikey_models::delete_managed_model_source_mapping( + id, source_kind, source_id, upstream_model, + )) } "apikey/usageStats" => super::value_or_error( apikey_usage_stats::read_api_key_usage_stats_for_actor(actor) diff --git a/crates/service/src/tests/lib_tests.rs b/crates/service/src/tests/lib_tests.rs index 3bb5c1f8f..cf0e16e53 100644 --- a/crates/service/src/tests/lib_tests.rs +++ b/crates/service/src/tests/lib_tests.rs @@ -176,7 +176,12 @@ fn password_mode_can_call_admin_and_model_source_rpcs() { ), ( "apikey/modelSourceMappingDelete", - serde_json::json!({ "id": "map_test" }), + serde_json::json!({ + "id": "map_test", + "sourceKind": "openai_account", + "sourceId": "acc_test", + "upstreamModel": "gpt-test", + }), ), ] { let resp = response_result(handle_request_with_actor( From d51a91cce53c378fa5d31f8ea5d561e004a65815 Mon Sep 17 00:00:00 2001 From: panzeyu2013 <1971614652@qq.com> Date: Thu, 4 Jun 2026 00:00:21 +0800 Subject: [PATCH 2/3] refactor: reduce double HashMap lookup in auto_associate to single match --- crates/service/src/apikey/apikey_models.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/service/src/apikey/apikey_models.rs b/crates/service/src/apikey/apikey_models.rs index e384b9f12..769fd4486 100644 --- a/crates/service/src/apikey/apikey_models.rs +++ b/crates/service/src/apikey/apikey_models.rs @@ -949,15 +949,11 @@ fn auto_associate_source_models( if existing_source_platform_mappings.contains(source_model.upstream_model.as_str()) { continue; } - if prefs - .get(source_model.upstream_model.as_str()) - .map_or(false, |v| v == "unlinked") - { - continue; - } - let enabled = prefs - .get(source_model.upstream_model.as_str()) - .map_or(true, |v| v != "disabled"); + let enabled = match prefs.get(source_model.upstream_model.as_str()).map(String::as_str) { + Some("unlinked") => continue, + Some(v) => v != "disabled", + None => true, + }; let mapping = ModelSourceMapping { id: generate_mapping_id(), platform_model_slug: source_model.upstream_model.clone(), From fc3ad9ad80a2ba0e8e053657683ab93dde7735dc Mon Sep 17 00:00:00 2001 From: panzeyu2013 <1971614652@qq.com> Date: Thu, 4 Jun 2026 01:15:48 +0800 Subject: [PATCH 3/3] fix: wrap model source mapping delete + unlink preference in transaction - Add delete_model_source_mapping_with_unlink_preference to atomically upsert unlinked preference and delete the mapping in a single transaction - Replace INSERT OR REPLACE with INSERT ... ON CONFLICT DO UPDATE for upsert_model_source_mapping_preference (avoids rowid reset) - Update delete_managed_model_source_mapping to use the new transactional method - Fix migration test expect message: '057 marker' -> '062 marker' --- crates/core/src/storage/model_sources.rs | 39 +++++++++++++++++++- crates/core/tests/storage/migration_tests.rs | 2 +- crates/service/src/apikey/apikey_models.rs | 5 +-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/core/src/storage/model_sources.rs b/crates/core/src/storage/model_sources.rs index 5e1dc251d..9db7de486 100644 --- a/crates/core/src/storage/model_sources.rs +++ b/crates/core/src/storage/model_sources.rs @@ -357,6 +357,38 @@ impl Storage { Ok(()) } + pub fn delete_model_source_mapping_with_unlink_preference( + &self, + id: &str, + source_kind: &str, + source_id: &str, + upstream_model: &str, + ) -> Result<()> { + let id = normalize_text(id); + let source_kind = normalize_text(source_kind); + let source_id = normalize_text(source_id); + let upstream_model = normalize_text(upstream_model); + if source_kind.is_empty() || source_id.is_empty() || upstream_model.is_empty() { + return Ok(()); + } + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO model_source_mapping_preferences + (source_kind, source_id, upstream_model, preference, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(source_kind, source_id, upstream_model) DO UPDATE SET + preference = excluded.preference, + updated_at = excluded.updated_at", + params![&source_kind, &source_id, &upstream_model, "unlinked", now_ts()], + )?; + tx.execute( + "DELETE FROM model_source_mappings WHERE id = ?1", + params![&id], + )?; + tx.commit()?; + Ok(()) + } + pub fn delete_model_source_mapping(&self, id: &str) -> Result<()> { self.conn.execute( "DELETE FROM model_source_mappings WHERE id = ?1", @@ -400,9 +432,12 @@ impl Storage { return Ok(()); } self.conn.execute( - "INSERT OR REPLACE INTO model_source_mapping_preferences + "INSERT INTO model_source_mapping_preferences (source_kind, source_id, upstream_model, preference, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5)", + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(source_kind, source_id, upstream_model) DO UPDATE SET + preference = excluded.preference, + updated_at = excluded.updated_at", params![ &source_kind, &source_id, diff --git a/crates/core/tests/storage/migration_tests.rs b/crates/core/tests/storage/migration_tests.rs index e83b0f6a1..b170b731d 100644 --- a/crates/core/tests/storage/migration_tests.rs +++ b/crates/core/tests/storage/migration_tests.rs @@ -1180,7 +1180,7 @@ fn observability_storage_compaction_migration_rolls_up_and_prunes_legacy_rows() "DELETE FROM schema_migrations WHERE version = '062_observability_storage_compaction'", [], ) - .expect("remove 057 marker"); + .expect("remove 062 marker"); storage .conn diff --git a/crates/service/src/apikey/apikey_models.rs b/crates/service/src/apikey/apikey_models.rs index 769fd4486..a8039d11b 100644 --- a/crates/service/src/apikey/apikey_models.rs +++ b/crates/service/src/apikey/apikey_models.rs @@ -439,10 +439,7 @@ pub(crate) fn delete_managed_model_source_mapping( let source_id = normalize_required("sourceId", source_id)?; let upstream_model = normalize_required("upstreamModel", upstream_model)?; storage - .upsert_model_source_mapping_preference(&source_kind, &source_id, &upstream_model, "unlinked") - .map_err(|err| format!("save unlink preference failed: {err}"))?; - storage - .delete_model_source_mapping(id.as_str()) + .delete_model_source_mapping_with_unlink_preference(&id, &source_kind, &source_id, &upstream_model) .map_err(|err| format!("delete model mapping failed: {err}")) }