diff --git a/packages/backend/native/src/runtime/backend_runtime/search/generation.rs b/packages/backend/native/src/runtime/backend_runtime/search/generation.rs index 14e08570a9..2eb2996bbf 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/generation.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/generation.rs @@ -3,7 +3,7 @@ use sha2::{Digest, Sha256}; use sqlx::{PgPool, Row}; use uuid::Uuid; -use super::{SCHEMA_FINGERPRINT, SearchProvider, SearchTable}; +use super::{SCHEMA_FINGERPRINT, SearchProvider, SearchTable, WORKSPACE_RECONCILE_FAILED}; use crate::{ runtime::{RuntimeError, RuntimeResult, SearchRuntimeConfig}, search_index::EmbeddedSearchIndex, @@ -311,6 +311,7 @@ pub(super) async fn activate( SELECT 1 FROM search_projection.workspace_states state WHERE state.generation_id=generation.id + AND state.last_error IS DISTINCT FROM $2 AND ( NOT state.covered OR state.required_permission_version > state.applied_permission_version @@ -331,6 +332,12 @@ pub(super) async fn activate( NOT EXISTS ( SELECT 1 FROM search_projection.document_states state WHERE state.generation_id=generation.id + AND NOT EXISTS ( + SELECT 1 FROM search_projection.workspace_states workspace + WHERE workspace.generation_id=state.generation_id + AND workspace.workspace_id=state.workspace_id + AND workspace.last_error=$2 + ) AND (state.target_source_version <> state.published_source_version OR state.target_source_exists <> state.published_source_exists OR state.target_permission_version <> state.published_permission_version) @@ -340,6 +347,7 @@ pub(super) async fn activate( FOR UPDATE"#, ) .bind(generation.id) + .bind(WORKSPACE_RECONCILE_FAILED) .fetch_optional(&mut *transaction) .await .map_err(|error| RuntimeError::database("load search generation activation state", error))?; diff --git a/packages/backend/native/src/runtime/backend_runtime/search/mod.rs b/packages/backend/native/src/runtime/backend_runtime/search/mod.rs index a969a3a247..2fc9d1d766 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/mod.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/mod.rs @@ -24,6 +24,7 @@ use super::{ }; const SCHEMA_FINGERPRINT: i32 = 1; +const WORKSPACE_RECONCILE_FAILED: &str = "search_workspace_reconcile_failed"; #[cfg(test)] pub(crate) static SEARCH_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); diff --git a/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs b/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs index a3fcd89d39..513cebc0ff 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/runtime.rs @@ -8,8 +8,8 @@ use tokio::{ use uuid::Uuid; use super::{ - ActiveGeneration, PermissionAuthorizer, SearchProvider, activate, cleanup_retired_generation, config_hash, ensure, - load_active, reconcile_workspace, sweep_generation_orphans, + ActiveGeneration, PermissionAuthorizer, SearchProvider, WORKSPACE_RECONCILE_FAILED, activate, + cleanup_retired_generation, config_hash, ensure, load_active, reconcile_workspace, sweep_generation_orphans, }; use crate::{ runtime::{RuntimeError, RuntimeResult, SearchRuntimeConfig}, @@ -212,12 +212,16 @@ impl SearchRuntime { .await .map_err(|error| RuntimeError::database("count search workspaces", error))?; let pending: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM search_projection.workspace_states WHERE generation_id=$1 AND (NOT covered OR \ - pending_scope <> 'none')) OR EXISTS (SELECT 1 FROM search_projection.document_states WHERE generation_id=$1 \ - AND (target_source_version <> published_source_version OR target_source_exists <> published_source_exists OR \ - target_permission_version <> published_permission_version))", + "SELECT EXISTS (SELECT 1 FROM search_projection.workspace_states WHERE generation_id=$1 AND last_error IS \ + DISTINCT FROM $2 AND (NOT covered OR pending_scope <> 'none')) OR EXISTS (SELECT 1 FROM \ + search_projection.document_states document WHERE generation_id=$1 AND NOT EXISTS (SELECT 1 FROM \ + search_projection.workspace_states workspace WHERE workspace.generation_id=document.generation_id AND \ + workspace.workspace_id=document.workspace_id AND workspace.last_error=$2) AND (target_source_version <> \ + published_source_version OR target_source_exists <> published_source_exists OR target_permission_version <> \ + published_permission_version))", ) .bind(generation.id) + .bind(WORKSPACE_RECONCILE_FAILED) .fetch_one(&self.pool) .await .map_err(|error| RuntimeError::database("check pending search projection", error))?; @@ -318,6 +322,12 @@ impl SearchRuntime { r#"SELECT id,provider,state,scan_cursor_sid,scan_high_water_sid, (SELECT count(*) FROM search_projection.document_states document WHERE document.generation_id=generations.id + AND NOT EXISTS ( + SELECT 1 FROM search_projection.workspace_states workspace + WHERE workspace.generation_id=document.generation_id + AND workspace.workspace_id=document.workspace_id + AND workspace.last_error=$4 + ) AND (document.target_source_version <> document.published_source_version OR document.target_source_exists <> document.published_source_exists OR document.target_permission_version <> document.published_permission_version)) AS pending_publications, @@ -332,6 +342,7 @@ impl SearchRuntime { .bind(&self.config.provider) .bind(config_hash(&self.config)) .bind(super::SCHEMA_FINGERPRINT) + .bind(WORKSPACE_RECONCILE_FAILED) .fetch_optional(&self.pool) .await .map_err(|error| RuntimeError::database("load search projection status", error))?; diff --git a/packages/backend/native/src/runtime/backend_runtime/search/worker/mod.rs b/packages/backend/native/src/runtime/backend_runtime/search/worker/mod.rs index 5cce610003..00f3753986 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/worker/mod.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/worker/mod.rs @@ -36,6 +36,6 @@ use workspace_state::{ }; use super::{ - ActiveGeneration, ProjectionInput, SearchChange, SearchProvider, SearchTable, project_document, - projection_external_id, provider_payload, + ActiveGeneration, ProjectionInput, SearchChange, SearchProvider, SearchTable, WORKSPACE_RECONCILE_FAILED, + project_document, projection_external_id, provider_payload, }; diff --git a/packages/backend/native/src/runtime/backend_runtime/search/worker/workspace_state.rs b/packages/backend/native/src/runtime/backend_runtime/search/worker/workspace_state.rs index 6cc212d58f..7b017a6ebe 100644 --- a/packages/backend/native/src/runtime/backend_runtime/search/worker/workspace_state.rs +++ b/packages/backend/native/src/runtime/backend_runtime/search/worker/workspace_state.rs @@ -3,7 +3,7 @@ use serde_json::Value; use sqlx::{PgPool, Row}; use uuid::Uuid; -use super::{ActiveGeneration, LEASE_SECONDS, SearchTable}; +use super::{ActiveGeneration, LEASE_SECONDS, SearchTable, WORKSPACE_RECONCILE_FAILED}; use crate::runtime::{RuntimeError, RuntimeResult}; const ANTI_ENTROPY_INTERVAL_SECONDS: i64 = 3600; @@ -350,29 +350,9 @@ pub(super) async fn mark_workspace_failed( .begin() .await .map_err(|error| RuntimeError::database("begin failed search workspace update", error))?; - let generation_state: Option = sqlx::query_scalar( - r#"SELECT generation.state - FROM search_projection.workspace_states state - JOIN search_projection.generations generation ON generation.id=state.generation_id - WHERE state.generation_id=$1 AND state.workspace_id=$2 AND state.claim_fence=$3 - FOR UPDATE"#, - ) - .bind(generation_id) - .bind(workspace_id) - .bind(fence) - .fetch_optional(&mut *transaction) - .await - .map_err(|error| RuntimeError::database("load failed search workspace claim", error))?; - let Some(generation_state) = generation_state else { - transaction - .commit() - .await - .map_err(|error| RuntimeError::database("commit empty search workspace update", error))?; - return Ok(()); - }; - sqlx::query( + let updated = sqlx::query( r#"UPDATE search_projection.workspace_states - SET covered=false, pending_scope='workspace', last_error='search_workspace_reconcile_failed', + SET covered=false, pending_scope='workspace', last_error=$4, progress=NULL, available_at='infinity'::timestamptz, lease_owner=NULL, lease_expires_at=NULL, updated_at=now() WHERE generation_id=$1 AND workspace_id=$2 AND claim_fence=$3"#, @@ -380,9 +360,17 @@ pub(super) async fn mark_workspace_failed( .bind(generation_id) .bind(workspace_id) .bind(fence) + .bind(WORKSPACE_RECONCILE_FAILED) .execute(&mut *transaction) .await .map_err(|error| RuntimeError::database("mark search workspace failed", error))?; + if updated.rows_affected() == 0 { + transaction + .commit() + .await + .map_err(|error| RuntimeError::database("commit empty search workspace update", error))?; + return Ok(()); + } sqlx::query( r#"UPDATE search_projection.document_states SET available_at='infinity'::timestamptz, lease_owner=NULL, lease_expires_at=NULL, updated_at=now() @@ -393,17 +381,6 @@ pub(super) async fn mark_workspace_failed( .execute(&mut *transaction) .await .map_err(|error| RuntimeError::database("pause failed search document publications", error))?; - if generation_state != "active" { - sqlx::query( - r#"UPDATE search_projection.generations - SET state='failed', last_error='search_workspace_reconcile_failed' - WHERE id=$1 AND state='building'"#, - ) - .bind(generation_id) - .execute(&mut *transaction) - .await - .map_err(|error| RuntimeError::database("mark search generation failed", error))?; - } transaction .commit() .await @@ -416,7 +393,11 @@ mod tests { use serde_json::json; use super::*; - use crate::runtime::{backend_runtime::search::SEARCH_TEST_LOCK, migrations::migrate_search_tables}; + use crate::runtime::{ + SearchRuntimeConfig, + backend_runtime::search::{SEARCH_TEST_LOCK, SearchRuntime, config_hash}, + migrations::migrate_search_tables, + }; #[test] fn progress_round_trips_versioned_context_for_each_phase() { @@ -465,6 +446,7 @@ mod tests { migrate_search_tables(&pool).await.unwrap(); let generation_id = Uuid::new_v4(); let workspace_id = format!("claim-workspace-{}", Uuid::new_v4().simple()); + let config = SearchRuntimeConfig::default(); sqlx::query("INSERT INTO workspaces(id) VALUES($1)") .bind(&workspace_id) .execute(&pool) @@ -485,10 +467,12 @@ mod tests { .await .unwrap(); sqlx::query( - r#"INSERT INTO search_projection.generations(id,provider,state,config_hash,schema_version) - VALUES($1,'embedded','failed',decode(repeat('00',32),'hex'),1)"#, + r#"INSERT INTO search_projection.generations( + id,provider,state,config_hash,schema_version,scan_high_water_sid,scan_cursor_sid + ) VALUES($1,'embedded','building',$2,1,0,0)"#, ) .bind(generation_id) + .bind(config_hash(&config)) .execute(&pool) .await .unwrap(); @@ -536,14 +520,9 @@ mod tests { checkpoint_workspace(&pool, generation_id, &workspace_id, first.fence, checkpoint) .await .unwrap(); - sqlx::query("UPDATE snapshots SET updated_at='2026-01-02 UTC' WHERE workspace_id=$1 AND guid=$1") - .bind(&workspace_id) - .execute(&pool) - .await - .unwrap(); sqlx::query( - "UPDATE search_projection.workspace_states SET required_permission_version=4 WHERE generation_id=$1 AND \ - workspace_id=$2", + "UPDATE search_projection.workspace_states SET target_root_revision=target_root_revision+1, \ + required_permission_version=4 WHERE generation_id=$1 AND workspace_id=$2", ) .bind(generation_id) .bind(&workspace_id) @@ -557,12 +536,25 @@ mod tests { .unwrap(); assert_eq!(second.progress.captured_root_revision, initial_root); assert_eq!(second.progress.captured_permission_version, 3); + sqlx::query( + r#"INSERT INTO search_projection.document_states( + generation_id,workspace_id,doc_id,target_source_version,target_source_exists + ) VALUES($1,$2,'pending-doc',1,true)"#, + ) + .bind(generation_id) + .bind(&workspace_id) + .execute(&pool) + .await + .unwrap(); mark_workspace_failed(&pool, generation_id, &workspace_id, second.fence) .await .unwrap(); - let failed: (bool, Option, Option, bool) = sqlx::query_as( - r#"SELECT covered,last_error,progress,available_at='infinity'::timestamptz - FROM search_projection.workspace_states WHERE generation_id=$1 AND workspace_id=$2"#, + let failed: (bool, Option, Option, bool, String) = sqlx::query_as( + r#"SELECT state.covered,state.last_error,state.progress,state.available_at='infinity'::timestamptz, + generation.state + FROM search_projection.workspace_states state + JOIN search_projection.generations generation ON generation.id=state.generation_id + WHERE state.generation_id=$1 AND state.workspace_id=$2"#, ) .bind(generation_id) .bind(&workspace_id) @@ -571,9 +563,28 @@ mod tests { .unwrap(); assert_eq!( failed, - (false, Some("search_workspace_reconcile_failed".to_string()), None, true) + ( + false, + Some(WORKSPACE_RECONCILE_FAILED.to_string()), + None, + true, + "building".to_string() + ) ); + let runtime = SearchRuntime::new(pool.clone(), config).unwrap(); + runtime.embedded.prepare_generation(generation_id).await; + assert_eq!(runtime.reconcile_pending(1).await.unwrap(), 0); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT state FROM search_projection.generations WHERE id=$1") + .bind(generation_id) + .fetch_one(&pool) + .await + .unwrap(), + "active" + ); + assert_eq!(runtime.status().await.unwrap()["metrics"]["pendingPublications"], 0); + sqlx::query("DELETE FROM search_projection.generations WHERE id=$1") .bind(generation_id) .execute(&pool) diff --git a/packages/backend/native/src/runtime/migrations.rs b/packages/backend/native/src/runtime/migrations.rs index d5d2aa7ced..5757abe16e 100644 --- a/packages/backend/native/src/runtime/migrations.rs +++ b/packages/backend/native/src/runtime/migrations.rs @@ -293,6 +293,7 @@ mod tests { assert!(!SEARCH_PROJECTION_MIGRATION.contains("clock_timestamp()")); assert!(!SEARCH_PROJECTION_MIGRATION.contains("CREATE TABLE search_runtime_projections")); assert!(!SEARCH_PROJECTION_MIGRATION.contains("payload JSONB NOT NULL")); + assert!(SEARCH_PROJECTION_MIGRATION.contains("search_workspace_reconcile_failed")); } #[tokio::test] diff --git a/packages/backend/native/src/runtime/sql/search_projection.sql b/packages/backend/native/src/runtime/sql/search_projection.sql index cfe4e2ae96..5e27e6c8a7 100644 --- a/packages/backend/native/src/runtime/sql/search_projection.sql +++ b/packages/backend/native/src/runtime/sql/search_projection.sql @@ -266,16 +266,23 @@ RETURNS boolean LANGUAGE SQL STABLE AS $$ AND NOT EXISTS ( SELECT 1 FROM search_projection.workspace_states WHERE generation_id = target_generation + AND last_error IS DISTINCT FROM 'search_workspace_reconcile_failed' AND (NOT covered OR pending_scope <> 'none' OR required_permission_version > applied_permission_version OR last_error IS NOT NULL) ) AND NOT EXISTS ( - SELECT 1 FROM search_projection.document_states - WHERE generation_id = target_generation - AND (target_source_version <> published_source_version - OR target_source_exists <> published_source_exists - OR target_permission_version <> published_permission_version) + SELECT 1 FROM search_projection.document_states document + WHERE document.generation_id = target_generation + AND NOT EXISTS ( + SELECT 1 FROM search_projection.workspace_states workspace + WHERE workspace.generation_id = document.generation_id + AND workspace.workspace_id = document.workspace_id + AND workspace.last_error = 'search_workspace_reconcile_failed' + ) + AND (document.target_source_version <> document.published_source_version + OR document.target_source_exists <> document.published_source_exists + OR document.target_permission_version <> document.published_permission_version) ) $$; diff --git a/packages/backend/server/src/data/__tests__/migrations.spec.ts b/packages/backend/server/src/data/__tests__/migrations.spec.ts index 6995144968..96ccab8a51 100644 --- a/packages/backend/server/src/data/__tests__/migrations.spec.ts +++ b/packages/backend/server/src/data/__tests__/migrations.spec.ts @@ -143,6 +143,24 @@ test('managed provider migration preserves explicit profiles and converts legacy models: ['@cf/baai/bge-reranker-base'], config: { apiKey: 'profile-key' }, }, + { + id: 'fal-default', + type: 'fal', + priority: 5, + config: { apiKey: 'existing-fal-key' }, + }, + { + id: 'anthropic-default', + type: 'anthropic', + priority: 2, + config: { apiKey: 'existing-anthropic-key' }, + }, + { + id: 'anthropicVertex-default', + type: 'anthropicVertex', + priority: 1, + config: { projectId: 'existing-anthropic-vertex-project' }, + }, ]; await t.context.db.appConfig.createMany({ data: [ @@ -151,6 +169,10 @@ test('managed provider migration preserves explicit profiles and converts legacy id: 'copilot.providers.openai', value: { apiKey: 'openai-key' }, }, + { + id: 'copilot.providers.cloudflareWorkersAi', + value: { apiKey: 'legacy-cloudflare-key' }, + }, { id: 'copilot.providers.gemini', value: { apiKey: 'gemini-key' }, @@ -159,6 +181,18 @@ test('managed provider migration preserves explicit profiles and converts legacy id: 'copilot.providers.geminiVertex', value: { projectId: 'gemini-vertex-project' }, }, + { + id: 'copilot.providers.fal', + value: { apiKey: 'legacy-fal-key' }, + }, + { + id: 'copilot.providers.anthropic', + value: { apiKey: 'legacy-anthropic-key' }, + }, + { + id: 'copilot.providers.anthropicVertex', + value: { projectId: 'legacy-anthropic-vertex-project' }, + }, { id: 'copilot.providers.defaults', value: { fallback: 'openai-default' }, @@ -173,7 +207,20 @@ test('managed provider migration preserves explicit profiles and converts legacy where: { id: 'copilot.providers.profiles' }, }); t.deepEqual(migrated.value, [ - ...profiles, + profiles[0], + { + ...profiles[1], + models: ['lora/image-to-image', 'workflowutils/teed'], + }, + { + ...profiles[2], + models: ['claude-sonnet-4-6'], + }, + { + ...profiles[3], + models: ['claude-sonnet-4-6'], + enabled: false, + }, { id: 'openai-default', type: 'openai', @@ -181,6 +228,14 @@ test('managed provider migration preserves explicit profiles and converts legacy models: ['gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-image-1', 'gpt-4o-mini'], config: { apiKey: 'openai-key' }, }, + { + id: 'cloudflareWorkersAi-default', + type: 'cloudflareWorkersAi', + priority: 6, + models: ['@cf/baai/bge-reranker-base'], + config: { apiKey: 'legacy-cloudflare-key' }, + enabled: false, + }, { id: 'gemini-default', type: 'gemini', @@ -203,8 +258,12 @@ test('managed provider migration preserves explicit profiles and converts legacy id: { in: [ 'copilot.providers.openai', + 'copilot.providers.cloudflareWorkersAi', 'copilot.providers.gemini', 'copilot.providers.geminiVertex', + 'copilot.providers.fal', + 'copilot.providers.anthropic', + 'copilot.providers.anthropicVertex', ], }, }, @@ -216,6 +275,38 @@ test('managed provider migration preserves explicit profiles and converts legacy where: { id: 'copilot.providers.defaults' }, }) ); + await t.context.db.appConfig.delete({ + where: { id: 'copilot.providers.defaults' }, + }); + + const defaultOnlyProfiles = profiles.slice(1); + await t.context.db.appConfig.update({ + where: { id: 'copilot.providers.profiles' }, + data: { value: defaultOnlyProfiles }, + }); + await ConvergeManagedProviderProfiles1786810000000.up(t.context.db); + t.deepEqual( + ( + await t.context.db.appConfig.findUniqueOrThrow({ + where: { id: 'copilot.providers.profiles' }, + }) + ).value, + [ + { + ...defaultOnlyProfiles[0], + models: ['lora/image-to-image', 'workflowutils/teed'], + }, + { + ...defaultOnlyProfiles[1], + models: ['claude-sonnet-4-6'], + }, + { + ...defaultOnlyProfiles[2], + models: ['claude-sonnet-4-6'], + enabled: false, + }, + ] + ); await t.context.db.appConfig.update({ where: { id: 'copilot.providers.profiles' }, diff --git a/packages/backend/server/src/data/migrations/1786810000000-converge-managed-provider-profiles.ts b/packages/backend/server/src/data/migrations/1786810000000-converge-managed-provider-profiles.ts index e35700fe09..16f3e30278 100644 --- a/packages/backend/server/src/data/migrations/1786810000000-converge-managed-provider-profiles.ts +++ b/packages/backend/server/src/data/migrations/1786810000000-converge-managed-provider-profiles.ts @@ -13,6 +13,9 @@ const PROVIDERS = [ ] as const; const PROVIDER_IDS = PROVIDERS.map(provider => `copilot.providers.${provider}`); +const DEFAULT_PROFILE_IDS = new Set( + PROVIDERS.map(provider => `${provider}-default`) +); const PROVIDER_MODELS: Record<(typeof PROVIDERS)[number], string[]> = { openai: ['gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-image-1', 'gpt-4o-mini'], cloudflareWorkersAi: ['@cf/baai/bge-reranker-base'], @@ -60,16 +63,11 @@ export class ConvergeManagedProviderProfiles1786810000000 { const byId = new Map(rows.map(row => [row.id, row])); const profileRow = byId.get(PROFILE_KEY); const profiles = readProfiles(profileRow?.value); - const profileIds = new Set( - profiles.flatMap(profile => - isRecord(profile) && typeof profile.id === 'string' - ? [profile.id] - : [] - ) - ); const assignedModels = new Set( profiles.flatMap(profile => isRecord(profile) && + typeof profile.id === 'string' && + !DEFAULT_PROFILE_IDS.has(profile.id) && profile.enabled !== false && Array.isArray(profile.models) ? profile.models.filter( @@ -78,33 +76,69 @@ export class ConvergeManagedProviderProfiles1786810000000 { : [] ) ); + let converged = false; for (const [index, provider] of PROVIDERS.entries()) { const legacy = byId.get(`copilot.providers.${provider}`); - if (!legacy) continue; - if (!isRecord(legacy.value)) { + if (legacy && !isRecord(legacy.value)) { throw new Error(`copilot.providers.${provider} must be an object`); } const id = `${provider}-default`; - if (!profileIds.has(id)) { - const models = PROVIDER_MODELS[provider].filter( - model => !assignedModels.has(model) - ); - const enabled = models.length > 0; - profiles.push({ - id, - type: provider, - priority: PROVIDERS.length - index, - models: enabled ? models : PROVIDER_MODELS[provider], - config: legacy.value, - ...(enabled ? {} : { enabled: false }), + const profileIndex = profiles.findIndex( + profile => isRecord(profile) && profile.id === id + ); + const existing = + profileIndex === -1 ? undefined : profiles[profileIndex]; + if (!legacy && !existing) continue; + converged = true; + + const configuredModels = + isRecord(existing) && + Array.isArray(existing.models) && + existing.models.length > 0 + ? existing.models + : PROVIDER_MODELS[provider]; + const canEnable = + !isRecord(existing) || + existing.enabled === undefined || + existing.enabled === true; + const modelsAreValid = configuredModels.every( + model => typeof model === 'string' + ); + const availableModels = + canEnable && modelsAreValid + ? configuredModels.filter(model => !assignedModels.has(model)) + : configuredModels; + const hasConflict = canEnable && availableModels.length === 0; + const models = hasConflict ? configuredModels : availableModels; + const profile: Prisma.JsonObject = { + ...(legacy + ? { + id, + type: provider, + priority: PROVIDERS.length - index, + config: legacy.value, + } + : {}), + ...(isRecord(existing) ? existing : {}), + id, + type: provider, + models, + ...(hasConflict ? { enabled: false } : {}), + }; + if (profileIndex === -1) { + profiles.push(profile); + } else { + profiles[profileIndex] = profile; + } + if (canEnable && !hasConflict) { + models.forEach(model => { + if (typeof model === 'string') assignedModels.add(model); }); - models.forEach(model => assignedModels.add(model)); - profileIds.add(id); } } - if (PROVIDER_IDS.some(id => byId.has(id))) { + if (converged) { validateProfiles(profiles); await tx.appConfig.upsert({ where: { id: PROFILE_KEY },