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