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