Skip to main content

perfgate_server/storage/
postgres.rs

1//! PostgreSQL storage implementation for persistent baseline storage.
2//!
3//! This module provides a robust, asynchronous PostgreSQL backend for storing
4//! and querying perfgate baseline records using sqlx.
5
6use super::{ArtifactStore, AuditStore, BaselineStore, StorageHealth};
7use crate::error::StoreError;
8use crate::models::{
9    AuditAction, AuditEvent, AuditResourceType, BaselineRecord, BaselineSource, BaselineVersion,
10    DecisionRecord, ListAuditEventsQuery, ListAuditEventsResponse, ListBaselinesQuery,
11    ListBaselinesResponse, ListDecisionsQuery, ListDecisionsResponse, ListVerdictsQuery,
12    ListVerdictsResponse, PaginationInfo, PruneDecisionsResponse, VerdictRecord,
13};
14use crate::server::PostgresPoolConfig;
15use async_trait::async_trait;
16use perfgate_types::{MetricStatus, VerdictStatus};
17use sqlx::{Connection, Executor, PgPool, Row, postgres::PgPoolOptions};
18use std::sync::Arc;
19use std::time::Duration;
20use tracing::{info, warn};
21
22/// PostgreSQL storage backend for baselines.
23#[derive(Debug, Clone)]
24pub struct PostgresStore {
25    pool: PgPool,
26    artifacts: Option<Arc<dyn ArtifactStore>>,
27}
28
29/// Maximum number of retry attempts for transient connection failures.
30const MAX_RETRIES: u32 = 3;
31
32/// Initial backoff delay for connection retries.
33const INITIAL_BACKOFF: Duration = Duration::from_millis(250);
34
35/// Returns `true` when an sqlx error looks transient (connection refused,
36/// timeout, reset, etc.) -- i.e. worth retrying.
37fn is_transient(err: &sqlx::Error) -> bool {
38    match err {
39        sqlx::Error::PoolTimedOut => true,
40        sqlx::Error::PoolClosed => false,
41        sqlx::Error::Io(_) => true,
42        sqlx::Error::Database(db_err) => {
43            // Postgres error codes starting with 08 are connection exceptions.
44            // Class 57P covers operator intervention: 57P01 (admin_shutdown),
45            // 57P02 (crash_shutdown), 57P03 (cannot_connect_now).
46            db_err
47                .code()
48                .map(|c| c.starts_with("08") || c.starts_with("57P"))
49                .unwrap_or(false)
50        }
51        _ => {
52            let msg = err.to_string().to_lowercase();
53            msg.contains("connection refused")
54                || msg.contains("connection reset")
55                || msg.contains("broken pipe")
56                || msg.contains("timed out")
57        }
58    }
59}
60
61fn postgres_schema_statements() -> [&'static str; 14] {
62    [
63        r#"
64            CREATE TABLE IF NOT EXISTS baselines (
65                id VARCHAR(64) PRIMARY KEY,
66                project VARCHAR(255) NOT NULL,
67                benchmark VARCHAR(255) NOT NULL,
68                version VARCHAR(64) NOT NULL,
69                schema_id VARCHAR(64) NOT NULL,
70                git_ref VARCHAR(255),
71                git_sha VARCHAR(40),
72                receipt JSONB,
73                artifact_path TEXT,
74                metadata JSONB NOT NULL,
75                tags JSONB NOT NULL,
76                created_at TIMESTAMPTZ NOT NULL,
77                updated_at TIMESTAMPTZ NOT NULL,
78                content_hash VARCHAR(64) NOT NULL,
79                source VARCHAR(32) NOT NULL,
80                deleted BOOLEAN NOT NULL DEFAULT FALSE,
81                UNIQUE (project, benchmark, version)
82            )
83            "#,
84        r#"
85            CREATE INDEX IF NOT EXISTS idx_baselines_project_benchmark
86            ON baselines(project, benchmark)
87            "#,
88        r#"
89            CREATE TABLE IF NOT EXISTS verdicts (
90                id VARCHAR(64) PRIMARY KEY,
91                schema_id VARCHAR(64) NOT NULL,
92                project VARCHAR(255) NOT NULL,
93                benchmark VARCHAR(255) NOT NULL,
94                run_id VARCHAR(255) NOT NULL,
95                status VARCHAR(32) NOT NULL,
96                counts JSONB NOT NULL,
97                reasons JSONB NOT NULL,
98                git_ref VARCHAR(255),
99                git_sha VARCHAR(40),
100                wall_ms_cv DOUBLE PRECISION,
101                flakiness_score DOUBLE PRECISION,
102                created_at TIMESTAMPTZ NOT NULL
103            )
104            "#,
105        r#"
106            CREATE INDEX IF NOT EXISTS idx_verdicts_project_benchmark
107            ON verdicts(project, benchmark)
108            "#,
109        r#"
110            CREATE INDEX IF NOT EXISTS idx_verdicts_created_at
111            ON verdicts(created_at)
112            "#,
113        r#"
114            CREATE TABLE IF NOT EXISTS decisions (
115                id VARCHAR(64) PRIMARY KEY,
116                schema_id VARCHAR(64) NOT NULL,
117                project VARCHAR(255) NOT NULL,
118                scenario VARCHAR(255),
119                status VARCHAR(32) NOT NULL,
120                verdict VARCHAR(32) NOT NULL,
121                accepted_rules JSONB NOT NULL DEFAULT '[]'::jsonb,
122                review_required BOOLEAN NOT NULL DEFAULT FALSE,
123                review_reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
124                git_ref VARCHAR(255),
125                git_sha VARCHAR(40),
126                scenario_receipt JSONB,
127                tradeoff_receipt JSONB NOT NULL,
128                artifact_index JSONB,
129                created_at TIMESTAMPTZ NOT NULL
130            )
131            "#,
132        r#"
133            CREATE INDEX IF NOT EXISTS idx_decisions_project_created
134            ON decisions(project, created_at DESC)
135            "#,
136        r#"
137            CREATE INDEX IF NOT EXISTS idx_decisions_project_scenario
138            ON decisions(project, scenario)
139            "#,
140        r#"
141            CREATE TABLE IF NOT EXISTS audit_events (
142                id VARCHAR(64) PRIMARY KEY,
143                timestamp TIMESTAMPTZ NOT NULL,
144                actor VARCHAR(255) NOT NULL,
145                action VARCHAR(32) NOT NULL,
146                resource_type VARCHAR(32) NOT NULL,
147                resource_id VARCHAR(255) NOT NULL,
148                project VARCHAR(255) NOT NULL,
149                metadata JSONB NOT NULL DEFAULT '{}'
150            )
151            "#,
152        r#"
153            CREATE INDEX IF NOT EXISTS idx_audit_events_project
154            ON audit_events(project)
155            "#,
156        r#"
157            CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp
158            ON audit_events(timestamp DESC)
159            "#,
160        r#"
161            CREATE INDEX IF NOT EXISTS idx_audit_events_action
162            ON audit_events(action)
163            "#,
164        "ALTER TABLE verdicts ADD COLUMN IF NOT EXISTS wall_ms_cv DOUBLE PRECISION",
165        "ALTER TABLE verdicts ADD COLUMN IF NOT EXISTS flakiness_score DOUBLE PRECISION",
166    ]
167}
168
169impl PostgresStore {
170    /// Creates a new PostgreSQL storage backend and runs initial schema migrations.
171    ///
172    /// The connection pool is configured according to the supplied
173    /// [`PostgresPoolConfig`]. An `after_connect` callback sets
174    /// `statement_timeout` on every new connection, and a `before_acquire`
175    /// callback pings the connection to verify it is still alive.
176    pub async fn new(
177        url: &str,
178        artifacts: Option<Arc<dyn ArtifactStore>>,
179        pool_config: &PostgresPoolConfig,
180    ) -> Result<Self, StoreError> {
181        let stmt_timeout_ms = pool_config.statement_timeout.as_millis() as u64;
182
183        let pool = PgPoolOptions::new()
184            .max_connections(pool_config.max_connections)
185            .min_connections(pool_config.min_connections)
186            .idle_timeout(pool_config.idle_timeout)
187            .max_lifetime(pool_config.max_lifetime)
188            .acquire_timeout(pool_config.acquire_timeout)
189            .after_connect(move |conn, _meta| {
190                Box::pin(async move {
191                    conn.execute(format!("SET statement_timeout = '{}'", stmt_timeout_ms).as_str())
192                        .await?;
193                    Ok(())
194                })
195            })
196            .before_acquire(|conn, _meta| {
197                Box::pin(async move {
198                    // Quick ping to verify the connection is alive.
199                    conn.ping().await?;
200                    Ok(true)
201                })
202            })
203            .connect(url)
204            .await
205            .map_err(|e| StoreError::ConnectionError(e.to_string()))?;
206
207        info!(
208            max = pool_config.max_connections,
209            min = pool_config.min_connections,
210            idle_timeout_s = pool_config.idle_timeout.as_secs(),
211            max_lifetime_s = pool_config.max_lifetime.as_secs(),
212            acquire_timeout_s = pool_config.acquire_timeout.as_secs(),
213            statement_timeout_ms = stmt_timeout_ms,
214            "PostgreSQL connection pool configured"
215        );
216
217        let store = Self { pool, artifacts };
218        store.init_schema().await?;
219        Ok(store)
220    }
221
222    /// Returns current pool metrics (idle, active, max connections).
223    fn pg_pool_metrics(&self) -> crate::models::PoolMetrics {
224        let size = self.pool.size();
225        let num_idle = self.pool.num_idle() as u32;
226        crate::models::PoolMetrics {
227            idle: num_idle,
228            active: size.saturating_sub(num_idle),
229            max: self.pool.options().get_max_connections(),
230        }
231    }
232
233    /// Executes a query with automatic retry on transient errors.
234    ///
235    /// The closure receives a reference to the pool and should return the
236    /// query result. On transient failure the call is retried up to
237    /// [`MAX_RETRIES`] times with exponential backoff.
238    pub(crate) async fn with_retry<F, Fut, T>(&self, operation: F) -> Result<T, StoreError>
239    where
240        F: Fn(PgPool) -> Fut,
241        Fut: std::future::Future<Output = Result<T, sqlx::Error>>,
242    {
243        let mut last_err = None;
244        let mut backoff = INITIAL_BACKOFF;
245
246        for attempt in 0..=MAX_RETRIES {
247            match operation(self.pool.clone()).await {
248                Ok(val) => return Ok(val),
249                Err(e) if is_transient(&e) && attempt < MAX_RETRIES => {
250                    warn!(
251                        attempt = attempt + 1,
252                        max = MAX_RETRIES,
253                        backoff_ms = backoff.as_millis() as u64,
254                        error = %e,
255                        "Transient database error, retrying"
256                    );
257                    tokio::time::sleep(backoff).await;
258                    backoff *= 2;
259                    last_err = Some(e);
260                }
261                Err(e) => {
262                    return Err(StoreError::QueryError(e.to_string()));
263                }
264            }
265        }
266
267        Err(StoreError::QueryError(
268            last_err
269                .map(|e| e.to_string())
270                .unwrap_or_else(|| "Unknown error after retries".to_string()),
271        ))
272    }
273
274    async fn init_schema(&self) -> Result<(), StoreError> {
275        let statements = postgres_schema_statements();
276
277        for statement in statements {
278            sqlx::query(statement)
279                .execute(&self.pool)
280                .await
281                .map_err(|e| {
282                    StoreError::ConnectionError(format!("Failed to init schema: {}", e))
283                })?;
284        }
285
286        Ok(())
287    }
288
289    async fn store_artifact(&self, record: &BaselineRecord) -> Result<Option<String>, StoreError> {
290        if let Some(store) = &self.artifacts {
291            let path = format!(
292                "{}/{}/{}.json",
293                record.project, record.benchmark, record.version
294            );
295            let data =
296                serde_json::to_vec(&record.receipt).map_err(StoreError::SerializationError)?;
297            store.put(&path, data).await?;
298            Ok(Some(path))
299        } else {
300            Ok(None)
301        }
302    }
303
304    fn row_to_record(
305        row: sqlx::postgres::PgRow,
306    ) -> Result<(BaselineRecord, Option<String>), StoreError> {
307        let artifact_path: Option<String> = row.get("artifact_path");
308
309        let receipt = if let Some(receipt_json) = row.get::<Option<serde_json::Value>, _>("receipt")
310        {
311            serde_json::from_value(receipt_json).map_err(StoreError::SerializationError)?
312        } else {
313            // Placeholder, will be loaded from artifact store if needed
314            serde_json::from_value(serde_json::json!({
315                "schema": "perfgate.run.v1",
316                "tool": {"name": "placeholder", "version": "0"},
317                "run": {
318                    "id": "placeholder",
319                    "started_at": "1970-01-01T00:00:00Z",
320                    "ended_at": "1970-01-01T00:00:00Z",
321                    "host": {"os": "unknown", "arch": "unknown"}
322                },
323                "bench": {
324                    "name": "placeholder",
325                    "command": [],
326                    "repeat": 0,
327                    "warmup": 0
328                },
329                "samples": [],
330                "stats": {
331                    "wall_ms": {"median": 0, "min": 0, "max": 0}
332                }
333            }))
334            .unwrap()
335        };
336
337        let metadata_json: serde_json::Value = row.get("metadata");
338        let metadata =
339            serde_json::from_value(metadata_json).map_err(StoreError::SerializationError)?;
340
341        let tags_json: serde_json::Value = row.get("tags");
342        let tags = serde_json::from_value(tags_json).map_err(StoreError::SerializationError)?;
343
344        let source_str: String = row.get("source");
345        let source = serde_json::from_value(serde_json::Value::String(source_str))
346            .unwrap_or(BaselineSource::Upload);
347
348        let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
349        let updated_at: chrono::DateTime<chrono::Utc> = row.get("updated_at");
350
351        Ok((
352            BaselineRecord {
353                schema: row.get("schema_id"),
354                id: row.get("id"),
355                project: row.get("project"),
356                benchmark: row.get("benchmark"),
357                version: row.get("version"),
358                git_ref: row.get("git_ref"),
359                git_sha: row.get("git_sha"),
360                receipt,
361                metadata,
362                tags,
363                created_at,
364                updated_at,
365                content_hash: row.get("content_hash"),
366                source,
367                deleted: row.get("deleted"),
368            },
369            artifact_path,
370        ))
371    }
372
373    async fn load_artifact(
374        &self,
375        path: Option<String>,
376        mut record: BaselineRecord,
377    ) -> Result<BaselineRecord, StoreError> {
378        if let (Some(store), Some(path)) = (&self.artifacts, path) {
379            let data = store.get(&path).await?;
380            record.receipt =
381                serde_json::from_slice(&data).map_err(StoreError::SerializationError)?;
382        }
383        Ok(record)
384    }
385}
386
387#[async_trait]
388impl BaselineStore for PostgresStore {
389    async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
390        let artifact_path = self.store_artifact(record).await?;
391
392        let receipt_json = if artifact_path.is_none() {
393            Some(serde_json::to_value(&record.receipt).map_err(StoreError::SerializationError)?)
394        } else {
395            None
396        };
397
398        let metadata_json =
399            serde_json::to_value(&record.metadata).map_err(StoreError::SerializationError)?;
400        let tags_json =
401            serde_json::to_value(&record.tags).map_err(StoreError::SerializationError)?;
402        let source_json =
403            serde_json::to_value(&record.source).map_err(StoreError::SerializationError)?;
404        let source_str = source_json.as_str().unwrap_or("upload");
405
406        let sql = r#"
407            INSERT INTO baselines (
408                id, project, benchmark, version, schema_id, 
409                git_ref, git_sha, receipt, artifact_path, metadata, tags,
410                created_at, updated_at, content_hash, source, deleted
411            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
412        "#;
413
414        let result = sqlx::query(sql)
415            .bind(&record.id)
416            .bind(&record.project)
417            .bind(&record.benchmark)
418            .bind(&record.version)
419            .bind(&record.schema)
420            .bind(&record.git_ref)
421            .bind(&record.git_sha)
422            .bind(receipt_json)
423            .bind(artifact_path)
424            .bind(metadata_json)
425            .bind(tags_json)
426            .bind(record.created_at)
427            .bind(record.updated_at)
428            .bind(&record.content_hash)
429            .bind(source_str)
430            .bind(record.deleted)
431            .execute(&self.pool)
432            .await;
433
434        match result {
435            Ok(_) => Ok(()),
436            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
437                StoreError::already_exists(&record.project, &record.benchmark, &record.version),
438            ),
439            Err(e) => Err(StoreError::QueryError(e.to_string())),
440        }
441    }
442
443    async fn get(
444        &self,
445        project: &str,
446        benchmark: &str,
447        version: &str,
448    ) -> Result<Option<BaselineRecord>, StoreError> {
449        let sql = "SELECT * FROM baselines WHERE project = $1 AND benchmark = $2 AND version = $3 AND deleted = FALSE";
450
451        let row_opt = sqlx::query(sql)
452            .bind(project)
453            .bind(benchmark)
454            .bind(version)
455            .fetch_optional(&self.pool)
456            .await
457            .map_err(|e| StoreError::QueryError(e.to_string()))?;
458
459        match row_opt {
460            Some(row) => {
461                let (record, artifact_path) = Self::row_to_record(row)?;
462                Ok(Some(self.load_artifact(artifact_path, record).await?))
463            }
464            None => Ok(None),
465        }
466    }
467
468    async fn get_latest(
469        &self,
470        project: &str,
471        benchmark: &str,
472    ) -> Result<Option<BaselineRecord>, StoreError> {
473        let sql = "SELECT * FROM baselines WHERE project = $1 AND benchmark = $2 AND deleted = FALSE ORDER BY created_at DESC LIMIT 1";
474
475        let row_opt = sqlx::query(sql)
476            .bind(project)
477            .bind(benchmark)
478            .fetch_optional(&self.pool)
479            .await
480            .map_err(|e| StoreError::QueryError(e.to_string()))?;
481
482        match row_opt {
483            Some(row) => {
484                let (record, artifact_path) = Self::row_to_record(row)?;
485                Ok(Some(self.load_artifact(artifact_path, record).await?))
486            }
487            None => Ok(None),
488        }
489    }
490
491    async fn list(
492        &self,
493        project: &str,
494        query: &ListBaselinesQuery,
495    ) -> Result<ListBaselinesResponse, StoreError> {
496        let mut sql =
497            String::from("SELECT * FROM baselines WHERE project = $1 AND deleted = FALSE");
498
499        if let Some(bench) = &query.benchmark {
500            sql.push_str(" AND benchmark = '");
501            sql.push_str(&bench.replace('\'', "''"));
502            sql.push('\'');
503        }
504
505        sql.push_str(" ORDER BY created_at DESC");
506
507        let limit = query.limit.min(100) as i64;
508        sql.push_str(&format!(" LIMIT {}", limit + 1));
509
510        let offset = query.offset as i64;
511        sql.push_str(&format!(" OFFSET {}", offset));
512
513        let rows = sqlx::query(&sql)
514            .bind(project)
515            .fetch_all(&self.pool)
516            .await
517            .map_err(|e| StoreError::QueryError(e.to_string()))?;
518
519        let has_more = rows.len() > limit as usize;
520        let take_count = if has_more { limit as usize } else { rows.len() };
521
522        let mut baselines = Vec::with_capacity(take_count);
523        for row in rows.into_iter().take(take_count) {
524            let (mut record, artifact_path) = Self::row_to_record(row)?;
525            if query.include_receipt {
526                record = self.load_artifact(artifact_path, record).await?;
527            }
528            baselines.push(record.into());
529        }
530
531        // Determine total count
532        let count_sql = "SELECT COUNT(*) FROM baselines WHERE project = $1 AND deleted = FALSE";
533        let mut count_query = String::from(count_sql);
534        if let Some(bench) = &query.benchmark {
535            count_query.push_str(" AND benchmark = '");
536            count_query.push_str(&bench.replace('\'', "''"));
537            count_query.push('\'');
538        }
539        let total_row = sqlx::query(&count_query)
540            .bind(project)
541            .fetch_one(&self.pool)
542            .await
543            .map_err(|e| StoreError::QueryError(e.to_string()))?;
544
545        let total: i64 = total_row.get(0);
546
547        let pagination = PaginationInfo {
548            limit: limit as u32,
549            offset: query.offset,
550            total: total as u64,
551            has_more,
552        };
553
554        Ok(ListBaselinesResponse {
555            baselines,
556            pagination,
557        })
558    }
559
560    async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
561        let receipt_json =
562            serde_json::to_value(&record.receipt).map_err(StoreError::SerializationError)?;
563        let metadata_json =
564            serde_json::to_value(&record.metadata).map_err(StoreError::SerializationError)?;
565        let tags_json =
566            serde_json::to_value(&record.tags).map_err(StoreError::SerializationError)?;
567
568        let sql = r#"
569            UPDATE baselines 
570            SET schema_id = $1, git_ref = $2, git_sha = $3, receipt = $4, 
571                metadata = $5, tags = $6, updated_at = $7, content_hash = $8
572            WHERE project = $9 AND benchmark = $10 AND version = $11 AND deleted = FALSE
573        "#;
574
575        let result = sqlx::query(sql)
576            .bind(&record.schema)
577            .bind(&record.git_ref)
578            .bind(&record.git_sha)
579            .bind(receipt_json)
580            .bind(metadata_json)
581            .bind(tags_json)
582            .bind(record.updated_at)
583            .bind(&record.content_hash)
584            .bind(&record.project)
585            .bind(&record.benchmark)
586            .bind(&record.version)
587            .execute(&self.pool)
588            .await
589            .map_err(|e| StoreError::QueryError(e.to_string()))?;
590
591        if result.rows_affected() == 0 {
592            return Err(StoreError::not_found(
593                &record.project,
594                &record.benchmark,
595                &record.version,
596            ));
597        }
598
599        Ok(())
600    }
601
602    async fn delete(
603        &self,
604        project: &str,
605        benchmark: &str,
606        version: &str,
607    ) -> Result<bool, StoreError> {
608        let sql = "UPDATE baselines SET deleted = TRUE, updated_at = NOW() WHERE project = $1 AND benchmark = $2 AND version = $3 AND deleted = FALSE";
609
610        let result = sqlx::query(sql)
611            .bind(project)
612            .bind(benchmark)
613            .bind(version)
614            .execute(&self.pool)
615            .await
616            .map_err(|e| StoreError::QueryError(e.to_string()))?;
617
618        Ok(result.rows_affected() > 0)
619    }
620
621    async fn hard_delete(
622        &self,
623        project: &str,
624        benchmark: &str,
625        version: &str,
626    ) -> Result<bool, StoreError> {
627        let sql = "DELETE FROM baselines WHERE project = $1 AND benchmark = $2 AND version = $3";
628
629        let result = sqlx::query(sql)
630            .bind(project)
631            .bind(benchmark)
632            .bind(version)
633            .execute(&self.pool)
634            .await
635            .map_err(|e| StoreError::QueryError(e.to_string()))?;
636
637        Ok(result.rows_affected() > 0)
638    }
639
640    async fn list_versions(
641        &self,
642        project: &str,
643        benchmark: &str,
644    ) -> Result<Vec<BaselineVersion>, StoreError> {
645        let sql = "SELECT version, created_at, git_ref, git_sha, source FROM baselines WHERE project = $1 AND benchmark = $2 AND deleted = FALSE ORDER BY created_at DESC";
646
647        let rows = sqlx::query(sql)
648            .bind(project)
649            .bind(benchmark)
650            .fetch_all(&self.pool)
651            .await
652            .map_err(|e| StoreError::QueryError(e.to_string()))?;
653
654        let mut versions = Vec::with_capacity(rows.len());
655        for row in rows {
656            let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
657            let source_str: String = row.get("source");
658            let source = serde_json::from_value(serde_json::Value::String(source_str))
659                .unwrap_or(BaselineSource::Upload);
660
661            versions.push(BaselineVersion {
662                version: row.get("version"),
663                created_at,
664                git_ref: row.get("git_ref"),
665                git_sha: row.get("git_sha"),
666                created_by: None,
667                is_current: false, // Could be determined by checking if it's the latest
668                source,
669            });
670        }
671
672        if let Some(first) = versions.first_mut() {
673            first.is_current = true;
674        }
675
676        Ok(versions)
677    }
678
679    async fn health_check(&self) -> Result<StorageHealth, StoreError> {
680        self.with_retry(|pool| async move { sqlx::query("SELECT 1").execute(&pool).await })
681            .await?;
682        Ok(StorageHealth::Healthy)
683    }
684
685    fn backend_type(&self) -> &'static str {
686        "postgres"
687    }
688
689    fn pool_metrics(&self) -> Option<crate::models::PoolMetrics> {
690        Some(self.pg_pool_metrics())
691    }
692
693    async fn create_verdict(&self, record: &VerdictRecord) -> Result<(), StoreError> {
694        let sql = r#"
695            INSERT INTO verdicts (
696                id, schema_id, project, benchmark, run_id, status, counts, reasons,
697                git_ref, git_sha, wall_ms_cv, flakiness_score, created_at
698            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
699        "#;
700
701        let counts_json =
702            serde_json::to_value(&record.counts).map_err(StoreError::SerializationError)?;
703        let reasons_json =
704            serde_json::to_value(&record.reasons).map_err(StoreError::SerializationError)?;
705        let status_str = record.status.as_str();
706
707        sqlx::query(sql)
708            .bind(&record.id)
709            .bind(&record.schema)
710            .bind(&record.project)
711            .bind(&record.benchmark)
712            .bind(&record.run_id)
713            .bind(status_str)
714            .bind(counts_json)
715            .bind(reasons_json)
716            .bind(&record.git_ref)
717            .bind(&record.git_sha)
718            .bind(record.wall_ms_cv)
719            .bind(record.flakiness_score)
720            .bind(record.created_at)
721            .execute(&self.pool)
722            .await
723            .map_err(|e| StoreError::QueryError(e.to_string()))?;
724
725        Ok(())
726    }
727
728    async fn list_verdicts(
729        &self,
730        project: &str,
731        query: &ListVerdictsQuery,
732    ) -> Result<ListVerdictsResponse, StoreError> {
733        let mut sql = "SELECT * FROM verdicts WHERE project = $1".to_string();
734        let mut params_count = 1;
735
736        if let Some(_bench) = &query.benchmark {
737            params_count += 1;
738            sql.push_str(&format!(" AND benchmark = ${}", params_count));
739        }
740
741        if let Some(_status) = &query.status {
742            params_count += 1;
743            sql.push_str(&format!(" AND status = ${}", params_count));
744        }
745
746        if let Some(_since) = &query.since {
747            params_count += 1;
748            sql.push_str(&format!(" AND created_at >= ${}", params_count));
749        }
750
751        if let Some(_until) = &query.until {
752            params_count += 1;
753            sql.push_str(&format!(" AND created_at <= ${}", params_count));
754        }
755
756        sql.push_str(" ORDER BY created_at DESC");
757
758        // Limit and offset
759        params_count += 1;
760        sql.push_str(&format!(" LIMIT ${}", params_count));
761        params_count += 1;
762        sql.push_str(&format!(" OFFSET ${}", params_count));
763
764        let mut q = sqlx::query(&sql).bind(project);
765
766        if let Some(bench) = &query.benchmark {
767            q = q.bind(bench);
768        }
769        if let Some(status) = &query.status {
770            q = q.bind(status.as_str());
771        }
772        if let Some(since) = &query.since {
773            q = q.bind(since);
774        }
775        if let Some(until) = &query.until {
776            q = q.bind(until);
777        }
778
779        q = q.bind(query.limit as i64);
780        q = q.bind(query.offset as i64);
781
782        let rows = q
783            .fetch_all(&self.pool)
784            .await
785            .map_err(|e| StoreError::QueryError(e.to_string()))?;
786
787        let mut verdicts = Vec::with_capacity(rows.len());
788        for row in rows {
789            verdicts.push(self.row_to_verdict(row)?);
790        }
791
792        // For total count
793        let count_sql = "SELECT COUNT(*) FROM verdicts WHERE project = $1";
794        let total: i64 = sqlx::query_scalar(count_sql)
795            .bind(project)
796            .fetch_one(&self.pool)
797            .await
798            .map_err(|e| StoreError::QueryError(e.to_string()))?;
799
800        Ok(ListVerdictsResponse {
801            verdicts,
802            pagination: PaginationInfo {
803                total: total as u64,
804                offset: query.offset,
805                limit: query.limit,
806                has_more: (query.offset + query.limit as u64) < total as u64,
807            },
808        })
809    }
810
811    async fn create_decision(&self, record: &DecisionRecord) -> Result<(), StoreError> {
812        let sql = r#"
813            INSERT INTO decisions (
814                id, schema_id, project, scenario, status, verdict, accepted_rules,
815                review_required, review_reasons, git_ref, git_sha, scenario_receipt,
816                tradeoff_receipt, artifact_index, created_at
817            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
818        "#;
819
820        let accepted_rules =
821            serde_json::to_value(&record.accepted_rules).map_err(StoreError::SerializationError)?;
822        let review_reasons =
823            serde_json::to_value(&record.review_reasons).map_err(StoreError::SerializationError)?;
824        let scenario_receipt = record
825            .scenario_receipt
826            .as_ref()
827            .map(serde_json::to_value)
828            .transpose()
829            .map_err(StoreError::SerializationError)?;
830        let tradeoff_receipt = serde_json::to_value(&record.tradeoff_receipt)
831            .map_err(StoreError::SerializationError)?;
832        let artifact_index = record
833            .artifact_index
834            .as_ref()
835            .map(serde_json::to_value)
836            .transpose()
837            .map_err(StoreError::SerializationError)?;
838
839        sqlx::query(sql)
840            .bind(&record.id)
841            .bind(&record.schema)
842            .bind(&record.project)
843            .bind(&record.scenario)
844            .bind(record.status.as_str())
845            .bind(record.verdict.as_str())
846            .bind(accepted_rules)
847            .bind(record.review_required)
848            .bind(review_reasons)
849            .bind(&record.git_ref)
850            .bind(&record.git_sha)
851            .bind(scenario_receipt)
852            .bind(tradeoff_receipt)
853            .bind(artifact_index)
854            .bind(record.created_at)
855            .execute(&self.pool)
856            .await
857            .map_err(|e| StoreError::QueryError(e.to_string()))?;
858
859        Ok(())
860    }
861
862    async fn latest_decision(&self, project: &str) -> Result<Option<DecisionRecord>, StoreError> {
863        let row = sqlx::query(
864            "SELECT * FROM decisions WHERE project = $1 ORDER BY created_at DESC LIMIT 1",
865        )
866        .bind(project)
867        .fetch_optional(&self.pool)
868        .await
869        .map_err(|e| StoreError::QueryError(e.to_string()))?;
870
871        row.map(|row| self.row_to_decision(row)).transpose()
872    }
873
874    async fn list_decisions(
875        &self,
876        project: &str,
877        query: &ListDecisionsQuery,
878    ) -> Result<ListDecisionsResponse, StoreError> {
879        let mut filter_sql = " FROM decisions WHERE project = $1".to_string();
880        let mut params_count = 1;
881
882        if query.scenario.is_some() {
883            params_count += 1;
884            filter_sql.push_str(&format!(" AND scenario = ${}", params_count));
885        }
886        if query.status.is_some() {
887            params_count += 1;
888            filter_sql.push_str(&format!(" AND status = ${}", params_count));
889        }
890        if query.verdict.is_some() {
891            params_count += 1;
892            filter_sql.push_str(&format!(" AND verdict = ${}", params_count));
893        }
894        if query.review_required.is_some() {
895            params_count += 1;
896            filter_sql.push_str(&format!(" AND review_required = ${}", params_count));
897        }
898        if let Some(accepted) = query.accepted {
899            if accepted {
900                filter_sql.push_str(" AND jsonb_array_length(accepted_rules) > 0");
901            } else {
902                filter_sql.push_str(" AND jsonb_array_length(accepted_rules) = 0");
903            }
904        }
905        if query.rule.is_some() {
906            params_count += 1;
907            filter_sql.push_str(&format!(" AND accepted_rules @> ${}::jsonb", params_count));
908        }
909
910        let count_sql = format!("SELECT COUNT(*){}", filter_sql);
911        let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql).bind(project);
912        if let Some(scenario) = &query.scenario {
913            count_query = count_query.bind(scenario);
914        }
915        if let Some(status) = query.status {
916            count_query = count_query.bind(status.as_str());
917        }
918        if let Some(verdict) = query.verdict {
919            count_query = count_query.bind(verdict.as_str());
920        }
921        if let Some(review_required) = query.review_required {
922            count_query = count_query.bind(review_required);
923        }
924        if let Some(rule) = &query.rule {
925            count_query = count_query.bind(serde_json::json!([rule]));
926        }
927        let total = count_query
928            .fetch_one(&self.pool)
929            .await
930            .map_err(|e| StoreError::QueryError(e.to_string()))?;
931
932        params_count += 1;
933        let limit_param = params_count;
934        params_count += 1;
935        let offset_param = params_count;
936        let sql = format!(
937            "SELECT *{} ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
938            filter_sql, limit_param, offset_param
939        );
940
941        let mut q = sqlx::query(&sql).bind(project);
942        if let Some(scenario) = &query.scenario {
943            q = q.bind(scenario);
944        }
945        if let Some(status) = query.status {
946            q = q.bind(status.as_str());
947        }
948        if let Some(verdict) = query.verdict {
949            q = q.bind(verdict.as_str());
950        }
951        if let Some(review_required) = query.review_required {
952            q = q.bind(review_required);
953        }
954        if let Some(rule) = &query.rule {
955            q = q.bind(serde_json::json!([rule]));
956        }
957        q = q.bind(query.limit as i64).bind(query.offset as i64);
958
959        let rows = q
960            .fetch_all(&self.pool)
961            .await
962            .map_err(|e| StoreError::QueryError(e.to_string()))?;
963
964        let mut decisions = Vec::with_capacity(rows.len());
965        for row in rows {
966            decisions.push(self.row_to_decision(row)?);
967        }
968
969        Ok(ListDecisionsResponse {
970            decisions,
971            pagination: PaginationInfo {
972                total: total as u64,
973                offset: query.offset,
974                limit: query.limit,
975                has_more: (query.offset + query.limit as u64) < total as u64,
976            },
977        })
978    }
979
980    async fn prune_decisions(
981        &self,
982        project: &str,
983        older_than: chrono::DateTime<chrono::Utc>,
984        dry_run: bool,
985    ) -> Result<PruneDecisionsResponse, StoreError> {
986        let ids = sqlx::query_scalar::<_, String>(
987            "SELECT id FROM decisions WHERE project = $1 AND created_at < $2 ORDER BY created_at ASC",
988        )
989        .bind(project)
990        .bind(older_than)
991        .fetch_all(&self.pool)
992        .await
993        .map_err(|e| StoreError::QueryError(e.to_string()))?;
994        let matched = ids.len() as u64;
995
996        let deleted = if dry_run {
997            0
998        } else {
999            sqlx::query("DELETE FROM decisions WHERE project = $1 AND created_at < $2")
1000                .bind(project)
1001                .bind(older_than)
1002                .execute(&self.pool)
1003                .await
1004                .map_err(|e| StoreError::QueryError(e.to_string()))?
1005                .rows_affected()
1006        };
1007
1008        Ok(PruneDecisionsResponse {
1009            project: project.to_string(),
1010            older_than,
1011            dry_run,
1012            matched,
1013            deleted,
1014            decision_ids: ids,
1015        })
1016    }
1017}
1018
1019impl PostgresStore {
1020    fn row_to_verdict(&self, row: sqlx::postgres::PgRow) -> Result<VerdictRecord, StoreError> {
1021        let status_str: String = row.get("status");
1022        let status = match status_str.as_str() {
1023            "pass" => VerdictStatus::Pass,
1024            "warn" => VerdictStatus::Warn,
1025            "fail" => VerdictStatus::Fail,
1026            "skip" => VerdictStatus::Skip,
1027            _ => VerdictStatus::Pass, // Default fallback
1028        };
1029
1030        let counts_json: serde_json::Value = row.get("counts");
1031        let counts = serde_json::from_value(counts_json).map_err(StoreError::SerializationError)?;
1032
1033        let reasons_json: serde_json::Value = row.get("reasons");
1034        let reasons =
1035            serde_json::from_value(reasons_json).map_err(StoreError::SerializationError)?;
1036
1037        Ok(VerdictRecord {
1038            schema: row.get("schema_id"), // Wait, I didn't add schema_id to verdicts table
1039            id: row.get("id"),
1040            project: row.get("project"),
1041            benchmark: row.get("benchmark"),
1042            run_id: row.get("run_id"),
1043            status,
1044            counts,
1045            reasons,
1046            git_ref: row.get("git_ref"),
1047            git_sha: row.get("git_sha"),
1048            wall_ms_cv: row.get("wall_ms_cv"),
1049            flakiness_score: row.get("flakiness_score"),
1050            created_at: row.get("created_at"),
1051        })
1052    }
1053
1054    fn row_to_decision(&self, row: sqlx::postgres::PgRow) -> Result<DecisionRecord, StoreError> {
1055        let status_str: String = row.get("status");
1056        let verdict_str: String = row.get("verdict");
1057        let accepted_rules_json: serde_json::Value = row.get("accepted_rules");
1058        let review_reasons_json: serde_json::Value = row.get("review_reasons");
1059        let scenario_receipt_json: Option<serde_json::Value> = row.get("scenario_receipt");
1060        let tradeoff_receipt_json: serde_json::Value = row.get("tradeoff_receipt");
1061        let artifact_index_json: Option<serde_json::Value> = row.get("artifact_index");
1062
1063        Ok(DecisionRecord {
1064            schema: row.get("schema_id"),
1065            id: row.get("id"),
1066            project: row.get("project"),
1067            scenario: row.get("scenario"),
1068            status: parse_metric_status(&status_str),
1069            verdict: parse_verdict_status(&verdict_str),
1070            accepted_rules: serde_json::from_value(accepted_rules_json)
1071                .map_err(StoreError::SerializationError)?,
1072            review_required: row.get("review_required"),
1073            review_reasons: serde_json::from_value(review_reasons_json)
1074                .map_err(StoreError::SerializationError)?,
1075            git_ref: row.get("git_ref"),
1076            git_sha: row.get("git_sha"),
1077            scenario_receipt: scenario_receipt_json
1078                .map(serde_json::from_value)
1079                .transpose()
1080                .map_err(StoreError::SerializationError)?,
1081            tradeoff_receipt: serde_json::from_value(tradeoff_receipt_json)
1082                .map_err(StoreError::SerializationError)?,
1083            artifact_index: artifact_index_json
1084                .map(serde_json::from_value)
1085                .transpose()
1086                .map_err(StoreError::SerializationError)?,
1087            created_at: row.get("created_at"),
1088        })
1089    }
1090}
1091
1092fn parse_metric_status(status: &str) -> MetricStatus {
1093    match status {
1094        "pass" => MetricStatus::Pass,
1095        "warn" => MetricStatus::Warn,
1096        "fail" => MetricStatus::Fail,
1097        "skip" => MetricStatus::Skip,
1098        _ => MetricStatus::Warn,
1099    }
1100}
1101
1102fn parse_verdict_status(status: &str) -> VerdictStatus {
1103    match status {
1104        "pass" => VerdictStatus::Pass,
1105        "warn" => VerdictStatus::Warn,
1106        "fail" => VerdictStatus::Fail,
1107        "skip" => VerdictStatus::Skip,
1108        _ => VerdictStatus::Warn,
1109    }
1110}
1111
1112#[async_trait]
1113impl AuditStore for PostgresStore {
1114    async fn log_event(&self, event: &AuditEvent) -> Result<(), StoreError> {
1115        let metadata_json =
1116            serde_json::to_value(&event.metadata).map_err(StoreError::SerializationError)?;
1117
1118        let sql = r#"
1119            INSERT INTO audit_events (
1120                id, timestamp, actor, action, resource_type, resource_id, project, metadata
1121            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1122        "#;
1123
1124        sqlx::query(sql)
1125            .bind(&event.id)
1126            .bind(event.timestamp)
1127            .bind(&event.actor)
1128            .bind(event.action.to_string())
1129            .bind(event.resource_type.to_string())
1130            .bind(&event.resource_id)
1131            .bind(&event.project)
1132            .bind(metadata_json)
1133            .execute(&self.pool)
1134            .await
1135            .map_err(|e| StoreError::QueryError(e.to_string()))?;
1136
1137        Ok(())
1138    }
1139
1140    async fn list_events(
1141        &self,
1142        query: &ListAuditEventsQuery,
1143    ) -> Result<ListAuditEventsResponse, StoreError> {
1144        let mut sql = "SELECT * FROM audit_events WHERE TRUE".to_string();
1145        let mut params_count = 0;
1146
1147        if query.project.is_some() {
1148            params_count += 1;
1149            sql.push_str(&format!(" AND project = ${}", params_count));
1150        }
1151        if query.action.is_some() {
1152            params_count += 1;
1153            sql.push_str(&format!(" AND action = ${}", params_count));
1154        }
1155        if query.resource_type.is_some() {
1156            params_count += 1;
1157            sql.push_str(&format!(" AND resource_type = ${}", params_count));
1158        }
1159        if query.actor.is_some() {
1160            params_count += 1;
1161            sql.push_str(&format!(" AND actor = ${}", params_count));
1162        }
1163        if query.since.is_some() {
1164            params_count += 1;
1165            sql.push_str(&format!(" AND timestamp >= ${}", params_count));
1166        }
1167        if query.until.is_some() {
1168            params_count += 1;
1169            sql.push_str(&format!(" AND timestamp <= ${}", params_count));
1170        }
1171
1172        sql.push_str(" ORDER BY timestamp DESC");
1173
1174        params_count += 1;
1175        sql.push_str(&format!(" LIMIT ${}", params_count));
1176        params_count += 1;
1177        sql.push_str(&format!(" OFFSET ${}", params_count));
1178
1179        let mut q = sqlx::query(&sql);
1180
1181        if let Some(ref project) = query.project {
1182            q = q.bind(project);
1183        }
1184        if let Some(ref action) = query.action {
1185            q = q.bind(action);
1186        }
1187        if let Some(ref resource_type) = query.resource_type {
1188            q = q.bind(resource_type);
1189        }
1190        if let Some(ref actor) = query.actor {
1191            q = q.bind(actor);
1192        }
1193        if let Some(ref since) = query.since {
1194            q = q.bind(since);
1195        }
1196        if let Some(ref until) = query.until {
1197            q = q.bind(until);
1198        }
1199
1200        q = q.bind(query.limit as i64);
1201        q = q.bind(query.offset as i64);
1202
1203        let rows = q
1204            .fetch_all(&self.pool)
1205            .await
1206            .map_err(|e| StoreError::QueryError(e.to_string()))?;
1207
1208        let mut events = Vec::with_capacity(rows.len());
1209        for row in rows {
1210            let action_str: String = row.get("action");
1211            let action = action_str
1212                .parse::<AuditAction>()
1213                .unwrap_or(AuditAction::Create);
1214
1215            let resource_type_str: String = row.get("resource_type");
1216            let resource_type = resource_type_str
1217                .parse::<AuditResourceType>()
1218                .unwrap_or(AuditResourceType::Baseline);
1219
1220            let metadata_json: serde_json::Value = row.get("metadata");
1221
1222            events.push(AuditEvent {
1223                id: row.get("id"),
1224                timestamp: row.get("timestamp"),
1225                actor: row.get("actor"),
1226                action,
1227                resource_type,
1228                resource_id: row.get("resource_id"),
1229                project: row.get("project"),
1230                metadata: metadata_json,
1231            });
1232        }
1233
1234        // Total count
1235        let count_sql = "SELECT COUNT(*) FROM audit_events WHERE TRUE";
1236        let total: i64 = sqlx::query_scalar(count_sql)
1237            .fetch_one(&self.pool)
1238            .await
1239            .map_err(|e| StoreError::QueryError(e.to_string()))?;
1240
1241        Ok(ListAuditEventsResponse {
1242            events,
1243            pagination: PaginationInfo {
1244                total: total as u64,
1245                offset: query.offset,
1246                limit: query.limit,
1247                has_more: (query.offset + query.limit as u64) < total as u64,
1248            },
1249        })
1250    }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256
1257    #[test]
1258    fn test_is_transient_pool_timed_out() {
1259        assert!(is_transient(&sqlx::Error::PoolTimedOut));
1260    }
1261
1262    #[test]
1263    fn test_is_transient_pool_closed() {
1264        assert!(!is_transient(&sqlx::Error::PoolClosed));
1265    }
1266
1267    #[test]
1268    fn test_is_transient_io_error() {
1269        let err = sqlx::Error::Io(std::io::Error::new(
1270            std::io::ErrorKind::ConnectionRefused,
1271            "connection refused",
1272        ));
1273        assert!(is_transient(&err));
1274    }
1275
1276    #[test]
1277    fn test_is_transient_non_transient() {
1278        let err = sqlx::Error::ColumnNotFound("missing".to_string());
1279        assert!(!is_transient(&err));
1280    }
1281
1282    #[test]
1283    fn test_postgres_schema_ids_fit_generated_ids() {
1284        let generated_id = crate::models::generate_ulid();
1285        assert!(generated_id.len() <= 64);
1286
1287        for table in ["baselines", "verdicts", "decisions"] {
1288            let table_ddl = postgres_schema_statements()
1289                .into_iter()
1290                .find(|statement| {
1291                    statement.contains(&format!("CREATE TABLE IF NOT EXISTS {}", table))
1292                })
1293                .expect("table schema should be present");
1294            assert!(table_ddl.contains("id VARCHAR(64) PRIMARY KEY"));
1295            assert!(!table_ddl.contains("id VARCHAR(26) PRIMARY KEY"));
1296        }
1297    }
1298}