Skip to main content

pipedash_core/infrastructure/database/
repository.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use chrono::{
6    DateTime,
7    Utc,
8};
9use sqlx::postgres::PgPool;
10use sqlx::{
11    Row as SqlxRow,
12    SqlitePool,
13};
14use tokio::time::sleep;
15
16use crate::domain::{
17    DomainError,
18    DomainResult,
19    Pipeline,
20    PipelineRun,
21    PipelineStatus,
22    ProviderConfig,
23};
24use crate::infrastructure::deduplication::hash_pipeline_run;
25use crate::infrastructure::{
26    ConfigBackend,
27    TokenStore,
28};
29
30#[derive(Clone)]
31pub enum DatabasePool {
32    Sqlite(SqlitePool),
33    Postgres(PgPool),
34}
35
36impl DatabasePool {
37    pub fn is_postgres(&self) -> bool {
38        matches!(self, DatabasePool::Postgres(_))
39    }
40
41    pub fn as_sqlite(&self) -> Option<&SqlitePool> {
42        match self {
43            DatabasePool::Sqlite(pool) => Some(pool),
44            _ => None,
45        }
46    }
47
48    pub fn as_postgres(&self) -> Option<&PgPool> {
49        match self {
50            DatabasePool::Postgres(pool) => Some(pool),
51            _ => None,
52        }
53    }
54}
55
56const FETCH_STATUS_SUCCESS: &str = "success";
57const FETCH_STATUS_ERROR: &str = "error";
58const FETCH_STATUS_NEVER: &str = "never";
59
60async fn retry_on_busy<F, Fut, T>(operation: F) -> DomainResult<T>
61where
62    F: Fn() -> Fut,
63    Fut: std::future::Future<Output = DomainResult<T>>,
64{
65    const MAX_RETRIES: u32 = 3; // Reduced from 10 to 3 to prevent 5-minute
66                                // delays
67    const INITIAL_DELAY_MS: u64 = 10;
68
69    let mut attempt = 0;
70    loop {
71        match operation().await {
72            Ok(result) => {
73                if attempt > 0 {
74                    tracing::warn!(
75                        "SQLite operation succeeded after {} retries ({}ms total)",
76                        attempt,
77                        INITIAL_DELAY_MS * (2_u64.pow(attempt) - 1)
78                    );
79                }
80                return Ok(result);
81            }
82            Err(DomainError::DatabaseError(ref msg))
83                if (msg.contains("database is locked")
84                    || msg.contains("SQLITE_BUSY")
85                    || msg.contains("locked"))
86                    && attempt < MAX_RETRIES =>
87            {
88                attempt += 1;
89                let delay = INITIAL_DELAY_MS * 2_u64.pow(attempt - 1);
90
91                if attempt > 3 {
92                    tracing::warn!(
93                        "SQLite busy, retry {}/{} (waiting {}ms)",
94                        attempt,
95                        MAX_RETRIES,
96                        delay
97                    );
98                }
99
100                sleep(Duration::from_millis(delay)).await;
101            }
102            Err(e) => {
103                if attempt > 0 {
104                    tracing::error!("SQLite operation failed after {} retries: {}", attempt, e);
105                }
106                return Err(e);
107            }
108        }
109    }
110}
111
112pub struct Repository {
113    config_backend: Arc<dyn ConfigBackend>,
114    cache_pool: DatabasePool,
115    token_store: Arc<dyn TokenStore>,
116}
117
118impl Repository {
119    pub fn new(
120        config_backend: Arc<dyn ConfigBackend>, cache_pool: DatabasePool,
121        token_store: Arc<dyn TokenStore>,
122    ) -> Self {
123        Self {
124            config_backend,
125            cache_pool,
126            token_store,
127        }
128    }
129
130    pub fn cache_pool(&self) -> &DatabasePool {
131        &self.cache_pool
132    }
133
134    pub fn config_backend(&self) -> &Arc<dyn ConfigBackend> {
135        &self.config_backend
136    }
137
138    pub fn pool(&self) -> &SqlitePool {
139        self.cache_pool
140            .as_sqlite()
141            .expect("Expected SQLite pool but found PostgreSQL")
142    }
143
144    #[allow(dead_code)]
145    pub(crate) fn datetime_now(&self) -> &'static str {
146        match self.cache_pool {
147            DatabasePool::Sqlite(_) => "datetime('now')",
148            DatabasePool::Postgres(_) => "NOW()",
149        }
150    }
151
152    fn placeholder(&self, index: usize) -> String {
153        match self.cache_pool {
154            DatabasePool::Sqlite(_) => "?".to_string(),
155            DatabasePool::Postgres(_) => format!("${}", index),
156        }
157    }
158
159    #[allow(dead_code)]
160    fn build_batch_run_history_upsert(&self, batch_size: usize) -> String {
161        let columns: [&str; 5] = [
162            "pipeline_id",
163            "run_number",
164            "run_data",
165            "fetched_at",
166            "run_hash",
167        ];
168        let params_per_row = 4; // pipeline_id, run_number, run_data, run_hash
169                                // (fetched_at is datetime)
170
171        match self.cache_pool {
172            DatabasePool::Sqlite(_) => {
173                let mut param_idx = 0;
174                let values_clauses: Vec<String> = (0..batch_size)
175                    .map(|_| {
176                        let clause = format!(
177                            "({}, {}, {}, datetime('now'), {})",
178                            self.placeholder(param_idx + 1),
179                            self.placeholder(param_idx + 2),
180                            self.placeholder(param_idx + 3),
181                            self.placeholder(param_idx + 4)
182                        );
183                        param_idx += params_per_row;
184                        clause
185                    })
186                    .collect();
187
188                format!(
189                    "INSERT OR REPLACE INTO run_history_cache ({}) VALUES {}",
190                    columns.join(", "),
191                    values_clauses.join(", ")
192                )
193            }
194            DatabasePool::Postgres(_) => {
195                let mut param_idx = 0;
196                let values_clauses: Vec<String> = (0..batch_size)
197                    .map(|_| {
198                        let clause = format!(
199                            "({}, {}, {}, NOW(), {})",
200                            self.placeholder(param_idx + 1),
201                            self.placeholder(param_idx + 2),
202                            self.placeholder(param_idx + 3),
203                            self.placeholder(param_idx + 4)
204                        );
205                        param_idx += params_per_row;
206                        clause
207                    })
208                    .collect();
209
210                format!(
211                    "INSERT INTO run_history_cache ({}) VALUES {} ON CONFLICT (pipeline_id, run_number) DO UPDATE SET run_data = EXCLUDED.run_data, fetched_at = EXCLUDED.fetched_at, run_hash = EXCLUDED.run_hash",
212                    columns.join(", "),
213                    values_clauses.join(", ")
214                )
215            }
216        }
217    }
218
219    pub async fn add_provider(&self, config: &ProviderConfig) -> DomainResult<i64> {
220        let provider_id = self.config_backend.create_provider(config).await?;
221
222        self.token_store
223            .store_token(provider_id, &config.token)
224            .await
225            .map_err(|e| {
226                tracing::error!(
227                    provider_id = provider_id,
228                    error = %e,
229                    "Failed to store provider token - provider may not work correctly"
230                );
231                e
232            })?;
233
234        tracing::debug!(
235            provider_id = provider_id,
236            "Provider created and token stored successfully"
237        );
238
239        Ok(provider_id)
240    }
241
242    pub async fn get_provider(&self, id: i64) -> DomainResult<ProviderConfig> {
243        let mut provider = self
244            .config_backend
245            .get_provider(id)
246            .await?
247            .ok_or_else(|| DomainError::ProviderNotFound(id.to_string()))?;
248
249        provider.token = self.token_store.get_token(id).await?;
250
251        Ok(provider)
252    }
253
254    pub async fn list_providers(&self) -> DomainResult<Vec<ProviderConfig>> {
255        let mut providers = self.config_backend.list_providers().await?;
256
257        for provider in &mut providers {
258            if let Some(id) = provider.id {
259                provider.token = self.token_store.get_token(id).await.unwrap_or_default();
260            }
261        }
262
263        Ok(providers)
264    }
265
266    pub async fn update_provider(&self, id: i64, config: &ProviderConfig) -> DomainResult<()> {
267        self.config_backend.update_provider(id, config).await?;
268
269        self.token_store.store_token(id, &config.token).await?;
270
271        Ok(())
272    }
273
274    pub async fn update_provider_with_version(
275        &self, id: i64, config: &ProviderConfig, expected_version: i64,
276    ) -> DomainResult<bool> {
277        let cache_pool = self.cache_pool.clone();
278        let config_clone = config.clone();
279        let token_clone = config.token.clone();
280        let token_store = self.token_store.clone();
281
282        retry_on_busy(|| async {
283            let config_json = serde_json::to_string(&config_clone.config)
284                .map_err(|e| DomainError::DatabaseError(format!("Failed to serialize config: {}", e)))?;
285
286            let rows_affected = match &cache_pool {
287                DatabasePool::Sqlite(p) => {
288                    sqlx::query(
289                        r#"UPDATE providers
290                           SET name = ?, provider_type = ?, config_json = ?, refresh_interval = ?,
291                               version = version + 1, updated_at = datetime('now')
292                           WHERE id = ? AND version = ?"#
293                    )
294                    .bind(&config_clone.name)
295                    .bind(&config_clone.provider_type)
296                    .bind(&config_json)
297                    .bind(config_clone.refresh_interval)
298                    .bind(id)
299                    .bind(expected_version)
300                    .execute(p)
301                    .await
302                    .map_err(|e| DomainError::DatabaseError(format!("Failed to update provider: {}", e)))?
303                    .rows_affected()
304                }
305                DatabasePool::Postgres(p) => {
306                    sqlx::query(
307                        r#"UPDATE providers
308                           SET name = $1, provider_type = $2, config_json = $3, refresh_interval = $4,
309                               version = version + 1, updated_at = NOW()
310                           WHERE id = $5 AND version = $6"#
311                    )
312                    .bind(&config_clone.name)
313                    .bind(&config_clone.provider_type)
314                    .bind(&config_json)
315                    .bind(config_clone.refresh_interval)
316                    .bind(id)
317                    .bind(expected_version)
318                    .execute(p)
319                    .await
320                    .map_err(|e| DomainError::DatabaseError(format!("Failed to update provider: {}", e)))?
321                    .rows_affected()
322                }
323            };
324
325            let success = rows_affected > 0;
326
327            if success {
328                token_store.store_token(id, &token_clone).await?;
329            }
330
331            Ok(success)
332        }).await
333    }
334
335    pub async fn remove_provider(&self, id: i64) -> DomainResult<()> {
336        let pipelines = self
337            .get_cached_pipelines(Some(id))
338            .await
339            .unwrap_or_default();
340        let pipeline_ids: Vec<String> = pipelines.iter().map(|p| p.id.clone()).collect();
341
342        let run_history_sql = format!(
343            "DELETE FROM run_history_cache WHERE pipeline_id IN (SELECT id FROM pipelines_cache WHERE provider_id = {})",
344            self.placeholder(1)
345        );
346        match &self.cache_pool {
347            DatabasePool::Sqlite(p) => {
348                let _ = sqlx::query(&run_history_sql)
349                    .bind(id)
350                    .execute(p)
351                    .await
352                    .map_err(|e| DomainError::DatabaseError(e.to_string()));
353            }
354            DatabasePool::Postgres(p) => {
355                let _ = sqlx::query(&run_history_sql)
356                    .bind(id)
357                    .execute(p)
358                    .await
359                    .map_err(|e| DomainError::DatabaseError(e.to_string()));
360            }
361        }
362
363        for pipeline_id in pipeline_ids {
364            let workflow_params_sql = format!(
365                "DELETE FROM workflow_parameters_cache WHERE workflow_id LIKE {}",
366                self.placeholder(1)
367            );
368            match &self.cache_pool {
369                DatabasePool::Sqlite(p) => {
370                    let _ = sqlx::query(&workflow_params_sql)
371                        .bind(format!("{}%", pipeline_id))
372                        .execute(p)
373                        .await
374                        .map_err(|e| DomainError::DatabaseError(e.to_string()));
375                }
376                DatabasePool::Postgres(p) => {
377                    let _ = sqlx::query(&workflow_params_sql)
378                        .bind(format!("{}%", pipeline_id))
379                        .execute(p)
380                        .await
381                        .map_err(|e| DomainError::DatabaseError(e.to_string()));
382                }
383            }
384        }
385
386        let pipelines_sql = format!(
387            "DELETE FROM pipelines_cache WHERE provider_id = {}",
388            self.placeholder(1)
389        );
390        match &self.cache_pool {
391            DatabasePool::Sqlite(p) => {
392                let _ = sqlx::query(&pipelines_sql)
393                    .bind(id)
394                    .execute(p)
395                    .await
396                    .map_err(|e| DomainError::DatabaseError(e.to_string()));
397            }
398            DatabasePool::Postgres(p) => {
399                let _ = sqlx::query(&pipelines_sql)
400                    .bind(id)
401                    .execute(p)
402                    .await
403                    .map_err(|e| DomainError::DatabaseError(e.to_string()));
404            }
405        }
406
407        self.config_backend.delete_provider(id).await?;
408
409        let token_store = self.token_store.clone();
410        tokio::spawn(async move {
411            if let Err(e) = token_store.delete_token(id).await {
412                tracing::warn!(
413                    provider_id = id,
414                    error = %e,
415                    "Failed to delete provider token in background - token may persist in keyring"
416                );
417            } else {
418                tracing::debug!(
419                    provider_id = id,
420                    "Provider token deleted successfully in background"
421                );
422            }
423        });
424
425        Ok(())
426    }
427
428    pub async fn update_provider_fetch_status(
429        &self, provider_id: i64, success: bool, error: Option<String>,
430    ) -> DomainResult<bool> {
431        let cache_pool = self.cache_pool.clone();
432        let error_clone = error.clone();
433
434        retry_on_busy(|| async {
435            let now = chrono::Utc::now();
436            let new_status = if success {
437                FETCH_STATUS_SUCCESS
438            } else {
439                FETCH_STATUS_ERROR
440            };
441
442            let select_sql = match cache_pool {
443                DatabasePool::Sqlite(_) => {
444                    "SELECT last_fetch_status, last_fetch_error FROM providers WHERE id = ?".to_string()
445                }
446                DatabasePool::Postgres(_) => {
447                    "SELECT last_fetch_status, last_fetch_error FROM providers WHERE id = $1".to_string()
448                }
449            };
450
451            let status_changed = match &cache_pool {
452                DatabasePool::Sqlite(p) => {
453                    let current = sqlx::query(&select_sql)
454                        .bind(provider_id)
455                        .fetch_optional(p)
456                        .await
457                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
458
459                    if let Some(row) = current {
460                        let current_status: String =
461                            row.try_get("last_fetch_status").unwrap_or_default();
462                        let current_error: Option<String> =
463                            row.try_get("last_fetch_error").ok().flatten();
464                        current_status != new_status || current_error != error_clone
465                    } else {
466                        true
467                    }
468                }
469                DatabasePool::Postgres(p) => {
470                    let current = sqlx::query(&select_sql)
471                        .bind(provider_id)
472                        .fetch_optional(p)
473                        .await
474                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
475
476                    if let Some(row) = current {
477                        let current_status: String =
478                            row.try_get("last_fetch_status").unwrap_or_default();
479                        let current_error: Option<String> =
480                            row.try_get("last_fetch_error").ok().flatten();
481                        current_status != new_status || current_error != error_clone
482                    } else {
483                        true
484                    }
485                }
486            };
487
488            if status_changed {
489                match &cache_pool {
490                    DatabasePool::Sqlite(p) => {
491                        let update_sql = "UPDATE providers SET last_fetch_at = ?, last_fetch_status = ?, last_fetch_error = ? WHERE id = ?";
492                        sqlx::query(update_sql)
493                            .bind(now.to_rfc3339())
494                            .bind(new_status)
495                            .bind(error_clone.as_ref())
496                            .bind(provider_id)
497                            .execute(p)
498                            .await
499                            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
500                    }
501                    DatabasePool::Postgres(p) => {
502                        let update_sql = "UPDATE providers SET last_fetch_at = $1, last_fetch_status = $2, last_fetch_error = $3 WHERE id = $4";
503                        sqlx::query(update_sql)
504                            .bind(now)
505                            .bind(new_status)
506                            .bind(error_clone.as_ref())
507                            .bind(provider_id)
508                            .execute(p)
509                            .await
510                            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
511                    }
512                }
513            }
514
515            Ok(status_changed)
516        }).await
517    }
518
519    pub async fn get_provider_fetch_status(
520        &self, provider_id: i64,
521    ) -> DomainResult<(String, Option<String>, Option<String>)> {
522        let sql = format!(
523            "SELECT last_fetch_status, last_fetch_error, last_fetch_at FROM providers WHERE id = {}",
524            self.placeholder(1)
525        );
526        match &self.cache_pool {
527            DatabasePool::Sqlite(p) => {
528                let row = sqlx::query(&sql)
529                    .bind(provider_id)
530                    .fetch_one(p)
531                    .await
532                    .map_err(|_e| DomainError::ProviderNotFound(provider_id.to_string()))?;
533
534                let status: String = row
535                    .try_get(0)
536                    .unwrap_or_else(|_| FETCH_STATUS_NEVER.to_string());
537                let error: Option<String> = row.try_get(1).ok().flatten();
538                let fetch_at: Option<String> = row.try_get(2).ok().flatten();
539
540                Ok((status, error, fetch_at))
541            }
542            DatabasePool::Postgres(p) => {
543                let row = sqlx::query(&sql)
544                    .bind(provider_id)
545                    .fetch_one(p)
546                    .await
547                    .map_err(|_e| DomainError::ProviderNotFound(provider_id.to_string()))?;
548
549                let status: String = row
550                    .try_get(0)
551                    .unwrap_or_else(|_| FETCH_STATUS_NEVER.to_string());
552                let error: Option<String> = row.try_get(1).ok().flatten();
553                let fetch_at: Option<String> = row.try_get(2).ok().flatten();
554
555                Ok((status, error, fetch_at))
556            }
557        }
558    }
559
560    pub async fn get_cached_pipelines(
561        &self, provider_id: Option<i64>,
562    ) -> DomainResult<Vec<Pipeline>> {
563        if let Some(pid) = provider_id {
564            let sql = format!(
565                "SELECT id, provider_id, name, status, repository, branch, workflow_file, last_run, last_updated, provider_type
566                FROM pipelines_cache
567                WHERE provider_id = {}
568                ORDER BY last_updated DESC",
569                self.placeholder(1)
570            );
571            match &self.cache_pool {
572                DatabasePool::Sqlite(p) => {
573                    let rows = sqlx::query(&sql)
574                        .bind(pid)
575                        .fetch_all(p)
576                        .await
577                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
578                    rows.iter().map(|row| self.pipeline_from_row(row)).collect()
579                }
580                DatabasePool::Postgres(p) => {
581                    let rows = sqlx::query(&sql)
582                        .bind(pid)
583                        .fetch_all(p)
584                        .await
585                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
586                    rows.iter().map(|row| self.pipeline_from_row(row)).collect()
587                }
588            }
589        } else {
590            let sql = "SELECT id, provider_id, name, status, repository, branch, workflow_file, last_run, last_updated, provider_type
591                FROM pipelines_cache
592                ORDER BY last_updated DESC";
593            match &self.cache_pool {
594                DatabasePool::Sqlite(p) => {
595                    let rows = sqlx::query(sql)
596                        .fetch_all(p)
597                        .await
598                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
599                    rows.iter().map(|row| self.pipeline_from_row(row)).collect()
600                }
601                DatabasePool::Postgres(p) => {
602                    let rows = sqlx::query(sql)
603                        .fetch_all(p)
604                        .await
605                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
606                    rows.iter().map(|row| self.pipeline_from_row(row)).collect()
607                }
608            }
609        }
610    }
611
612    pub async fn cache_workflow_parameters(
613        &self, workflow_id: &str, parameters: &[pipedash_plugin_api::WorkflowParameter],
614    ) -> DomainResult<()> {
615        let workflow_id_owned = workflow_id.to_string();
616        let parameters_vec = parameters.to_vec();
617        let cache_pool = self.cache_pool.clone();
618        let is_postgres = cache_pool.is_postgres();
619
620        retry_on_busy(|| async {
621            let parameters_json = serde_json::to_string(&parameters_vec)
622                .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
623
624            let sql = if is_postgres {
625                "INSERT INTO workflow_parameters_cache (workflow_id, parameters_json, cached_at) VALUES ($1, $2, NOW()) ON CONFLICT (workflow_id) DO UPDATE SET parameters_json = EXCLUDED.parameters_json, cached_at = EXCLUDED.cached_at"
626            } else {
627                "INSERT OR REPLACE INTO workflow_parameters_cache (workflow_id, parameters_json, cached_at) VALUES (?, ?, datetime('now'))"
628            };
629
630            match &cache_pool {
631                DatabasePool::Sqlite(p) => {
632                    sqlx::query(sql)
633                        .bind(&workflow_id_owned)
634                        .bind(&parameters_json)
635                        .execute(p)
636                        .await
637                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
638                }
639                DatabasePool::Postgres(p) => {
640                    sqlx::query(sql)
641                        .bind(&workflow_id_owned)
642                        .bind(&parameters_json)
643                        .execute(p)
644                        .await
645                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
646                }
647            }
648
649            Ok(())
650        }).await
651    }
652
653    pub async fn get_cached_workflow_parameters(
654        &self, workflow_id: &str,
655    ) -> DomainResult<Option<Vec<pipedash_plugin_api::WorkflowParameter>>> {
656        let sql = format!(
657            "SELECT parameters_json FROM workflow_parameters_cache WHERE workflow_id = {}",
658            self.placeholder(1)
659        );
660        let result = match &self.cache_pool {
661            DatabasePool::Sqlite(p) => sqlx::query_scalar::<_, String>(&sql)
662                .bind(workflow_id)
663                .fetch_optional(p)
664                .await
665                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
666            DatabasePool::Postgres(p) => sqlx::query_scalar::<_, String>(&sql)
667                .bind(workflow_id)
668                .fetch_optional(p)
669                .await
670                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
671        };
672
673        match result {
674            Some(json) => {
675                if json.trim().is_empty() {
676                    Ok(Some(vec![]))
677                } else {
678                    let parameters: Vec<pipedash_plugin_api::WorkflowParameter> =
679                        serde_json::from_str(&json).map_err(|e| {
680                            DomainError::DatabaseError(format!(
681                                "Failed to parse workflow parameters: {}",
682                                e
683                            ))
684                        })?;
685                    Ok(Some(parameters))
686                }
687            }
688            None => Ok(None),
689        }
690    }
691
692    pub async fn clear_workflow_parameters_cache(&self) -> DomainResult<()> {
693        match &self.cache_pool {
694            DatabasePool::Sqlite(p) => {
695                sqlx::query("DELETE FROM workflow_parameters_cache")
696                    .execute(p)
697                    .await
698                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
699            }
700            DatabasePool::Postgres(p) => {
701                sqlx::query("DELETE FROM workflow_parameters_cache")
702                    .execute(p)
703                    .await
704                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
705            }
706        }
707
708        Ok(())
709    }
710
711    pub async fn cache_run_history(
712        &self, pipeline_id: &str, runs: &[PipelineRun],
713    ) -> DomainResult<()> {
714        if runs.is_empty() {
715            return Ok(());
716        }
717
718        let start = std::time::Instant::now();
719        let pipeline_id_str = pipeline_id.to_string();
720        let runs_vec = runs.to_vec();
721        let pool = self.cache_pool.clone();
722
723        retry_on_busy(|| {
724            let pipeline_id_clone = pipeline_id_str.clone();
725            let runs_clone = runs_vec.clone();
726            let pool_clone = pool.clone();
727            async move {
728
729                const BATCH_SIZE: usize = 100;
730
731                match &pool_clone {
732                    DatabasePool::Sqlite(p) => {
733                        let mut tx = p.begin().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
734
735                        for chunk in runs_clone.chunks(BATCH_SIZE) {
736                            let prepared_data: Vec<(i64, String, String)> = chunk
737                                .iter()
738                                .map(|run| {
739                                    let run_data = serde_json::to_string(run)
740                                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
741                                    let status_str = run.status.as_str();
742                                    let run_hash = hash_pipeline_run(
743                                        run.run_number,
744                                        status_str,
745                                        run.branch.as_deref(),
746                                        &run.started_at.to_rfc3339(),
747                                        run.duration_seconds,
748                                        run.commit_sha.as_deref(),
749                                    );
750                                    Ok((run.run_number, run_data, run_hash))
751                                })
752                                .collect::<Result<Vec<_>, DomainError>>()?;
753
754                            let values_clause = prepared_data
755                                .iter()
756                                .map(|_| "(?, ?, ?, datetime('now'), ?)")
757                                .collect::<Vec<_>>()
758                                .join(", ");
759
760                            let sql = format!(
761                                "INSERT OR REPLACE INTO run_history_cache (pipeline_id, run_number, run_data, fetched_at, run_hash) VALUES {}",
762                                values_clause
763                            );
764
765                            let mut query = sqlx::query(&sql);
766                            for (run_number, run_data, run_hash) in &prepared_data {
767                                query = query
768                                    .bind(&pipeline_id_clone)
769                                    .bind(run_number)
770                                    .bind(run_data)
771                                    .bind(run_hash);
772                            }
773
774                            query.execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
775                        }
776
777                        tx.commit().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
778                    }
779                    DatabasePool::Postgres(p) => {
780                        let mut tx = p.begin().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
781
782                        for chunk in runs_clone.chunks(BATCH_SIZE) {
783                            let prepared_data: Vec<(i64, String, String)> = chunk
784                                .iter()
785                                .map(|run| {
786                                    let run_data = serde_json::to_string(run)
787                                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
788                                    let status_str = run.status.as_str();
789                                    let run_hash = hash_pipeline_run(
790                                        run.run_number,
791                                        status_str,
792                                        run.branch.as_deref(),
793                                        &run.started_at.to_rfc3339(),
794                                        run.duration_seconds,
795                                        run.commit_sha.as_deref(),
796                                    );
797                                    Ok((run.run_number, run_data, run_hash))
798                                })
799                                .collect::<Result<Vec<_>, DomainError>>()?;
800
801                            let mut param_idx = 0;
802                            let values_clauses: Vec<String> = prepared_data
803                                .iter()
804                                .map(|_| {
805                                    let clause = format!(
806                                        "(${}, ${}, ${}, NOW(), ${})",
807                                        param_idx + 1, param_idx + 2, param_idx + 3, param_idx + 4
808                                    );
809                                    param_idx += 4;
810                                    clause
811                                })
812                                .collect();
813
814                            let sql = format!(
815                                "INSERT INTO run_history_cache (pipeline_id, run_number, run_data, fetched_at, run_hash) VALUES {} ON CONFLICT (pipeline_id, run_number) DO UPDATE SET run_data = EXCLUDED.run_data, fetched_at = EXCLUDED.fetched_at, run_hash = EXCLUDED.run_hash",
816                                values_clauses.join(", ")
817                            );
818
819                            let mut query = sqlx::query(&sql);
820                            for (run_number, run_data, run_hash) in &prepared_data {
821                                query = query
822                                    .bind(&pipeline_id_clone)
823                                    .bind(run_number)
824                                    .bind(run_data)
825                                    .bind(run_hash);
826                            }
827
828                            query.execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
829                        }
830
831                        tx.commit().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
832                    }
833                }
834
835                Ok(())
836            }
837        })
838        .await?;
839
840        let elapsed = start.elapsed();
841        tracing::debug!(
842            pipeline_id = pipeline_id,
843            runs_count = runs.len(),
844            elapsed_ms = elapsed.as_millis(),
845            "Cached run history (batch insert)"
846        );
847
848        Ok(())
849    }
850
851    pub async fn get_cached_run_history(
852        &self, pipeline_id: &str, limit: usize,
853    ) -> DomainResult<Vec<PipelineRun>> {
854        let sql = format!(
855            "SELECT run_data FROM run_history_cache
856             WHERE pipeline_id = {}
857             ORDER BY run_number DESC
858             LIMIT {}",
859            self.placeholder(1),
860            self.placeholder(2)
861        );
862        let rows = match &self.cache_pool {
863            DatabasePool::Sqlite(p) => sqlx::query_scalar::<_, String>(&sql)
864                .bind(pipeline_id)
865                .bind(limit as i64)
866                .fetch_all(p)
867                .await
868                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
869            DatabasePool::Postgres(p) => sqlx::query_scalar::<_, String>(&sql)
870                .bind(pipeline_id)
871                .bind(limit as i64)
872                .fetch_all(p)
873                .await
874                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
875        };
876
877        let runs: Vec<PipelineRun> = rows
878            .iter()
879            .filter_map(|json| serde_json::from_str(json).ok())
880            .collect();
881
882        Ok(runs)
883    }
884
885    pub async fn clear_cached_run_history(&self, pipeline_id: &str) -> DomainResult<()> {
886        let sql = format!(
887            "DELETE FROM run_history_cache WHERE pipeline_id = {}",
888            self.placeholder(1)
889        );
890        match &self.cache_pool {
891            DatabasePool::Sqlite(p) => {
892                sqlx::query(&sql)
893                    .bind(pipeline_id)
894                    .execute(p)
895                    .await
896                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
897            }
898            DatabasePool::Postgres(p) => {
899                sqlx::query(&sql)
900                    .bind(pipeline_id)
901                    .execute(p)
902                    .await
903                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
904            }
905        }
906
907        Ok(())
908    }
909
910    pub async fn clear_all_run_history_cache(&self) -> DomainResult<()> {
911        match &self.cache_pool {
912            DatabasePool::Sqlite(p) => {
913                sqlx::query("DELETE FROM run_history_cache")
914                    .execute(p)
915                    .await
916                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
917            }
918            DatabasePool::Postgres(p) => {
919                sqlx::query("DELETE FROM run_history_cache")
920                    .execute(p)
921                    .await
922                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
923            }
924        }
925
926        Ok(())
927    }
928
929    pub async fn get_cached_runs_with_hashes(
930        &self, pipeline_id: &str,
931    ) -> DomainResult<HashMap<i64, (PipelineRun, String)>> {
932        let mut result = HashMap::new();
933        match &self.cache_pool {
934            DatabasePool::Sqlite(p) => {
935                let sql = format!(
936                    "SELECT run_number, run_data, run_hash FROM run_history_cache WHERE pipeline_id = {}",
937                    self.placeholder(1)
938                );
939                let rows = sqlx::query(&sql)
940                    .bind(pipeline_id)
941                    .fetch_all(p)
942                    .await
943                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
944
945                for row in rows {
946                    let run_number: i64 = row
947                        .try_get(0)
948                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
949                    let run_data: String = row
950                        .try_get(1)
951                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
952                    let run_hash: String = row
953                        .try_get(2)
954                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
955
956                    if let Ok(run) = serde_json::from_str::<PipelineRun>(&run_data) {
957                        result.insert(run_number, (run, run_hash));
958                    }
959                }
960            }
961            DatabasePool::Postgres(p) => {
962                let sql = format!(
963                    "SELECT run_number::BIGINT, run_data, run_hash FROM run_history_cache WHERE pipeline_id = {}",
964                    self.placeholder(1)
965                );
966                let rows = sqlx::query(&sql)
967                    .bind(pipeline_id)
968                    .fetch_all(p)
969                    .await
970                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
971
972                for row in rows {
973                    let run_number: i64 = row
974                        .try_get(0)
975                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
976                    let run_data: String = row
977                        .try_get(1)
978                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
979                    let run_hash: String = row
980                        .try_get(2)
981                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
982
983                    if let Ok(run) = serde_json::from_str::<PipelineRun>(&run_data) {
984                        result.insert(run_number, (run, run_hash));
985                    }
986                }
987            }
988        }
989
990        Ok(result)
991    }
992
993    pub async fn merge_run_cache(
994        &self, pipeline_id: &str, new_runs: Vec<PipelineRun>, changed_runs: Vec<PipelineRun>,
995        deleted_run_numbers: Vec<i64>,
996    ) -> DomainResult<()> {
997        let start = std::time::Instant::now();
998        let pipeline_id_str = pipeline_id.to_string();
999        let pool = self.cache_pool.clone();
1000
1001        let new_runs_count = new_runs.len();
1002        let changed_runs_count = changed_runs.len();
1003        let deleted_runs_count = deleted_run_numbers.len();
1004
1005        retry_on_busy(move || {
1006            let pipeline_id_clone = pipeline_id_str.clone();
1007            let new_runs_clone = new_runs.clone();
1008            let changed_runs_clone = changed_runs.clone();
1009            let deleted_clone = deleted_run_numbers.clone();
1010            let pool_clone = pool.clone();
1011
1012            async move {
1013                const BATCH_SIZE: usize = 100;
1014                const DELETE_BATCH_SIZE: usize = 100;
1015
1016                let all_runs: Vec<_> = new_runs_clone.iter().chain(changed_runs_clone.iter()).collect();
1017
1018                match &pool_clone {
1019                    DatabasePool::Sqlite(p) => {
1020                        let mut tx = p.begin().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1021
1022                        if !all_runs.is_empty() {
1023                            for chunk in all_runs.chunks(BATCH_SIZE) {
1024                                let prepared_data: Vec<(i64, String, String)> = chunk
1025                                    .iter()
1026                                    .map(|run| {
1027                                        let run_data = serde_json::to_string(run)
1028                                            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1029                                        let status_str = run.status.as_str();
1030                                        let run_hash = hash_pipeline_run(
1031                                            run.run_number,
1032                                            status_str,
1033                                            run.branch.as_deref(),
1034                                            &run.started_at.to_rfc3339(),
1035                                            run.duration_seconds,
1036                                            run.commit_sha.as_deref(),
1037                                        );
1038                                        Ok((run.run_number, run_data, run_hash))
1039                                    })
1040                                    .collect::<Result<Vec<_>, DomainError>>()?;
1041
1042                                let values_clause = prepared_data
1043                                    .iter()
1044                                    .map(|_| "(?, ?, ?, datetime('now'), ?)")
1045                                    .collect::<Vec<_>>()
1046                                    .join(", ");
1047
1048                                let sql = format!(
1049                                    "INSERT OR REPLACE INTO run_history_cache (pipeline_id, run_number, run_data, fetched_at, run_hash) VALUES {}",
1050                                    values_clause
1051                                );
1052
1053                                let mut query = sqlx::query(&sql);
1054                                for (run_number, run_data, run_hash) in &prepared_data {
1055                                    query = query
1056                                        .bind(&pipeline_id_clone)
1057                                        .bind(run_number)
1058                                        .bind(run_data)
1059                                        .bind(run_hash);
1060                                }
1061
1062                                query.execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1063                            }
1064                        }
1065
1066                        if !deleted_clone.is_empty() {
1067                            for chunk in deleted_clone.chunks(DELETE_BATCH_SIZE) {
1068                                let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1069                                let sql = format!(
1070                                    "DELETE FROM run_history_cache WHERE pipeline_id = ? AND run_number IN ({})",
1071                                    placeholders
1072                                );
1073
1074                                let mut query = sqlx::query(&sql).bind(&pipeline_id_clone);
1075                                for run_number in chunk {
1076                                    query = query.bind(run_number);
1077                                }
1078
1079                                query.execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1080                            }
1081                        }
1082
1083                        tx.commit().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1084                    }
1085                    DatabasePool::Postgres(p) => {
1086                        let mut tx = p.begin().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1087
1088                        if !all_runs.is_empty() {
1089                            for chunk in all_runs.chunks(BATCH_SIZE) {
1090                                let prepared_data: Vec<(i64, String, String)> = chunk
1091                                    .iter()
1092                                    .map(|run| {
1093                                        let run_data = serde_json::to_string(run)
1094                                            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1095                                        let status_str = run.status.as_str();
1096                                        let run_hash = hash_pipeline_run(
1097                                            run.run_number,
1098                                            status_str,
1099                                            run.branch.as_deref(),
1100                                            &run.started_at.to_rfc3339(),
1101                                            run.duration_seconds,
1102                                            run.commit_sha.as_deref(),
1103                                        );
1104                                        Ok((run.run_number, run_data, run_hash))
1105                                    })
1106                                    .collect::<Result<Vec<_>, DomainError>>()?;
1107
1108                                let mut param_idx = 0;
1109                                let values_clauses: Vec<String> = prepared_data
1110                                    .iter()
1111                                    .map(|_| {
1112                                        let clause = format!(
1113                                            "(${}, ${}, ${}, NOW(), ${})",
1114                                            param_idx + 1, param_idx + 2, param_idx + 3, param_idx + 4
1115                                        );
1116                                        param_idx += 4;
1117                                        clause
1118                                    })
1119                                    .collect();
1120
1121                                let sql = format!(
1122                                    "INSERT INTO run_history_cache (pipeline_id, run_number, run_data, fetched_at, run_hash) VALUES {} ON CONFLICT (pipeline_id, run_number) DO UPDATE SET run_data = EXCLUDED.run_data, fetched_at = EXCLUDED.fetched_at, run_hash = EXCLUDED.run_hash",
1123                                    values_clauses.join(", ")
1124                                );
1125
1126                                let mut query = sqlx::query(&sql);
1127                                for (run_number, run_data, run_hash) in &prepared_data {
1128                                    query = query
1129                                        .bind(&pipeline_id_clone)
1130                                        .bind(run_number)
1131                                        .bind(run_data)
1132                                        .bind(run_hash);
1133                                }
1134
1135                                query.execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1136                            }
1137                        }
1138
1139                        if !deleted_clone.is_empty() {
1140                            for chunk in deleted_clone.chunks(DELETE_BATCH_SIZE) {
1141                                let mut param_idx = 1;
1142                                let placeholders = chunk.iter().map(|_| {
1143                                    let p = format!("${}", param_idx);
1144                                    param_idx += 1;
1145                                    p
1146                                }).collect::<Vec<_>>().join(", ");
1147
1148                                let sql = format!(
1149                                    "DELETE FROM run_history_cache WHERE pipeline_id = ${} AND run_number IN ({})",
1150                                    param_idx,
1151                                    placeholders
1152                                );
1153
1154                                let mut query = sqlx::query(&sql);
1155                                for run_number in chunk {
1156                                    query = query.bind(run_number);
1157                                }
1158                                query = query.bind(&pipeline_id_clone);
1159
1160                                query.execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1161                            }
1162                        }
1163
1164                        tx.commit().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1165                    }
1166                }
1167
1168                Ok(())
1169            }
1170        })
1171        .await?;
1172
1173        let elapsed = start.elapsed();
1174        tracing::debug!(
1175            pipeline_id = pipeline_id,
1176            new_runs = new_runs_count,
1177            changed_runs = changed_runs_count,
1178            deleted_runs = deleted_runs_count,
1179            elapsed_ms = elapsed.as_millis(),
1180            "Merged run cache (batch operations)"
1181        );
1182
1183        Ok(())
1184    }
1185
1186    pub async fn update_pipelines_cache(
1187        &self, provider_id: i64, new_pipelines: &[Pipeline],
1188    ) -> DomainResult<()> {
1189        let new_pipelines_vec = new_pipelines.to_vec();
1190        let pool = self.cache_pool.clone();
1191        let is_postgres = pool.is_postgres();
1192
1193        retry_on_busy(move || {
1194            let new_pipelines_clone = new_pipelines_vec.clone();
1195            let pool_clone = pool.clone();
1196            let is_pg = is_postgres;
1197            async move {
1198                let select_sql = if is_pg {
1199                    "SELECT id, provider_id, name, status, repository, branch, workflow_file, last_run, last_updated, provider_type
1200                    FROM pipelines_cache
1201                    WHERE provider_id = $1"
1202                } else {
1203                    "SELECT id, provider_id, name, status, repository, branch, workflow_file, last_run, last_updated, provider_type
1204                    FROM pipelines_cache
1205                    WHERE provider_id = ?"
1206                };
1207
1208                let update_sql = if is_pg {
1209                    "UPDATE pipelines_cache
1210                     SET provider_id = $1, name = $2, status = $3, repository = $4, branch = $5,
1211                         workflow_file = $6, last_run = $7, last_updated = $8, metadata_json = $9, provider_type = $10
1212                     WHERE id = $11"
1213                } else {
1214                    "UPDATE pipelines_cache
1215                     SET provider_id = ?, name = ?, status = ?, repository = ?, branch = ?,
1216                         workflow_file = ?, last_run = ?, last_updated = ?, metadata_json = ?, provider_type = ?
1217                     WHERE id = ?"
1218                };
1219
1220                let insert_sql = if is_pg {
1221                    "INSERT INTO pipelines_cache
1222                     (id, provider_id, name, status, repository, branch, workflow_file, last_run, last_updated, metadata_json, provider_type)
1223                     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"
1224                } else {
1225                    "INSERT INTO pipelines_cache
1226                     (id, provider_id, name, status, repository, branch, workflow_file, last_run, last_updated, metadata_json, provider_type)
1227                     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
1228                };
1229
1230                let delete_sql = if is_pg {
1231                    "DELETE FROM pipelines_cache WHERE id = $1"
1232                } else {
1233                    "DELETE FROM pipelines_cache WHERE id = ?"
1234                };
1235
1236                match &pool_clone {
1237                    DatabasePool::Sqlite(p) => {
1238                        let mut tx = p.begin().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1239
1240                        let existing_rows = sqlx::query(select_sql)
1241                            .bind(provider_id)
1242                            .fetch_all(&mut *tx)
1243                            .await
1244                            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1245
1246                let mut existing: HashMap<String, Pipeline> = HashMap::new();
1247                for row in existing_rows.iter() {
1248                    let id: String = row
1249                        .try_get(0)
1250                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1251                    let provider_id_val: i64 = row
1252                        .try_get(1)
1253                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1254                    let name: String = row
1255                        .try_get(2)
1256                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1257                    let status_str: String = row
1258                        .try_get(3)
1259                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1260                    let repository: String = row
1261                        .try_get(4)
1262                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1263                    let branch: Option<String> = row
1264                        .try_get(5)
1265                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1266                    let workflow_file: Option<String> = row
1267                        .try_get(6)
1268                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1269                    let last_run_str: Option<String> = row
1270                        .try_get(7)
1271                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1272                    let last_updated_str: String = row
1273                        .try_get(8)
1274                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1275                    let provider_type: String = row
1276                        .try_get(9)
1277                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1278
1279                    let status: PipelineStatus = if status_str.trim().is_empty() {
1280                        PipelineStatus::Pending
1281                    } else if status_str.starts_with('"') {
1282                        serde_json::from_str(&status_str).map_err(|e| {
1283                            DomainError::DatabaseError(format!(
1284                                "Failed to parse status '{}': {}",
1285                                status_str, e
1286                            ))
1287                        })?
1288                    } else {
1289                        let quoted = format!("\"{}\"", status_str);
1290                        serde_json::from_str(&quoted).map_err(|e| {
1291                            DomainError::DatabaseError(format!(
1292                                "Failed to parse status '{}': {}",
1293                                status_str, e
1294                            ))
1295                        })?
1296                    };
1297                    let last_run = last_run_str
1298                        .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1299                        .map(|dt| dt.with_timezone(&Utc));
1300                    let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
1301                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1302                        .with_timezone(&Utc);
1303
1304                    existing.insert(
1305                        id.clone(),
1306                        Pipeline {
1307                            id,
1308                            provider_id: provider_id_val,
1309                            provider_type,
1310                            name,
1311                            status,
1312                            last_run,
1313                            last_updated,
1314                            repository,
1315                            branch,
1316                            workflow_file,
1317                            metadata: std::collections::HashMap::new(),
1318                        },
1319                    );
1320                }
1321
1322                        let new_ids: HashMap<String, &Pipeline> =
1323                            new_pipelines_clone.iter().map(|p| (p.id.clone(), p)).collect();
1324
1325                        for pipeline in &new_pipelines_clone {
1326                            if let Some(old) = existing.get(&pipeline.id) {
1327                                if old.status != pipeline.status
1328                                    || old.last_run != pipeline.last_run
1329                                    || old.name != pipeline.name
1330                                    || old.provider_id != provider_id
1331                                    || old.provider_type != pipeline.provider_type
1332                                {
1333                                    sqlx::query(update_sql)
1334                                        .bind(provider_id)
1335                                        .bind(&pipeline.name)
1336                                        .bind(pipeline.status.as_str())
1337                                        .bind(&pipeline.repository)
1338                                        .bind(&pipeline.branch)
1339                                        .bind(&pipeline.workflow_file)
1340                                        .bind(pipeline.last_run.as_ref().map(|dt| dt.to_rfc3339()))
1341                                        .bind(Utc::now().to_rfc3339())
1342                                        .bind("{}")
1343                                        .bind(&pipeline.provider_type)
1344                                        .bind(&pipeline.id)
1345                                        .execute(&mut *tx)
1346                                        .await
1347                                        .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1348                                }
1349                            } else {
1350                                sqlx::query(insert_sql)
1351                                    .bind(&pipeline.id)
1352                                    .bind(provider_id)
1353                                    .bind(&pipeline.name)
1354                                    .bind(pipeline.status.as_str())
1355                                    .bind(&pipeline.repository)
1356                                    .bind(&pipeline.branch)
1357                                    .bind(&pipeline.workflow_file)
1358                                    .bind(pipeline.last_run.as_ref().map(|dt| dt.to_rfc3339()))
1359                                    .bind(Utc::now().to_rfc3339())
1360                                    .bind("{}")
1361                                    .bind(&pipeline.provider_type)
1362                                    .execute(&mut *tx)
1363                                    .await
1364                                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1365                            }
1366                        }
1367
1368                        for old_id in existing.keys() {
1369                            if !new_ids.contains_key(old_id) {
1370                                sqlx::query(delete_sql)
1371                                    .bind(old_id)
1372                                    .execute(&mut *tx)
1373                                    .await
1374                                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1375                            }
1376                        }
1377
1378                        tx.commit().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1379                    }
1380                    DatabasePool::Postgres(p) => {
1381                        let mut tx = p.begin().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1382
1383                        let existing_rows = sqlx::query(select_sql)
1384                            .bind(provider_id)
1385                            .fetch_all(&mut *tx)
1386                            .await
1387                            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1388
1389                        let mut existing: HashMap<String, Pipeline> = HashMap::new();
1390                        for row in existing_rows.iter() {
1391                            let id: String = row.try_get(0).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1392                            let provider_id_val: i64 = row.try_get(1).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1393                            let name: String = row.try_get(2).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1394                            let status_str: String = row.try_get(3).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1395                            let repository: String = row.try_get(4).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1396                            let branch: Option<String> = row.try_get(5).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1397                            let workflow_file: Option<String> = row.try_get(6).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1398                            let last_run: Option<DateTime<Utc>> = row.try_get(7).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1399                            let last_updated: DateTime<Utc> = row.try_get(8).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1400                            let provider_type: String = row.try_get(9).map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1401
1402                            let status: PipelineStatus = if status_str.trim().is_empty() {
1403                                PipelineStatus::Pending
1404                            } else if status_str.starts_with('"') {
1405                                serde_json::from_str(&status_str).map_err(|e| DomainError::DatabaseError(format!("Failed to parse status '{}': {}", status_str, e)))?
1406                            } else {
1407                                let quoted = format!("\"{}\"", status_str);
1408                                serde_json::from_str(&quoted).map_err(|e| DomainError::DatabaseError(format!("Failed to parse status '{}': {}", status_str, e)))?
1409                            };
1410
1411                            existing.insert(id.clone(), Pipeline {
1412                                id, provider_id: provider_id_val, provider_type, name, status,
1413                                last_run, last_updated, repository, branch, workflow_file,
1414                                metadata: std::collections::HashMap::new(),
1415                            });
1416                        }
1417
1418                        let new_ids: HashMap<String, &Pipeline> = new_pipelines_clone.iter().map(|p| (p.id.clone(), p)).collect();
1419
1420                        for pipeline in &new_pipelines_clone {
1421                            let now = Utc::now();
1422                            if let Some(old) = existing.get(&pipeline.id) {
1423                                if old.status != pipeline.status || old.last_run != pipeline.last_run || old.name != pipeline.name || old.provider_id != provider_id || old.provider_type != pipeline.provider_type {
1424                                    sqlx::query(update_sql)
1425                                        .bind(provider_id).bind(&pipeline.name).bind(pipeline.status.as_str()).bind(&pipeline.repository).bind(&pipeline.branch)
1426                                        .bind(&pipeline.workflow_file).bind(pipeline.last_run).bind(now).bind("{}").bind(&pipeline.provider_type).bind(&pipeline.id)
1427                                        .execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1428                                }
1429                            } else {
1430                                sqlx::query(insert_sql)
1431                                    .bind(&pipeline.id).bind(provider_id).bind(&pipeline.name).bind(pipeline.status.as_str()).bind(&pipeline.repository).bind(&pipeline.branch)
1432                                    .bind(&pipeline.workflow_file).bind(pipeline.last_run).bind(now).bind("{}").bind(&pipeline.provider_type)
1433                                    .execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1434                            }
1435                        }
1436
1437                        for old_id in existing.keys() {
1438                            if !new_ids.contains_key(old_id) {
1439                                sqlx::query(delete_sql).bind(old_id).execute(&mut *tx).await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1440                            }
1441                        }
1442
1443                        tx.commit().await.map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1444                    }
1445                }
1446                Ok(())
1447            }
1448        })
1449        .await
1450    }
1451
1452    pub async fn get_pipelines_cache_count(&self) -> DomainResult<i64> {
1453        let count = match &self.cache_pool {
1454            DatabasePool::Sqlite(p) => {
1455                sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pipelines_cache")
1456                    .fetch_one(p)
1457                    .await
1458                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1459            }
1460            DatabasePool::Postgres(p) => {
1461                sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pipelines_cache")
1462                    .fetch_one(p)
1463                    .await
1464                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1465            }
1466        };
1467        Ok(count)
1468    }
1469
1470    pub async fn get_run_history_cache_count(&self) -> DomainResult<i64> {
1471        let count = match &self.cache_pool {
1472            DatabasePool::Sqlite(p) => {
1473                sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM run_history_cache")
1474                    .fetch_one(p)
1475                    .await
1476                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1477            }
1478            DatabasePool::Postgres(p) => {
1479                sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM run_history_cache")
1480                    .fetch_one(p)
1481                    .await
1482                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1483            }
1484        };
1485        Ok(count)
1486    }
1487
1488    pub async fn get_workflow_params_cache_count(&self) -> DomainResult<i64> {
1489        let count = match &self.cache_pool {
1490            DatabasePool::Sqlite(p) => {
1491                sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM workflow_parameters_cache")
1492                    .fetch_one(p)
1493                    .await
1494                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1495            }
1496            DatabasePool::Postgres(p) => {
1497                sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM workflow_parameters_cache")
1498                    .fetch_one(p)
1499                    .await
1500                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1501            }
1502        };
1503        Ok(count)
1504    }
1505
1506    pub async fn clear_pipelines_cache(&self) -> DomainResult<usize> {
1507        match &self.cache_pool {
1508            DatabasePool::Sqlite(p) => {
1509                let result = sqlx::query("DELETE FROM pipelines_cache")
1510                    .execute(p)
1511                    .await
1512                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1513                Ok(result.rows_affected() as usize)
1514            }
1515            DatabasePool::Postgres(p) => {
1516                let result = sqlx::query("DELETE FROM pipelines_cache")
1517                    .execute(p)
1518                    .await
1519                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1520                Ok(result.rows_affected() as usize)
1521            }
1522        }
1523    }
1524
1525    pub async fn clear_all_caches_atomic(&self) -> DomainResult<()> {
1526        match &self.cache_pool {
1527            DatabasePool::Sqlite(p) => {
1528                let mut tx = p
1529                    .begin()
1530                    .await
1531                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1532
1533                sqlx::query("DELETE FROM run_history_cache")
1534                    .execute(&mut *tx)
1535                    .await
1536                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1537
1538                sqlx::query("DELETE FROM pipelines_cache")
1539                    .execute(&mut *tx)
1540                    .await
1541                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1542
1543                sqlx::query("DELETE FROM workflow_parameters_cache")
1544                    .execute(&mut *tx)
1545                    .await
1546                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1547
1548                tx.commit()
1549                    .await
1550                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1551            }
1552            DatabasePool::Postgres(p) => {
1553                let mut tx = p
1554                    .begin()
1555                    .await
1556                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1557
1558                sqlx::query("DELETE FROM run_history_cache")
1559                    .execute(&mut *tx)
1560                    .await
1561                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1562
1563                sqlx::query("DELETE FROM pipelines_cache")
1564                    .execute(&mut *tx)
1565                    .await
1566                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1567
1568                sqlx::query("DELETE FROM workflow_parameters_cache")
1569                    .execute(&mut *tx)
1570                    .await
1571                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1572
1573                tx.commit()
1574                    .await
1575                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1576            }
1577        }
1578
1579        Ok(())
1580    }
1581
1582    pub async fn get_table_preferences(
1583        &self, provider_id: i64, table_id: &str,
1584    ) -> DomainResult<Option<String>> {
1585        self.config_backend
1586            .get_table_preferences(provider_id, table_id)
1587            .await
1588    }
1589
1590    pub async fn upsert_table_preferences(
1591        &self, provider_id: i64, table_id: &str, preferences_json: &str,
1592    ) -> DomainResult<()> {
1593        self.config_backend
1594            .set_table_preferences(provider_id, table_id, preferences_json)
1595            .await
1596    }
1597
1598    pub async fn store_provider_permissions(
1599        &self, provider_id: i64, status: &pipedash_plugin_api::PermissionStatus,
1600    ) -> DomainResult<()> {
1601        let mut permissions_map = HashMap::new();
1602        for perm_check in &status.permissions {
1603            permissions_map.insert(perm_check.permission.name.clone(), perm_check.granted);
1604        }
1605
1606        let stored_permissions = crate::infrastructure::config_backend::StoredPermissions {
1607            permissions: permissions_map,
1608            last_checked: status.checked_at,
1609        };
1610
1611        self.config_backend
1612            .store_permissions(provider_id, &stored_permissions)
1613            .await
1614    }
1615
1616    pub async fn get_provider_permissions(
1617        &self, provider_id: i64,
1618    ) -> DomainResult<Option<pipedash_plugin_api::PermissionStatus>> {
1619        let stored_perms = self.config_backend.get_permissions(provider_id).await?;
1620
1621        match stored_perms {
1622            Some(stored) => {
1623                let mut permissions = Vec::new();
1624                let mut all_granted = true;
1625
1626                for (name, granted) in stored.permissions {
1627                    let permission = pipedash_plugin_api::Permission {
1628                        name,
1629                        description: String::new(),
1630                        required: false,
1631                    };
1632
1633                    permissions.push(pipedash_plugin_api::PermissionCheck {
1634                        permission,
1635                        granted,
1636                    });
1637
1638                    if !granted {
1639                        all_granted = false;
1640                    }
1641                }
1642
1643                Ok(Some(pipedash_plugin_api::PermissionStatus {
1644                    permissions,
1645                    all_granted,
1646                    checked_at: stored.last_checked,
1647                    metadata: HashMap::new(),
1648                }))
1649            }
1650            None => Ok(None),
1651        }
1652    }
1653
1654    pub async fn get_cached_run_count(&self, pipeline_id: &str) -> DomainResult<usize> {
1655        let sql = format!(
1656            "SELECT COUNT(*) FROM run_history_cache WHERE pipeline_id = {}",
1657            self.placeholder(1)
1658        );
1659        let count = match &self.cache_pool {
1660            DatabasePool::Sqlite(p) => sqlx::query_scalar::<_, i64>(&sql)
1661                .bind(pipeline_id)
1662                .fetch_one(p)
1663                .await
1664                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
1665            DatabasePool::Postgres(p) => sqlx::query_scalar::<_, i64>(&sql)
1666                .bind(pipeline_id)
1667                .fetch_one(p)
1668                .await
1669                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
1670        };
1671
1672        Ok(count as usize)
1673    }
1674
1675    pub async fn get_paginated_runs(
1676        &self, pipeline_id: &str, page: usize, page_size: usize,
1677    ) -> DomainResult<Vec<PipelineRun>> {
1678        let offset = (page - 1) * page_size;
1679
1680        let sql = format!(
1681            "SELECT run_data FROM run_history_cache
1682             WHERE pipeline_id = {}
1683             ORDER BY run_number DESC
1684             LIMIT {} OFFSET {}",
1685            self.placeholder(1),
1686            self.placeholder(2),
1687            self.placeholder(3)
1688        );
1689        let runs_json = match &self.cache_pool {
1690            DatabasePool::Sqlite(p) => sqlx::query_scalar::<_, String>(&sql)
1691                .bind(pipeline_id)
1692                .bind(page_size as i64)
1693                .bind(offset as i64)
1694                .fetch_all(p)
1695                .await
1696                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
1697            DatabasePool::Postgres(p) => sqlx::query_scalar::<_, String>(&sql)
1698                .bind(pipeline_id)
1699                .bind(page_size as i64)
1700                .bind(offset as i64)
1701                .fetch_all(p)
1702                .await
1703                .map_err(|e| DomainError::DatabaseError(e.to_string()))?,
1704        };
1705
1706        let runs: Vec<PipelineRun> = runs_json
1707            .into_iter()
1708            .filter_map(|json| serde_json::from_str::<PipelineRun>(&json).ok())
1709            .collect();
1710
1711        Ok(runs)
1712    }
1713
1714    fn pipeline_from_row<'r, R>(&self, row: &'r R) -> DomainResult<Pipeline>
1715    where
1716        R: SqlxRow,
1717        usize: sqlx::ColumnIndex<R>,
1718        String: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
1719        i64: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
1720        Option<String>: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
1721        DateTime<Utc>: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
1722        Option<DateTime<Utc>>: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
1723    {
1724        let id: String = row
1725            .try_get(0)
1726            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1727        let provider_id: i64 = row
1728            .try_get(1)
1729            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1730        let name: String = row
1731            .try_get(2)
1732            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1733        let status_str: String = row
1734            .try_get(3)
1735            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1736        let repository: String = row
1737            .try_get(4)
1738            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1739        let branch: Option<String> = row
1740            .try_get(5)
1741            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1742        let workflow_file: Option<String> = row
1743            .try_get(6)
1744            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1745
1746        let status: PipelineStatus = if status_str.trim().is_empty() {
1747            PipelineStatus::Pending
1748        } else if status_str.starts_with('"') {
1749            serde_json::from_str(&status_str).map_err(|e| {
1750                DomainError::DatabaseError(format!(
1751                    "Failed to parse status '{}': {}",
1752                    status_str, e
1753                ))
1754            })?
1755        } else {
1756            let quoted = format!("\"{}\"", status_str);
1757            serde_json::from_str(&quoted).map_err(|e| {
1758                DomainError::DatabaseError(format!(
1759                    "Failed to parse status '{}': {}",
1760                    status_str, e
1761                ))
1762            })?
1763        };
1764
1765        let (last_run, last_updated) = match &self.cache_pool {
1766            DatabasePool::Sqlite(_) => {
1767                let last_run_str: Option<String> = row
1768                    .try_get(7)
1769                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1770                let last_updated_str: String = row
1771                    .try_get(8)
1772                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1773
1774                let last_run = last_run_str
1775                    .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1776                    .map(|dt| dt.with_timezone(&Utc));
1777                let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
1778                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?
1779                    .with_timezone(&Utc);
1780
1781                (last_run, last_updated)
1782            }
1783            DatabasePool::Postgres(_) => {
1784                let last_run: Option<DateTime<Utc>> = row
1785                    .try_get(7)
1786                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1787                let last_updated: DateTime<Utc> = row
1788                    .try_get(8)
1789                    .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1790
1791                (last_run, last_updated)
1792            }
1793        };
1794
1795        let provider_type: String = row
1796            .try_get(9)
1797            .map_err(|e| DomainError::DatabaseError(e.to_string()))?;
1798
1799        Ok(Pipeline {
1800            id,
1801            provider_id,
1802            provider_type,
1803            name,
1804            status,
1805            last_run,
1806            last_updated,
1807            repository,
1808            branch,
1809            workflow_file,
1810            metadata: std::collections::HashMap::new(),
1811        })
1812    }
1813}