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