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; 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; 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(¶meters_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(¶meters_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(¶meters_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("ed).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("ed).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("ed).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}