Skip to main content

pipedash_core/infrastructure/
migration.rs

1pub mod backup;
2pub mod validator;
3
4use std::collections::{
5    BTreeMap,
6    HashMap,
7};
8use std::sync::Arc;
9
10pub use backup::{
11    BackupManager,
12    BackupPaths,
13};
14use chrono::{
15    DateTime,
16    Utc,
17};
18use serde::{
19    Deserialize,
20    Serialize,
21};
22pub use validator::{
23    PreMigrationValidationReport,
24    PreMigrationValidator,
25};
26
27use crate::domain::{
28    DomainError,
29    DomainResult,
30    MetricsQuery,
31    ProviderConfig,
32};
33use crate::event::EventBus;
34use crate::infrastructure::config::{
35    PipedashConfig,
36    StorageConfig,
37    StorageManager,
38};
39use crate::infrastructure::token_store::TokenStore;
40use crate::infrastructure::{
41    database::{
42        DatabasePool,
43        MetricsRepository,
44        PipelineViewRepository,
45        PushRepository,
46        RecordMetrics,
47    },
48    ConfigBackend,
49    StorageBackend,
50};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum MigrationStep {
54    ValidateTarget,
55    MigrateTokens,
56    MigrateConfigs,
57    MigrateCache,
58    VerifyMigration,
59    UpdateConfig,
60}
61
62impl MigrationStep {
63    pub fn description(&self) -> &str {
64        match self {
65            MigrationStep::ValidateTarget => "Validating target configuration",
66            MigrationStep::MigrateTokens => "Migrating tokens",
67            MigrationStep::MigrateConfigs => "Migrating provider configurations",
68            MigrationStep::MigrateCache => "Migrating cached data",
69            MigrationStep::VerifyMigration => "Verifying migration",
70            MigrationStep::UpdateConfig => "Updating storage configuration",
71        }
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct MigrationPlan {
77    pub from: PipedashConfig,
78    pub to: PipedashConfig,
79    pub steps: Vec<MigrationStep>,
80    pub migrate_tokens: bool,
81    pub migrate_configs: bool,
82    pub migrate_cache: bool,
83    pub backend_changed: bool,
84    pub data_dir_changed: bool,
85    pub created_at: DateTime<Utc>,
86}
87
88impl MigrationPlan {
89    pub fn summary(&self) -> String {
90        format!(
91            "Migrate from {} to {} ({} steps)",
92            self.from.storage.summary(),
93            self.to.storage.summary(),
94            self.steps.len()
95        )
96    }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct MigrationProgress {
101    pub current_step: MigrationStep,
102    pub step_index: usize,
103    pub total_steps: usize,
104    pub message: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct MigrationResult {
109    pub success: bool,
110    pub steps_completed: Vec<MigrationStep>,
111    pub errors: Vec<String>,
112    pub duration_ms: u64,
113    pub stats: MigrationStats,
114    #[serde(default)]
115    pub provider_id_mapping: std::collections::HashMap<i64, i64>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub backups: Option<BackupPaths>,
118}
119
120impl MigrationResult {
121    pub fn summary(&self) -> String {
122        if self.success {
123            format!(
124                "Migration successful: {} providers, {} cache entries migrated in {}ms",
125                self.stats.providers_migrated, self.stats.cache_entries_migrated, self.duration_ms
126            )
127        } else {
128            format!(
129                "Migration failed after {} steps: {}",
130                self.steps_completed.len(),
131                self.errors.join(", ")
132            )
133        }
134    }
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct MigrationStats {
139    pub providers_migrated: usize,
140    pub tokens_migrated: usize,
141    pub cache_entries_migrated: usize,
142    pub metrics_migrated: usize,
143    pub permissions_migrated: usize,
144    pub providers_cleaned: usize,
145    pub tokens_cleaned: usize,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct MigrationOptions {
150    pub migrate_tokens: bool,
151    pub migrate_cache: bool,
152    pub token_password: Option<String>,
153    pub dry_run: bool,
154    #[serde(default)]
155    pub allow_non_empty_target: bool,
156    #[serde(default)]
157    pub clean_target: bool,
158    #[serde(default)]
159    pub keep_backups: bool,
160    #[serde(default = "default_max_retries")]
161    pub max_retries: u32,
162}
163
164fn default_max_retries() -> u32 {
165    3
166}
167
168impl Default for MigrationOptions {
169    fn default() -> Self {
170        Self {
171            migrate_tokens: false,
172            migrate_cache: false,
173            token_password: None,
174            dry_run: false,
175            allow_non_empty_target: false,
176            clean_target: false,
177            keep_backups: false,
178            max_retries: 3,
179        }
180    }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ValidationReport {
185    pub errors: Vec<String>,
186    pub warnings: Vec<String>,
187    pub recommendations: Vec<String>,
188}
189
190impl ValidationReport {
191    pub fn is_valid(&self) -> bool {
192        self.errors.is_empty()
193    }
194}
195
196pub const VAULT_PASSWORD_ENV_VAR: &str = "PIPEDASH_VAULT_PASSWORD";
197
198fn get_vault_password(options: &MigrationOptions) -> DomainResult<String> {
199    if let Ok(password) = std::env::var(VAULT_PASSWORD_ENV_VAR) {
200        if !password.is_empty() {
201            return Ok(password);
202        }
203    }
204
205    options.token_password.clone().ok_or_else(|| {
206        DomainError::InvalidConfig(format!(
207            "Vault password required for token migration. Set {} environment variable or provide token_password in options.",
208            VAULT_PASSWORD_ENV_VAR
209        ))
210    })
211}
212
213fn remap_pipeline_id(pipeline_id: &str, provider_ids: &HashMap<i64, i64>) -> Option<String> {
214    let mut parts = pipeline_id.splitn(3, "__");
215    let (Some(provider_type), Some(provider_id), Some(rest)) =
216        (parts.next(), parts.next(), parts.next())
217    else {
218        return None;
219    };
220    let new_id = provider_ids.get(&provider_id.parse().ok()?)?;
221
222    Some(format!("{provider_type}__{new_id}__{rest}"))
223}
224
225fn same_database(from: &PipedashConfig, to: &PipedashConfig) -> bool {
226    from.storage.backend == to.storage.backend
227        && if to.storage.backend.is_sqlite() {
228            from.db_path() == to.db_path()
229        } else {
230            from.storage.postgres.connection_string == to.storage.postgres.connection_string
231        }
232}
233
234pub struct MigrationOrchestrator {
235    source_config: PipedashConfig,
236    source_token_store: Arc<dyn TokenStore>,
237    source_config_backend: Arc<dyn ConfigBackend>,
238    source_cache_backend: Arc<dyn StorageBackend>,
239    source_metrics: MetricsRepository,
240    source_pool: DatabasePool,
241    event_bus: Option<Arc<dyn EventBus>>,
242    target_token_store: Option<Arc<dyn TokenStore>>,
243}
244
245impl MigrationOrchestrator {
246    pub async fn from_manager(
247        manager: &StorageManager, event_bus: Option<Arc<dyn EventBus>>,
248        target_token_store: Option<Arc<dyn TokenStore>>,
249    ) -> Self {
250        Self {
251            source_config: manager.config().clone(),
252            source_token_store: manager.token_store().await,
253            source_config_backend: manager.config_backend(),
254            source_cache_backend: manager.cache_backend(),
255            source_metrics: MetricsRepository::new_from_pool(manager.database_pool()),
256            source_pool: manager.database_pool(),
257            event_bus,
258            target_token_store,
259        }
260    }
261
262    pub fn plan_migration(
263        &self, mut target_config: PipedashConfig, options: &MigrationOptions,
264    ) -> DomainResult<MigrationPlan> {
265        use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
266
267        let source_data_dir = self.source_config.data_dir();
268        let target_data_dir = target_config.data_dir();
269        let data_dir_changed = source_data_dir != target_data_dir;
270
271        let source_storage = &self.source_config.storage;
272        let target_storage = &mut target_config.storage;
273
274        if data_dir_changed {
275            tracing::info!(
276                "[Migration Planning] Data directory change detected: {:?} → {:?}",
277                source_data_dir,
278                target_data_dir
279            );
280        }
281
282        let backend_changed = source_storage.backend != target_storage.backend;
283
284        let token_backend_changing = {
285            let source_uses_keyring = source_storage.backend.is_sqlite()
286                && std::env::var(VAULT_PASSWORD_ENV_VAR).is_err();
287            let target_uses_encrypted = target_storage.backend.is_sqlite()
288                && (options.token_password.is_some()
289                    || std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok());
290
291            source_uses_keyring && target_uses_encrypted
292        };
293
294        if token_backend_changing {
295            tracing::info!(
296                "[Migration Planning] Token backend change detected: keyring → encrypted storage"
297            );
298        }
299
300        let needs_data_migration = backend_changed || data_dir_changed || token_backend_changing;
301
302        if target_storage.backend == ConfigStorageBackend::Postgres {
303            if target_storage.data_dir.starts_with("postgresql://")
304                || target_storage.data_dir.starts_with("postgres://")
305            {
306                tracing::warn!(
307                    "Target data_dir contains PostgreSQL connection string: {}. \
308                     Resetting to empty (will use default) - connection string should be in postgres.connection_string field.",
309                    target_storage.data_dir
310                );
311                target_storage.data_dir = String::new(); // Use default data_dir
312            } else if target_storage.data_dir.is_empty() {
313                tracing::debug!("Target uses PostgreSQL - data_dir is empty (will use default for cache/local files)");
314            }
315        }
316
317        let mut steps = Vec::new();
318
319        steps.push(MigrationStep::ValidateTarget);
320
321        if needs_data_migration {
322            steps.push(MigrationStep::MigrateConfigs);
323        }
324
325        if needs_data_migration && options.migrate_tokens {
326            steps.push(MigrationStep::MigrateTokens);
327        }
328
329        if needs_data_migration && options.migrate_cache {
330            steps.push(MigrationStep::MigrateCache);
331        }
332
333        if !steps.is_empty() {
334            steps.push(MigrationStep::VerifyMigration);
335            steps.push(MigrationStep::UpdateConfig);
336        }
337
338        Ok(MigrationPlan {
339            from: self.source_config.clone(),
340            to: target_config,
341            steps,
342            migrate_tokens: needs_data_migration && options.migrate_tokens,
343            migrate_configs: needs_data_migration,
344            migrate_cache: needs_data_migration && options.migrate_cache,
345            backend_changed,
346            data_dir_changed,
347            created_at: Utc::now(),
348        })
349    }
350
351    pub async fn validate_target_config(
352        &self, config: &StorageConfig,
353    ) -> DomainResult<ValidationReport> {
354        use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
355
356        let mut report = ValidationReport {
357            errors: Vec::new(),
358            warnings: Vec::new(),
359            recommendations: Vec::new(),
360        };
361
362        if config.backend == ConfigStorageBackend::Postgres {
363            if config.postgres.connection_string.is_empty() {
364                report.errors.push(
365                    "PostgreSQL connection string required when using postgres backend".into(),
366                );
367            }
368
369            let has_settings_password = config.vault_password.is_some();
370            let has_env_password = std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok();
371
372            if !has_settings_password && !has_env_password {
373                report.warnings.push(format!(
374                    "Vault password not provided. Set {} environment variable or provide password during setup.",
375                    VAULT_PASSWORD_ENV_VAR
376                ));
377            }
378        }
379
380        Ok(report)
381    }
382
383    pub async fn execute_migration(
384        &self, plan: MigrationPlan, options: MigrationOptions, is_desktop: bool,
385    ) -> MigrationResult {
386        let start_time = std::time::Instant::now();
387        let mut result = MigrationResult {
388            success: false,
389            steps_completed: Vec::new(),
390            errors: Vec::new(),
391            duration_ms: 0,
392            stats: MigrationStats::default(),
393            provider_id_mapping: std::collections::HashMap::new(),
394            backups: None,
395        };
396
397        if options.dry_run {
398            result.success = true;
399            result.duration_ms = start_time.elapsed().as_millis() as u64;
400            return result;
401        }
402
403        let backup_manager = BackupManager::new(plan.from.data_dir());
404        let backups = match backup_manager
405            .create_backups(&plan.from, &plan.to, Some(&*self.source_config_backend))
406            .await
407        {
408            Ok(backups) => backups,
409            Err(e) => {
410                result.errors.push(format!(
411                    "Failed to create backups: {}\n\n\
412                    WARNING: Migration aborted - backup creation is required for safe migration.\n\
413                    INFO: Your data is safe - no modifications were performed.\n\n\
414                    Please ensure:\n\
415                    1. Sufficient disk space (need ~2x source database size)\n\
416                    2. Write permissions to data directory\n\
417                    3. Source database is not locked by another process",
418                    e
419                ));
420                result.duration_ms = start_time.elapsed().as_millis() as u64;
421                return result;
422            }
423        };
424        result.backups = Some(backups.clone());
425
426        let target_manager = if let Some(token_store) = &self.target_token_store {
427            match StorageManager::with_token_store(plan.to.clone(), token_store.clone(), is_desktop)
428                .await
429            {
430                Ok(m) => m,
431                Err(e) => {
432                    result.errors.push(format!(
433                        "Failed to create target manager with token store: {}\n\n\
434                        INFO: Migration aborted before any changes were made.\n\
435                        INFO: Your data is safe - no modifications were performed.",
436                        e
437                    ));
438                    backup_manager.cleanup_backups(&backups, false).await.ok();
439                    result.duration_ms = start_time.elapsed().as_millis() as u64;
440                    return result;
441                }
442            }
443        } else {
444            match StorageManager::from_config(plan.to.clone(), false).await {
445                Ok(m) => m,
446                Err(e) => {
447                    result.errors.push(format!(
448                        "Failed to create target manager: {}\n\n\
449                        INFO: Migration aborted before any changes were made.\n\
450                        INFO: Your data is safe - no modifications were performed.",
451                        e
452                    ));
453                    backup_manager.cleanup_backups(&backups, false).await.ok();
454                    result.duration_ms = start_time.elapsed().as_millis() as u64;
455                    return result;
456                }
457            }
458        };
459
460        for (idx, step) in plan.steps.iter().enumerate() {
461            self.emit_progress(*step, idx, plan.steps.len()).await;
462
463            let step_result = self
464                .execute_step(*step, &plan, &options, &target_manager, &mut result)
465                .await;
466
467            match step_result {
468                Ok(()) => {
469                    result.steps_completed.push(*step);
470                }
471                Err(e) => {
472                    let step_name = format!("{:?}", step);
473
474                    tracing::error!(
475                        "Migration failed at step {} - restoring from backups",
476                        step_name
477                    );
478
479                    let recovery_instructions = match backup_manager.restore_backups(&backups).await
480                    {
481                        Ok(()) => {
482                            tracing::info!("Successfully restored from backups");
483                            format!(
484                                "✓ Your data has been restored from backups.\n\
485                                 ✓ Config file: {}\n\
486                                 ✓ Database: {}\n\n\
487                                 The migration failed but your original data is intact.\n\
488                                 Please review the error below and try again after fixing the issue:\n\n\
489                                 Error: {}",
490                                backups.source_config.display(),
491                                backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string()),
492                                e
493                            )
494                        }
495                        Err(restore_err) => {
496                            format!(
497                                "CRITICAL ERROR: Migration failed AND backup restoration failed!\n\n\
498                                 Original error: {}\n\
499                                 Restore error: {}\n\n\
500                                 MANUAL RECOVERY REQUIRED:\n\
501                                 1. Restore config file from: {}\n\
502                                 2. Restore database from: {}\n\
503                                 3. Review logs for details\n\
504                                 4. Contact support if needed",
505                                e,
506                                restore_err,
507                                backups.source_config.display(),
508                                backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string())
509                            )
510                        }
511                    };
512
513                    result.errors.push(format!(
514                        "Migration failed at step '{}':\n{}\n\n{}",
515                        step_name, e, recovery_instructions
516                    ));
517
518                    self.rollback(&result).await;
519                    result.duration_ms = start_time.elapsed().as_millis() as u64;
520                    return result;
521                }
522            }
523        }
524
525        result.success = true;
526        result.duration_ms = start_time.elapsed().as_millis() as u64;
527
528        if let Err(e) = backup_manager
529            .cleanup_backups(&backups, options.keep_backups)
530            .await
531        {
532            tracing::warn!("Failed to cleanup backups: {}", e);
533        }
534
535        result
536    }
537
538    async fn execute_step(
539        &self, step: MigrationStep, plan: &MigrationPlan, options: &MigrationOptions,
540        target_manager: &StorageManager, result: &mut MigrationResult,
541    ) -> DomainResult<()> {
542        match step {
543            MigrationStep::ValidateTarget => {
544                if !target_manager.cache_backend().is_available().await {
545                    return Err(DomainError::NetworkError(
546                        "Target cache backend not available".into(),
547                    ));
548                }
549
550                target_manager.config_backend().list_providers().await?;
551            }
552
553            MigrationStep::MigrateTokens => {
554                tracing::info!("[Migration] Starting token migration...");
555                let password = get_vault_password(options)?;
556                tracing::info!(
557                    "[Migration] Vault password retrieved: {} chars",
558                    password.len()
559                );
560
561                tracing::info!("[Migration] Exporting tokens from source token store...");
562                let encrypted_blob = self.source_token_store.export_encrypted(&password).await?;
563                tracing::info!(
564                    "[Migration] Exported encrypted blob: {} bytes",
565                    encrypted_blob.len()
566                );
567
568                if !result.provider_id_mapping.is_empty() {
569                    tracing::info!(
570                        "[Migration] Using provider ID remapping path ({} mappings)",
571                        result.provider_id_mapping.len()
572                    );
573
574                    let source_tokens = self.source_token_store.get_all_tokens().await?;
575                    tracing::info!(
576                        "[Migration] Retrieved {} tokens from source",
577                        source_tokens.len()
578                    );
579
580                    let mut migrated_count = 0;
581                    for (old_id, token) in source_tokens {
582                        if let Some(&new_id) = result.provider_id_mapping.get(&old_id) {
583                            tracing::debug!(
584                                "[Migration] Storing token for provider {} → {} (token length: {})",
585                                old_id,
586                                new_id,
587                                token.len()
588                            );
589                            target_manager
590                                .token_store()
591                                .await
592                                .store_token(new_id, &token)
593                                .await?;
594                            migrated_count += 1;
595                            tracing::info!(
596                                "[Migration] Successfully stored token for provider {} (remapped to {})",
597                                old_id,
598                                new_id
599                            );
600                        } else {
601                            let source_providers =
602                                self.source_config_backend.list_providers().await?;
603                            return Err(DomainError::DataConsistency(format!(
604                                "Cannot migrate token for provider ID {} - provider not found in remapping.\n\n\
605                                This indicates data corruption or inconsistency.\n\n\
606                                Source provider IDs: {:?}\n\
607                                Remapping table: {:?}\n\n\
608                                This token would be lost if migration continued.\n\
609                                Please investigate why this provider's configuration wasn't migrated.",
610                                old_id,
611                                source_providers.iter().filter_map(|p| p.id).collect::<Vec<_>>(),
612                                result.provider_id_mapping
613                            )));
614                        }
615                    }
616                    tracing::info!(
617                        "[Migration] Migrated {} tokens via ID remapping",
618                        migrated_count
619                    );
620                } else {
621                    tracing::info!("[Migration] Using direct import path (no ID remapping)");
622                    tracing::info!(
623                        "[Migration] Importing encrypted blob ({} bytes) to target token store...",
624                        encrypted_blob.len()
625                    );
626                    target_manager
627                        .token_store()
628                        .await
629                        .import_encrypted(&encrypted_blob, &password)
630                        .await?;
631                    tracing::info!("[Migration] Direct import completed");
632                }
633
634                let count: usize = target_manager
635                    .token_store()
636                    .await
637                    .get_all_tokens()
638                    .await?
639                    .len();
640                tracing::info!(
641                    "[Migration] Verification: {} tokens now in target database",
642                    count
643                );
644                result.stats.tokens_migrated = count;
645            }
646
647            MigrationStep::MigrateConfigs => {
648                tracing::info!(
649                    "[Migration] Exporting providers from source database: {:?}",
650                    self.source_config.db_path()
651                );
652                let export = self.source_config_backend.export_all().await?;
653                result.stats.providers_migrated = export.providers.len();
654                result.stats.permissions_migrated = export.permissions.len();
655
656                tracing::info!(
657                    "[Migration] Importing {} providers to target database: {:?}",
658                    export.providers.len(),
659                    plan.to.db_path()
660                );
661                target_manager.config_backend().import_all(&export).await?;
662
663                let source_providers = self.source_config_backend.list_providers().await?;
664                let target_providers: Vec<ProviderConfig> =
665                    target_manager.config_backend().list_providers().await?;
666
667                for source_provider in &source_providers {
668                    if let Some(source_id) = source_provider.id {
669                        if let Some(target_provider) = target_providers
670                            .iter()
671                            .find(|p| p.name == source_provider.name)
672                        {
673                            if let Some(target_id) = target_provider.id {
674                                result.provider_id_mapping.insert(source_id, target_id);
675                            }
676                        }
677                    }
678                }
679
680                if !same_database(&plan.from, &plan.to) {
681                    let mut metrics_config = self.source_metrics.export_config().await?;
682                    metrics_config.pipeline_configs.retain_mut(|config| {
683                        match remap_pipeline_id(&config.pipeline_id, &result.provider_id_mapping) {
684                            Some(pipeline_id) => {
685                                config.pipeline_id = pipeline_id;
686                                true
687                            }
688                            None => false,
689                        }
690                    });
691                    MetricsRepository::new_from_pool(target_manager.database_pool())
692                        .import_config(&metrics_config)
693                        .await?;
694                }
695
696                if !same_database(&plan.from, &plan.to) {
697                    let subscriptions =
698                        PushRepository::new(self.source_pool.clone()).list().await?;
699                    let target_push = PushRepository::new(target_manager.database_pool());
700                    target_push.import(&subscriptions).await?;
701                    if options.clean_target {
702                        for stale in target_push.list().await? {
703                            if !subscriptions.iter().any(|s| s.id == stale.id) {
704                                target_push.delete(&stale.id).await?;
705                            }
706                        }
707                    }
708                    tracing::info!(
709                        "[Migration] Migrated {} push subscriptions",
710                        subscriptions.len()
711                    );
712                }
713
714                if !same_database(&plan.from, &plan.to) {
715                    let mapping = &result.provider_id_mapping;
716                    let mut export = PipelineViewRepository::new(self.source_pool.clone())
717                        .export()
718                        .await?;
719                    let remap = |pipeline_id: &mut String, provider_id: &mut i64| match (
720                        remap_pipeline_id(pipeline_id, mapping),
721                        mapping.get(provider_id),
722                    ) {
723                        (Some(next), Some(target)) => {
724                            *pipeline_id = next;
725                            *provider_id = *target;
726                            true
727                        }
728                        _ => false,
729                    };
730                    export
731                        .members
732                        .retain_mut(|m| remap(&mut m.pipeline_id, &mut m.provider_id));
733                    for view in &mut export.views {
734                        view.rules.provider_ids = view
735                            .rules
736                            .provider_ids
737                            .iter()
738                            .filter_map(|id| mapping.get(id).copied())
739                            .collect();
740                    }
741                    PipelineViewRepository::new(target_manager.database_pool())
742                        .import(&export, options.clean_target)
743                        .await?;
744                    tracing::info!("[Migration] Migrated {} pipeline views", export.views.len());
745                }
746
747                if options.clean_target {
748                    let source_names: std::collections::HashSet<String> =
749                        source_providers.iter().map(|p| p.name.clone()).collect();
750
751                    let orphaned: Vec<_> = target_providers
752                        .iter()
753                        .filter(|p| !source_names.contains(&p.name))
754                        .collect();
755
756                    for orphan in &orphaned {
757                        if let Some(id) = orphan.id {
758                            tracing::info!(
759                                "[Migration] Removing orphaned provider: {} (id={})",
760                                orphan.name,
761                                id
762                            );
763                            target_manager.config_backend().delete_provider(id).await?;
764                            let _ = target_manager.token_store().await.delete_token(id).await;
765                            result.stats.tokens_cleaned += 1;
766                        }
767                    }
768
769                    if !orphaned.is_empty() {
770                        result.stats.providers_cleaned = orphaned.len();
771                        tracing::info!(
772                            "[Migration] Cleaned {} orphaned providers from target",
773                            orphaned.len()
774                        );
775                    }
776                }
777            }
778
779            MigrationStep::MigrateCache => {
780                let objects = self.source_cache_backend.list(None).await?;
781                tracing::info!(
782                    "[Migration] Migrating {} cache entries from {:?} to {:?}",
783                    objects.len(),
784                    self.source_config.data_dir().join("cache"),
785                    plan.to.data_dir().join("cache")
786                );
787                for obj in objects {
788                    let data = self.source_cache_backend.get(&obj.key).await?;
789                    target_manager
790                        .cache_backend()
791                        .put(&obj.key, &data, obj.content_type.as_deref())
792                        .await?;
793                    result.stats.cache_entries_migrated += 1;
794                }
795
796                if !same_database(&plan.from, &plan.to) {
797                    let target_metrics =
798                        MetricsRepository::new_from_pool(target_manager.database_pool());
799                    let pipeline_query = |pipeline_id: &str| MetricsQuery {
800                        pipeline_id: Some(pipeline_id.to_string()),
801                        limit: None,
802                        ..Default::default()
803                    };
804                    let mut pipelines = 0;
805                    for source_id in self.source_metrics.list_pipeline_ids().await? {
806                        let Some(target_id) =
807                            remap_pipeline_id(&source_id, &result.provider_id_mapping)
808                        else {
809                            continue;
810                        };
811                        let last_run = self
812                            .source_metrics
813                            .get_last_processed_run(&source_id)
814                            .await?;
815                        let mut runs: BTreeMap<i64, RecordMetrics> = BTreeMap::new();
816                        for entry in self
817                            .source_metrics
818                            .query_metrics(pipeline_query(&source_id))
819                            .await?
820                        {
821                            let slot = entry.metric_type as usize;
822                            let record =
823                                runs.entry(entry.run_number)
824                                    .or_insert_with(|| RecordMetrics {
825                                        run_number: entry.run_number,
826                                        run_hash: entry.run_hash.clone().unwrap_or_default(),
827                                        entries: [None, None, None],
828                                    });
829                            record.entries[slot] = Some(entry);
830                        }
831                        let records: Vec<_> = runs.into_values().collect();
832                        target_metrics
833                            .refresh_record_metrics(&target_id, &records)
834                            .await?;
835                        result.stats.metrics_migrated += records
836                            .iter()
837                            .map(|record| record.entries.iter().flatten().count())
838                            .sum::<usize>();
839                        pipelines += 1;
840
841                        let Some(run) = last_run else {
842                            continue;
843                        };
844                        if target_metrics
845                            .get_last_processed_run(&target_id)
846                            .await?
847                            .is_none_or(|target_run| target_run < run)
848                        {
849                            target_metrics
850                                .update_last_processed_run(&target_id, run)
851                                .await?;
852                        }
853                    }
854                    tracing::info!(
855                        "[Migration] Migrated {} metric entries for {} pipelines",
856                        result.stats.metrics_migrated,
857                        pipelines
858                    );
859                }
860            }
861
862            MigrationStep::VerifyMigration => {
863                let source_providers = self.source_config_backend.list_providers().await?;
864
865                if plan.migrate_configs && !source_providers.is_empty() {
866                    let target_providers: Vec<ProviderConfig> =
867                        target_manager.config_backend().list_providers().await?;
868
869                    for source_provider in &source_providers {
870                        let found = target_providers
871                            .iter()
872                            .any(|t| t.name == source_provider.name);
873                        if !found {
874                            return Err(DomainError::DatabaseError(format!(
875                                "Provider '{}' from source was not found in target after migration",
876                                source_provider.name
877                            )));
878                        }
879                    }
880
881                    if options.clean_target {
882                        if source_providers.len() != target_providers.len() {
883                            return Err(DomainError::DatabaseError(format!(
884                                "Provider count mismatch after cleanup: source={}, target={}",
885                                source_providers.len(),
886                                target_providers.len()
887                            )));
888                        }
889                        tracing::info!(
890                            "[Migration] Verified exact match: {} providers",
891                            source_providers.len()
892                        );
893                    } else if target_providers.len() > source_providers.len() {
894                        tracing::info!(
895                            "[Migration] Merge complete: {} migrated, {} pre-existing kept (total: {})",
896                            source_providers.len(),
897                            target_providers.len() - source_providers.len(),
898                            target_providers.len()
899                        );
900                    } else {
901                        tracing::info!(
902                            "[Migration] Verified {} providers migrated successfully",
903                            source_providers.len()
904                        );
905                    }
906                } else if plan.migrate_configs {
907                    tracing::info!("Skipping provider verification: source database is empty");
908                }
909
910                if plan.migrate_tokens {
911                    let source_tokens = self.source_token_store.get_all_tokens().await?;
912
913                    if !source_tokens.is_empty() {
914                        let target_tokens: HashMap<i64, String> =
915                            target_manager.token_store().await.get_all_tokens().await?;
916
917                        if options.clean_target {
918                            if source_tokens.len() != target_tokens.len() {
919                                return Err(DomainError::DatabaseError(format!(
920                                    "Token count mismatch after cleanup: source={}, target={}",
921                                    source_tokens.len(),
922                                    target_tokens.len()
923                                )));
924                            }
925                            tracing::info!(
926                                "[Migration] Verified exact match: {} tokens",
927                                source_tokens.len()
928                            );
929                        } else {
930                            if target_tokens.len() < source_tokens.len() {
931                                return Err(DomainError::DatabaseError(format!(
932                                    "Token count too low after migration: source={}, target={}",
933                                    source_tokens.len(),
934                                    target_tokens.len()
935                                )));
936                            }
937                            tracing::info!(
938                                "[Migration] Token migration complete: {} migrated (target has {} total)",
939                                source_tokens.len(),
940                                target_tokens.len()
941                            );
942                        }
943                    } else {
944                        tracing::info!("Skipping token verification: source has no tokens");
945                    }
946                }
947            }
948
949            MigrationStep::UpdateConfig => {
950                use crate::infrastructure::config::ConfigLoader;
951
952                let config_path = ConfigLoader::discover_config_path();
953
954                tracing::info!(
955                    "Saving migrated config to: {} (data_dir: {:?}, backend: {})",
956                    config_path.display(),
957                    plan.to.data_dir(),
958                    plan.to.storage.backend
959                );
960
961                ConfigLoader::save(&plan.to, &config_path).map_err(|e| {
962                    DomainError::DatabaseError(format!("Failed to save config: {}", e))
963                })?;
964            }
965        }
966
967        Ok(())
968    }
969
970    async fn emit_progress(&self, step: MigrationStep, step_index: usize, total_steps: usize) {
971        if let Some(ref event_bus) = self.event_bus {
972            event_bus
973                .emit(crate::event::CoreEvent::MigrationProgress {
974                    step: format!("{:?}", step),
975                    step_index,
976                    total_steps,
977                    message: step.description().to_string(),
978                })
979                .await;
980        }
981    }
982
983    async fn rollback(&self, _result: &MigrationResult) {
984        tracing::warn!("Migration failed. Manual intervention may be required.");
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use tempfile::TempDir;
991
992    use super::*;
993    use crate::{
994        infrastructure::token_store::MemoryTokenStore,
995        test_env::EnvGuard,
996    };
997
998    async fn create_test_manager(temp_dir: &TempDir) -> StorageManager {
999        let mut config = PipedashConfig::default();
1000        config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
1001
1002        let token_store: Arc<dyn TokenStore> = Arc::new(MemoryTokenStore::new());
1003        StorageManager::with_token_store(config, token_store, true)
1004            .await
1005            .unwrap()
1006    }
1007
1008    #[tokio::test]
1009    async fn test_plan_migration_no_changes() {
1010        let temp_dir = TempDir::new().unwrap();
1011        let manager = create_test_manager(&temp_dir).await;
1012
1013        let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1014
1015        let target_config = manager.config().clone();
1016        let plan = orchestrator
1017            .plan_migration(target_config, &MigrationOptions::default())
1018            .unwrap();
1019
1020        assert!(!plan.steps.contains(&MigrationStep::MigrateConfigs));
1021        assert!(!plan.steps.contains(&MigrationStep::MigrateTokens));
1022    }
1023
1024    #[tokio::test]
1025    async fn test_migration_dry_run() {
1026        let temp_dir = TempDir::new().unwrap();
1027        let manager = create_test_manager(&temp_dir).await;
1028
1029        let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1030
1031        let target_config = manager.config().clone();
1032        let plan = MigrationPlan {
1033            from: manager.config().clone(),
1034            to: target_config,
1035            steps: vec![MigrationStep::ValidateTarget],
1036            migrate_tokens: false,
1037            migrate_configs: false,
1038            migrate_cache: false,
1039            backend_changed: false,
1040            data_dir_changed: false,
1041            created_at: Utc::now(),
1042        };
1043
1044        let result = orchestrator
1045            .execute_migration(
1046                plan,
1047                MigrationOptions {
1048                    dry_run: true,
1049                    ..Default::default()
1050                },
1051                true, // is_desktop
1052            )
1053            .await;
1054
1055        assert!(result.success);
1056    }
1057
1058    #[test]
1059    fn test_migration_step_descriptions() {
1060        assert!(!MigrationStep::ValidateTarget.description().is_empty());
1061        assert!(!MigrationStep::MigrateTokens.description().is_empty());
1062        assert!(!MigrationStep::MigrateConfigs.description().is_empty());
1063    }
1064
1065    #[test]
1066    fn test_migration_result_summary() {
1067        let result = MigrationResult {
1068            success: true,
1069            steps_completed: vec![MigrationStep::MigrateConfigs],
1070            errors: vec![],
1071            duration_ms: 100,
1072            stats: MigrationStats {
1073                providers_migrated: 5,
1074                tokens_migrated: 3,
1075                cache_entries_migrated: 10,
1076                metrics_migrated: 0,
1077                permissions_migrated: 2,
1078                providers_cleaned: 0,
1079                tokens_cleaned: 0,
1080            },
1081            provider_id_mapping: std::collections::HashMap::new(),
1082            backups: None,
1083        };
1084
1085        let summary = result.summary();
1086        assert!(summary.contains("5 providers"));
1087        assert!(summary.contains("10 cache entries"));
1088    }
1089
1090    #[tokio::test]
1091    async fn migrate_tokens_after_a_provider_was_deleted() {
1092        let source_dir = TempDir::new().unwrap();
1093        let target_dir = TempDir::new().unwrap();
1094        let config_path = source_dir.path().join("config.toml");
1095        std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1096        let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1097
1098        let mut source = PipedashConfig::default();
1099        source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1100        source.storage.vault_password = Some("test-password".to_string());
1101        let mut target = source.clone();
1102        target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1103
1104        let manager = StorageManager::from_config(source, false).await.unwrap();
1105        let backend = manager.config_backend();
1106        let tokens = manager.token_store().await;
1107        let mut ids = Vec::new();
1108        for name in ["a", "b", "c"] {
1109            let provider = ProviderConfig {
1110                id: None,
1111                name: name.to_string(),
1112                provider_type: "github".to_string(),
1113                token: String::new(),
1114                config: HashMap::new(),
1115                refresh_interval: 30,
1116                version: None,
1117            };
1118            ids.push(backend.create_provider(&provider).await.unwrap());
1119        }
1120        backend.delete_provider(ids[1]).await.unwrap();
1121        tokens.store_token(ids[0], "tok-a").await.unwrap();
1122        tokens.store_token(ids[2], "tok-c").await.unwrap();
1123
1124        let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1125        let options = MigrationOptions {
1126            migrate_tokens: true,
1127            token_password: Some("test-password".to_string()),
1128            ..Default::default()
1129        };
1130        let plan = orchestrator
1131            .plan_migration(target.clone(), &options)
1132            .unwrap();
1133        let result = orchestrator.execute_migration(plan, options, false).await;
1134        assert!(result.success, "{:?}", result.errors);
1135
1136        let target_manager = StorageManager::from_config(target, false).await.unwrap();
1137        let mut migrated = HashMap::new();
1138        for provider in target_manager
1139            .config_backend()
1140            .list_providers()
1141            .await
1142            .unwrap()
1143        {
1144            let token = target_manager
1145                .token_store()
1146                .await
1147                .get_token(provider.id.unwrap())
1148                .await
1149                .unwrap();
1150            migrated.insert(provider.name, token);
1151        }
1152        assert_eq!(
1153            migrated,
1154            HashMap::from([
1155                ("a".to_string(), "tok-a".to_string()),
1156                ("c".to_string(), "tok-c".to_string()),
1157            ])
1158        );
1159    }
1160
1161    #[tokio::test]
1162    async fn migration_keeps_pipeline_metrics() {
1163        use crate::domain::{
1164            MetricEntry,
1165            MetricType,
1166        };
1167        use crate::infrastructure::database::RecordMetrics;
1168
1169        let source_dir = TempDir::new().unwrap();
1170        let target_dir = TempDir::new().unwrap();
1171        let config_path = source_dir.path().join("config.toml");
1172        std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1173        let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1174
1175        let mut source = PipedashConfig::default();
1176        source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1177        source.storage.vault_password = Some("test-password".to_string());
1178        let mut target = source.clone();
1179        target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1180
1181        let manager = StorageManager::from_config(source, false).await.unwrap();
1182        let backend = manager.config_backend();
1183        let mut ids = Vec::new();
1184        for name in ["deleted", "gh"] {
1185            let provider = ProviderConfig {
1186                id: None,
1187                name: name.to_string(),
1188                provider_type: "github".to_string(),
1189                token: String::new(),
1190                config: HashMap::new(),
1191                refresh_interval: 30,
1192                version: None,
1193            };
1194            ids.push(backend.create_provider(&provider).await.unwrap());
1195        }
1196        backend.delete_provider(ids[0]).await.unwrap();
1197        let orphan_pipeline = format!("github__{}__o__r__1", ids[0]);
1198        let source_pipeline = format!("github__{}__o__r__1", ids[1]);
1199        let expired_pipeline = format!("github__{}__o__r__2", ids[1]);
1200
1201        let metrics = MetricsRepository::new_from_pool(manager.database_pool());
1202        metrics.update_global_config(true, 30).await.unwrap();
1203        metrics
1204            .upsert_pipeline_config(&source_pipeline, true, 14)
1205            .await
1206            .unwrap();
1207        metrics
1208            .upsert_pipeline_config(&orphan_pipeline, false, 3)
1209            .await
1210            .unwrap();
1211        let entry = |pipeline_id: &str, n: i64| MetricEntry {
1212            id: 0,
1213            pipeline_id: pipeline_id.to_string(),
1214            run_number: n,
1215            timestamp: Utc::now(),
1216            metric_type: MetricType::RunDuration,
1217            value: 60.0 * n as f64,
1218            metadata: None,
1219            created_at: Utc::now(),
1220            run_hash: Some(format!("hash-{n}")),
1221        };
1222        let mut entries: Vec<MetricEntry> = (1..=5).map(|n| entry(&source_pipeline, n)).collect();
1223        entries.push(entry(&orphan_pipeline, 1));
1224        metrics.insert_metrics_batch(entries).await.unwrap();
1225        metrics
1226            .update_last_processed_run(&source_pipeline, 5)
1227            .await
1228            .unwrap();
1229        metrics
1230            .update_last_processed_run(&expired_pipeline, 9)
1231            .await
1232            .unwrap();
1233
1234        let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1235        let options = MigrationOptions {
1236            migrate_tokens: true,
1237            migrate_cache: true,
1238            token_password: Some("test-password".to_string()),
1239            ..Default::default()
1240        };
1241        let plan = orchestrator
1242            .plan_migration(target.clone(), &options)
1243            .unwrap();
1244        let migrate = || orchestrator.execute_migration(plan.clone(), options.clone(), false);
1245        let result = migrate().await;
1246        assert!(result.success, "{:?}", result.errors);
1247        assert_eq!(result.stats.metrics_migrated, 5);
1248        let refreshed = MetricEntry {
1249            run_hash: Some("hash-4b".to_string()),
1250            value: 999.0,
1251            ..entry(&source_pipeline, 4)
1252        };
1253        metrics
1254            .refresh_record_metrics(
1255                &source_pipeline,
1256                &[RecordMetrics {
1257                    run_number: 4,
1258                    run_hash: "hash-4b".to_string(),
1259                    entries: [Some(refreshed), None, None],
1260                }],
1261            )
1262            .await
1263            .unwrap();
1264        let result = migrate().await;
1265        assert!(result.success, "{:?}", result.errors);
1266
1267        let target_manager = StorageManager::from_config(target, false).await.unwrap();
1268        let target_id = target_manager
1269            .config_backend()
1270            .list_providers()
1271            .await
1272            .unwrap()[0]
1273            .id
1274            .unwrap();
1275        assert_ne!(target_id, ids[1]);
1276        let target_pipeline = format!("github__{target_id}__o__r__1");
1277        let target_metrics = MetricsRepository::new_from_pool(target_manager.database_pool());
1278
1279        let copied = target_metrics
1280            .query_metrics(MetricsQuery {
1281                pipeline_id: Some(target_pipeline.clone()),
1282                ..Default::default()
1283            })
1284            .await
1285            .unwrap();
1286        assert_eq!(copied.len(), 5);
1287        let run_4: Vec<_> = copied
1288            .into_iter()
1289            .filter(|entry| entry.run_number == 4)
1290            .map(|entry| (entry.run_hash, entry.value))
1291            .collect();
1292        assert_eq!(run_4, [(Some("hash-4b".to_string()), 999.0)]);
1293        let config = target_metrics
1294            .get_pipeline_config(&target_pipeline)
1295            .await
1296            .unwrap()
1297            .unwrap();
1298        assert!(config.enabled);
1299        assert_eq!(config.retention_days, 14);
1300        let global = target_metrics.get_global_config().await.unwrap();
1301        assert!(global.enabled);
1302        assert_eq!(global.default_retention_days, 30);
1303        assert_eq!(
1304            target_metrics
1305                .get_last_processed_run(&target_pipeline)
1306                .await
1307                .unwrap(),
1308            Some(5)
1309        );
1310        assert_eq!(
1311            target_metrics
1312                .get_last_processed_run(&format!("github__{target_id}__o__r__2"))
1313                .await
1314                .unwrap(),
1315            Some(9)
1316        );
1317        assert_eq!(
1318            target_metrics
1319                .export_config()
1320                .await
1321                .unwrap()
1322                .pipeline_configs
1323                .len(),
1324            1
1325        );
1326        assert_eq!(
1327            target_metrics
1328                .calculate_storage_stats()
1329                .await
1330                .unwrap()
1331                .total_metrics_count,
1332            5
1333        );
1334    }
1335
1336    #[tokio::test]
1337    async fn push_subscriptions_follow_a_storage_migration_without_the_cache() {
1338        let source_dir = TempDir::new().unwrap();
1339        let target_dir = TempDir::new().unwrap();
1340        let config_path = source_dir.path().join("config.toml");
1341        std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1342        let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1343
1344        let mut source = PipedashConfig::default();
1345        source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1346        source.storage.vault_password = Some("test-password".to_string());
1347        let mut target = source.clone();
1348        target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1349
1350        let manager = StorageManager::from_config(source, false).await.unwrap();
1351        let push = PushRepository::new(manager.database_pool());
1352        push.register("relay-a", "iPhone", "ios").await.unwrap();
1353        let paired = push.register("relay-b", "iPad", "ios").await.unwrap();
1354        push.mark_success(&paired.id).await.unwrap();
1355        let expected = push.list().await.unwrap();
1356
1357        let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1358        let options = MigrationOptions {
1359            migrate_tokens: false,
1360            migrate_cache: false,
1361            ..Default::default()
1362        };
1363        let plan = orchestrator
1364            .plan_migration(target.clone(), &options)
1365            .unwrap();
1366        let result = orchestrator.execute_migration(plan, options, false).await;
1367        assert!(result.success, "{:?}", result.errors);
1368
1369        let target_manager = StorageManager::from_config(target, false).await.unwrap();
1370        let migrated = PushRepository::new(target_manager.database_pool())
1371            .list()
1372            .await
1373            .unwrap();
1374        assert_eq!(migrated, expected);
1375    }
1376
1377    #[tokio::test]
1378    async fn views_and_pins_survive_a_restart_and_a_storage_migration() {
1379        use crate::domain::{
1380            PinKind,
1381            SectionMode,
1382            ViewDisplay,
1383            ViewKind,
1384            ViewLayout,
1385            ViewRules,
1386        };
1387        use crate::infrastructure::database::ViewExport;
1388
1389        let source_dir = TempDir::new().unwrap();
1390        let target_dir = TempDir::new().unwrap();
1391        let config_path = source_dir.path().join("config.toml");
1392        std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1393        let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1394
1395        let mut source = PipedashConfig::default();
1396        source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1397        source.storage.vault_password = Some("test-password".to_string());
1398        let mut target = source.clone();
1399        target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1400
1401        let manager = StorageManager::from_config(source.clone(), false)
1402            .await
1403            .unwrap();
1404        let backend = manager.config_backend();
1405        let mut ids = Vec::new();
1406        for name in ["deleted", "gh"] {
1407            let provider = ProviderConfig {
1408                id: None,
1409                name: name.to_string(),
1410                provider_type: "github".to_string(),
1411                token: String::new(),
1412                config: HashMap::new(),
1413                refresh_interval: 30,
1414                version: None,
1415            };
1416            ids.push(backend.create_provider(&provider).await.unwrap());
1417        }
1418        backend.delete_provider(ids[0]).await.unwrap();
1419        let provider = ids[1];
1420        let pipeline = |n: u32| format!("github__{provider}__o__r__{n}");
1421
1422        let repo = PipelineViewRepository::new(manager.database_pool());
1423        let rules = ViewRules {
1424            provider_ids: vec![provider],
1425            repositories: vec!["o/*".to_string()],
1426            name_contains: Some("deploy".to_string()),
1427            statuses: Vec::new(),
1428        };
1429        let cards = ViewDisplay {
1430            layout: ViewLayout::Cards,
1431            sections: SectionMode::Provider,
1432            ..ViewDisplay::default()
1433        };
1434        let release = repo
1435            .insert_custom("Release", None, &rules, &cards)
1436            .await
1437            .unwrap();
1438        let prod = repo
1439            .insert_custom(
1440                "Prod",
1441                Some(release),
1442                &ViewRules::default(),
1443                &ViewDisplay::default(),
1444            )
1445            .await
1446            .unwrap();
1447        let eu = repo
1448            .insert_custom(
1449                "EU",
1450                Some(prod),
1451                &ViewRules::default(),
1452                &ViewDisplay::default(),
1453            )
1454            .await
1455            .unwrap();
1456        repo.upsert_member(eu, provider, &pipeline(1), false)
1457            .await
1458            .unwrap();
1459        repo.upsert_member(release, provider, &pipeline(2), true)
1460            .await
1461            .unwrap();
1462        let pinned = repo
1463            .list_views()
1464            .await
1465            .unwrap()
1466            .into_iter()
1467            .find(|v| v.kind == ViewKind::Pinned)
1468            .unwrap();
1469        repo.update_view(
1470            pinned.id,
1471            &pinned.name,
1472            None,
1473            &pinned.rules,
1474            &ViewDisplay {
1475                layout: ViewLayout::Table,
1476                ..pinned.display
1477            },
1478        )
1479        .await
1480        .unwrap();
1481        repo.insert_pin(PinKind::View, &eu.to_string())
1482            .await
1483            .unwrap();
1484        repo.insert_pin(PinKind::Repository, "o/r").await.unwrap();
1485        repo.insert_pin(PinKind::View, &release.to_string())
1486            .await
1487            .unwrap();
1488
1489        let snapshot = |export: ViewExport, provider: i64| {
1490            let pid = |id: &str| id.replace(&format!("__{provider}__"), "__P__");
1491            let view_name = |id: i64| {
1492                export
1493                    .views
1494                    .iter()
1495                    .find(|v| v.id == id)
1496                    .unwrap()
1497                    .name
1498                    .clone()
1499            };
1500            let views: Vec<_> = export
1501                .views
1502                .iter()
1503                .map(|v| {
1504                    let mut rules = v.rules.clone();
1505                    rules.provider_ids = rules
1506                        .provider_ids
1507                        .iter()
1508                        .map(|id| i64::from(*id == provider))
1509                        .collect();
1510                    (
1511                        v.kind,
1512                        v.name.clone(),
1513                        v.parent_id.map(view_name),
1514                        v.position,
1515                        rules,
1516                        v.display,
1517                    )
1518                })
1519                .collect();
1520            let members: Vec<_> = export
1521                .members
1522                .iter()
1523                .map(|m| {
1524                    (
1525                        view_name(m.view_id),
1526                        pid(&m.pipeline_id),
1527                        m.excluded,
1528                        m.provider_id == provider,
1529                    )
1530                })
1531                .collect();
1532            let pins: Vec<_> = export
1533                .pins
1534                .iter()
1535                .map(|p| match p.kind {
1536                    PinKind::View => view_name(p.reference.parse().unwrap()),
1537                    PinKind::Repository => p.reference.clone(),
1538                })
1539                .collect();
1540            format!("{views:?}\n{members:?}\n{pins:?}")
1541        };
1542        let before = snapshot(repo.export().await.unwrap(), provider);
1543        drop(repo);
1544        drop(manager);
1545
1546        let manager = StorageManager::from_config(source, false).await.unwrap();
1547        let reopened = PipelineViewRepository::new(manager.database_pool())
1548            .export()
1549            .await
1550            .unwrap();
1551        assert_eq!(snapshot(reopened, provider), before);
1552
1553        let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1554        let options = MigrationOptions {
1555            migrate_tokens: false,
1556            migrate_cache: false,
1557            ..Default::default()
1558        };
1559        let plan = orchestrator
1560            .plan_migration(target.clone(), &options)
1561            .unwrap();
1562        let result = orchestrator.execute_migration(plan, options, false).await;
1563        assert!(result.success, "{:?}", result.errors);
1564
1565        let target_manager = StorageManager::from_config(target, false).await.unwrap();
1566        let target_provider = target_manager
1567            .config_backend()
1568            .list_providers()
1569            .await
1570            .unwrap()[0]
1571            .id
1572            .unwrap();
1573        assert_ne!(target_provider, provider);
1574        let migrated = PipelineViewRepository::new(target_manager.database_pool())
1575            .export()
1576            .await
1577            .unwrap();
1578        assert_eq!(snapshot(migrated, target_provider), before);
1579    }
1580}