Skip to main content

perfgate_server/storage/
sqlite.rs

1//! SQLite storage implementation for persistent baseline storage.
2
3use async_trait::async_trait;
4use rusqlite::{OptionalExtension, params};
5use std::path::Path;
6use std::sync::{Arc, Mutex};
7
8use super::{ArtifactStore, AuditStore, BaselineStore, StorageHealth};
9use crate::error::StoreError;
10use crate::models::{
11    AuditAction, AuditEvent, AuditResourceType, BaselineRecord, BaselineSource, BaselineVersion,
12    DecisionRecord, ListAuditEventsQuery, ListAuditEventsResponse, ListBaselinesQuery,
13    ListBaselinesResponse, ListDecisionsQuery, ListDecisionsResponse, ListVerdictsQuery,
14    ListVerdictsResponse, PaginationInfo, PruneDecisionsResponse, VerdictRecord,
15};
16use perfgate_types::{MetricStatus, VerdictCounts, VerdictStatus};
17
18/// SQLite storage backend for baselines.
19#[derive(Debug)]
20pub struct SqliteStore {
21    /// Path to the database file
22    _path: std::path::PathBuf,
23
24    /// Connection pool (simplified: single connection wrapped in Mutex)
25    conn: Arc<Mutex<rusqlite::Connection>>,
26
27    /// Optional artifact store for raw receipts
28    artifacts: Option<Arc<dyn ArtifactStore>>,
29}
30
31impl SqliteStore {
32    /// Opens or creates a SQLite database at the specified path.
33    pub fn new<P: AsRef<Path>>(
34        path: P,
35        artifacts: Option<Arc<dyn ArtifactStore>>,
36    ) -> Result<Self, StoreError> {
37        let path = path.as_ref().to_path_buf();
38
39        if let Some(parent) = path.parent().filter(|p| !p.exists()) {
40            std::fs::create_dir_all(parent)?;
41        }
42
43        let conn = open_configured_connection(&path)?;
44
45        let store = Self {
46            _path: path,
47            conn: Arc::new(Mutex::new(conn)),
48            artifacts,
49        };
50
51        store.initialize()?;
52        Ok(store)
53    }
54
55    /// Creates an in-memory SQLite database (for testing).
56    pub fn in_memory() -> Result<Self, StoreError> {
57        let conn = open_configured_memory_connection()?;
58
59        let store = Self {
60            _path: std::path::PathBuf::from(":memory:"),
61            conn: Arc::new(Mutex::new(conn)),
62            artifacts: None,
63        };
64
65        store.initialize()?;
66        Ok(store)
67    }
68}
69
70/// Opens a SQLite file database and applies the server's required pragmas.
71pub(crate) fn open_configured_connection<P: AsRef<Path>>(
72    path: P,
73) -> Result<rusqlite::Connection, StoreError> {
74    let path = path.as_ref();
75    let is_memory = path.as_os_str() == ":memory:";
76    let conn = rusqlite::Connection::open(path)?;
77    configure_pragmas(&conn, is_memory)?;
78    Ok(conn)
79}
80
81fn open_configured_memory_connection() -> Result<rusqlite::Connection, StoreError> {
82    let conn = rusqlite::Connection::open_in_memory()?;
83    configure_pragmas(&conn, true)?;
84    Ok(conn)
85}
86
87/// Configures SQLite pragmas for performance and concurrent access.
88///
89/// - `journal_mode=WAL`: enables write-ahead logging so readers do not
90///   block writers and vice-versa. Verified via the returned mode string
91///   so that silent fallbacks (e.g. read-only filesystem) become hard
92///   errors. Skipped for in-memory databases where WAL is not applicable.
93/// - `busy_timeout=5000`: waits up to 5 seconds when the database is
94///   locked instead of returning SQLITE_BUSY immediately.
95fn configure_pragmas(conn: &rusqlite::Connection, is_memory: bool) -> Result<(), StoreError> {
96    conn.execute_batch("PRAGMA busy_timeout=5000;")?;
97
98    // GOTCHA: In-memory SQLite databases cannot use WAL mode. Executing
99    // `PRAGMA journal_mode=WAL` on an in-memory DB silently succeeds but
100    // returns "memory" instead of "wal". You MUST check the returned
101    // string — a bare `execute_batch("PRAGMA journal_mode=WAL")` will
102    // appear to work but leave you without WAL's concurrency benefits.
103    if !is_memory {
104        let mode: String = conn.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;
105        if mode.to_lowercase() != "wal" {
106            return Err(StoreError::Other(format!(
107                "failed to enable WAL journal mode (got '{mode}')"
108            )));
109        }
110    }
111
112    Ok(())
113}
114
115impl SqliteStore {
116    fn initialize(&self) -> Result<(), StoreError> {
117        let conn = self
118            .conn
119            .lock()
120            .map_err(|e| StoreError::LockError(e.to_string()))?;
121
122        conn.execute_batch(
123            r#"
124            CREATE TABLE IF NOT EXISTS baselines (
125                id TEXT PRIMARY KEY,
126                project TEXT NOT NULL,
127                benchmark TEXT NOT NULL,
128                version TEXT NOT NULL,
129                git_ref TEXT,
130                git_sha TEXT,
131                receipt TEXT,
132                artifact_path TEXT,
133                metadata TEXT NOT NULL DEFAULT '{}',
134                tags TEXT NOT NULL DEFAULT '[]',
135                source TEXT NOT NULL DEFAULT 'upload',
136                content_hash TEXT NOT NULL,
137                deleted INTEGER NOT NULL DEFAULT 0,
138                created_at TEXT NOT NULL,
139                updated_at TEXT NOT NULL,
140                UNIQUE(project, benchmark, version)
141            );
142            CREATE INDEX IF NOT EXISTS idx_baselines_project_benchmark ON baselines(project, benchmark);
143            CREATE INDEX IF NOT EXISTS idx_baselines_created_at ON baselines(created_at DESC);
144
145            CREATE TABLE IF NOT EXISTS verdicts (
146                id TEXT PRIMARY KEY,
147                schema_id TEXT NOT NULL,
148                project TEXT NOT NULL,
149                benchmark TEXT NOT NULL,
150                run_id TEXT NOT NULL,
151                status TEXT NOT NULL,
152                counts TEXT NOT NULL,
153                reasons TEXT NOT NULL,
154                git_ref TEXT,
155                git_sha TEXT,
156                wall_ms_cv REAL,
157                flakiness_score REAL,
158                created_at TEXT NOT NULL
159            );
160            CREATE INDEX IF NOT EXISTS idx_verdicts_project_benchmark ON verdicts(project, benchmark);
161            CREATE INDEX IF NOT EXISTS idx_verdicts_created_at ON verdicts(created_at DESC);
162
163            CREATE TABLE IF NOT EXISTS decisions (
164                id TEXT PRIMARY KEY,
165                schema_id TEXT NOT NULL,
166                project TEXT NOT NULL,
167                scenario TEXT,
168                status TEXT NOT NULL,
169                verdict TEXT NOT NULL,
170                accepted_rules TEXT NOT NULL,
171                review_required INTEGER NOT NULL DEFAULT 0,
172                review_reasons TEXT NOT NULL,
173                git_ref TEXT,
174                git_sha TEXT,
175                scenario_receipt TEXT,
176                tradeoff_receipt TEXT NOT NULL,
177                artifact_index TEXT,
178                created_at TEXT NOT NULL
179            );
180            CREATE INDEX IF NOT EXISTS idx_decisions_project_created ON decisions(project, created_at DESC);
181            CREATE INDEX IF NOT EXISTS idx_decisions_project_scenario ON decisions(project, scenario);
182
183            CREATE TABLE IF NOT EXISTS audit_events (
184                id TEXT PRIMARY KEY,
185                timestamp TEXT NOT NULL,
186                actor TEXT NOT NULL,
187                action TEXT NOT NULL,
188                resource_type TEXT NOT NULL,
189                resource_id TEXT NOT NULL,
190                project TEXT NOT NULL,
191                metadata TEXT NOT NULL DEFAULT '{}'
192            );
193            CREATE INDEX IF NOT EXISTS idx_audit_events_project ON audit_events(project);
194            CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events(timestamp DESC);
195            CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action);
196            "#,
197        )?;
198        let _ = conn.execute("ALTER TABLE verdicts ADD COLUMN wall_ms_cv REAL", []);
199        let _ = conn.execute("ALTER TABLE verdicts ADD COLUMN flakiness_score REAL", []);
200        Ok(())
201    }
202
203    fn row_to_record_tuple(
204        row: &rusqlite::Row,
205    ) -> Result<(BaselineRecord, Option<String>), rusqlite::Error> {
206        let created_at_str: String = row.get(13)?;
207        let updated_at_str: String = row.get(14)?;
208
209        let receipt_json: Option<String> = row.get(6)?;
210        let receipt = if let Some(json) = receipt_json {
211            serde_json::from_str(&json).unwrap_or_else(|_| Self::placeholder_receipt())
212        } else {
213            Self::placeholder_receipt()
214        };
215
216        let record = BaselineRecord {
217            schema: crate::models::BASELINE_SCHEMA_V1.to_string(),
218            id: row.get(0)?,
219            project: row.get(1)?,
220            benchmark: row.get(2)?,
221            version: row.get(3)?,
222            git_ref: row.get(4)?,
223            git_sha: row.get(5)?,
224            receipt,
225            metadata: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
226            tags: serde_json::from_str(&row.get::<_, String>(9)?).unwrap_or_default(),
227            created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str)
228                .map(|dt| dt.with_timezone(&chrono::Utc))
229                .unwrap_or_else(|_| chrono::Utc::now()),
230            updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at_str)
231                .map(|dt| dt.with_timezone(&chrono::Utc))
232                .unwrap_or_else(|_| chrono::Utc::now()),
233            content_hash: row.get(11)?,
234            source: match row.get::<_, String>(10)?.as_str() {
235                "promote" => BaselineSource::Promote,
236                "migrate" => BaselineSource::Migrate,
237                "rollback" => BaselineSource::Rollback,
238                _ => BaselineSource::Upload,
239            },
240            deleted: row.get::<_, i64>(12)? != 0,
241        };
242
243        Ok((record, row.get(7)?))
244    }
245
246    fn placeholder_receipt() -> perfgate_types::RunReceipt {
247        serde_json::from_value(serde_json::json!({
248            "schema": "perfgate.run.v1",
249            "tool": {"name": "placeholder", "version": "0"},
250            "run": {
251                "id": "placeholder",
252                "started_at": "1970-01-01T00:00:00Z",
253                "ended_at": "1970-01-01T00:00:00Z",
254                "host": {"os": "unknown", "arch": "unknown"}
255            },
256            "bench": {"name": "placeholder", "command": [], "repeat": 0, "warmup": 0},
257            "samples": [],
258            "stats": {"wall_ms": {"median": 0, "min": 0, "max": 0}}
259        }))
260        .unwrap()
261    }
262
263    async fn store_artifact(&self, record: &BaselineRecord) -> Result<Option<String>, StoreError> {
264        if let Some(store) = &self.artifacts {
265            let path = format!(
266                "{}/{}/{}.json",
267                record.project, record.benchmark, record.version
268            );
269            let data =
270                serde_json::to_vec(&record.receipt).map_err(StoreError::SerializationError)?;
271            store.put(&path, data).await?;
272            Ok(Some(path))
273        } else {
274            Ok(None)
275        }
276    }
277
278    async fn load_artifact(
279        &self,
280        path: Option<String>,
281        mut record: BaselineRecord,
282    ) -> Result<BaselineRecord, StoreError> {
283        if let (Some(store), Some(path)) = (&self.artifacts, path) {
284            let data = store.get(&path).await?;
285            record.receipt =
286                serde_json::from_slice(&data).map_err(StoreError::SerializationError)?;
287        }
288        Ok(record)
289    }
290}
291
292#[async_trait]
293impl BaselineStore for SqliteStore {
294    async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
295        let artifact_path = self.store_artifact(record).await?;
296        let receipt_json = if artifact_path.is_none() {
297            Some(serde_json::to_string(&record.receipt)?)
298        } else {
299            None
300        };
301
302        let conn = self
303            .conn
304            .lock()
305            .map_err(|e| StoreError::LockError(e.to_string()))?;
306        conn.execute(
307            r#"
308            INSERT INTO baselines (
309                id, project, benchmark, version, git_ref, git_sha,
310                receipt, artifact_path, metadata, tags, source, content_hash,
311                deleted, created_at, updated_at
312            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
313            "#,
314            params![
315                record.id,
316                record.project,
317                record.benchmark,
318                record.version,
319                record.git_ref,
320                record.git_sha,
321                receipt_json,
322                artifact_path,
323                serde_json::to_string(&record.metadata)?,
324                serde_json::to_string(&record.tags)?,
325                format!("{:?}", record.source).to_lowercase(),
326                record.content_hash,
327                if record.deleted { 1i64 } else { 0i64 },
328                record.created_at.to_rfc3339(),
329                record.updated_at.to_rfc3339(),
330            ],
331        )
332        .map_err(|e| match &e {
333            rusqlite::Error::SqliteFailure(err, _)
334                if err.code == rusqlite::ErrorCode::ConstraintViolation =>
335            {
336                StoreError::AlreadyExists(format!(
337                    "project={}, benchmark={}, version={}",
338                    record.project, record.benchmark, record.version
339                ))
340            }
341            _ => StoreError::SqliteError(e),
342        })?;
343        Ok(())
344    }
345
346    async fn get(
347        &self,
348        project: &str,
349        benchmark: &str,
350        version: &str,
351    ) -> Result<Option<BaselineRecord>, StoreError> {
352        let res = {
353            let conn = self
354                .conn
355                .lock()
356                .map_err(|e| StoreError::LockError(e.to_string()))?;
357            let mut stmt = conn.prepare(
358                "SELECT * FROM baselines WHERE project = ?1 AND benchmark = ?2 AND version = ?3 AND deleted = 0"
359            )?;
360            stmt.query_row(
361                params![project, benchmark, version],
362                Self::row_to_record_tuple,
363            )
364            .optional()?
365        };
366
367        match res {
368            Some((record, path)) => Ok(Some(self.load_artifact(path, record).await?)),
369            None => Ok(None),
370        }
371    }
372
373    async fn get_latest(
374        &self,
375        project: &str,
376        benchmark: &str,
377    ) -> Result<Option<BaselineRecord>, StoreError> {
378        let res = {
379            let conn = self
380                .conn
381                .lock()
382                .map_err(|e| StoreError::LockError(e.to_string()))?;
383            let mut stmt = conn.prepare(
384                "SELECT * FROM baselines WHERE project = ?1 AND benchmark = ?2 AND deleted = 0 ORDER BY created_at DESC LIMIT 1"
385            )?;
386            stmt.query_row(params![project, benchmark], Self::row_to_record_tuple)
387                .optional()?
388        };
389
390        match res {
391            Some((record, path)) => Ok(Some(self.load_artifact(path, record).await?)),
392            None => Ok(None),
393        }
394    }
395
396    async fn list(
397        &self,
398        project: &str,
399        query: &ListBaselinesQuery,
400    ) -> Result<ListBaselinesResponse, StoreError> {
401        let (records_with_paths, total) = {
402            let conn = self
403                .conn
404                .lock()
405                .map_err(|e| StoreError::LockError(e.to_string()))?;
406            let mut sql =
407                String::from("SELECT * FROM baselines WHERE project = ?1 AND deleted = 0");
408            let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(project.to_string())];
409
410            if let Some(ref b) = query.benchmark {
411                sql.push_str(" AND benchmark = ?");
412                params.push(Box::new(b.clone()));
413            }
414
415            let count_sql = format!("SELECT COUNT(*) FROM ({})", sql);
416            let total: u64 =
417                conn.query_row(&count_sql, rusqlite::params_from_iter(params.iter()), |r| {
418                    r.get(0)
419                })?;
420
421            sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
422            params.push(Box::new(query.limit as i64));
423            params.push(Box::new(query.offset as i64));
424
425            let mut stmt = conn.prepare(&sql)?;
426            let rows = stmt
427                .query_map(
428                    rusqlite::params_from_iter(params.iter()),
429                    Self::row_to_record_tuple,
430                )?
431                .collect::<Result<Vec<_>, _>>()?;
432            (rows, total)
433        };
434
435        let mut baselines = Vec::with_capacity(records_with_paths.len());
436        for (mut record, path) in records_with_paths {
437            if query.include_receipt {
438                record = self.load_artifact(path, record).await?;
439            }
440            baselines.push(record.into());
441        }
442
443        let count = baselines.len() as u64;
444
445        Ok(ListBaselinesResponse {
446            baselines,
447            pagination: PaginationInfo {
448                total,
449                limit: query.limit,
450                offset: query.offset,
451                has_more: (query.offset + count) < total,
452            },
453        })
454    }
455
456    async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
457        let artifact_path = self.store_artifact(record).await?;
458        let receipt_json = if artifact_path.is_none() {
459            Some(serde_json::to_string(&record.receipt)?)
460        } else {
461            None
462        };
463
464        let conn = self
465            .conn
466            .lock()
467            .map_err(|e| StoreError::LockError(e.to_string()))?;
468        conn.execute(
469            "UPDATE baselines SET git_ref=?1, git_sha=?2, receipt=?3, artifact_path=?4, metadata=?5, tags=?6, source=?7, content_hash=?8, updated_at=?9 WHERE project=?10 AND benchmark=?11 AND version=?12",
470            params![
471                record.git_ref, record.git_sha, receipt_json, artifact_path,
472                serde_json::to_string(&record.metadata)?, serde_json::to_string(&record.tags)?,
473                format!("{:?}", record.source).to_lowercase(), record.content_hash,
474                record.updated_at.to_rfc3339(), record.project, record.benchmark, record.version
475            ]
476        )?;
477        Ok(())
478    }
479
480    async fn delete(
481        &self,
482        project: &str,
483        benchmark: &str,
484        version: &str,
485    ) -> Result<bool, StoreError> {
486        let conn = self
487            .conn
488            .lock()
489            .map_err(|e| StoreError::LockError(e.to_string()))?;
490        let n = conn.execute("UPDATE baselines SET deleted = 1, updated_at = ?1 WHERE project = ?2 AND benchmark = ?3 AND version = ?4 AND deleted = 0",
491            params![chrono::Utc::now().to_rfc3339(), project, benchmark, version])?;
492        Ok(n > 0)
493    }
494
495    async fn hard_delete(
496        &self,
497        project: &str,
498        benchmark: &str,
499        version: &str,
500    ) -> Result<bool, StoreError> {
501        let conn = self
502            .conn
503            .lock()
504            .map_err(|e| StoreError::LockError(e.to_string()))?;
505        let n = conn.execute(
506            "DELETE FROM baselines WHERE project = ?1 AND benchmark = ?2 AND version = ?3",
507            params![project, benchmark, version],
508        )?;
509        Ok(n > 0)
510    }
511
512    async fn list_versions(
513        &self,
514        project: &str,
515        benchmark: &str,
516    ) -> Result<Vec<BaselineVersion>, StoreError> {
517        let conn = self
518            .conn
519            .lock()
520            .map_err(|e| StoreError::LockError(e.to_string()))?;
521        let mut stmt = conn.prepare("SELECT version, git_ref, git_sha, source, created_at FROM baselines WHERE project = ?1 AND benchmark = ?2 AND deleted = 0 ORDER BY created_at DESC")?;
522        let mut versions: Vec<BaselineVersion> = stmt
523            .query_map(params![project, benchmark], |row| {
524                let created_at_str: String = row.get(4)?;
525                Ok(BaselineVersion {
526                    version: row.get(0)?,
527                    git_ref: row.get(1)?,
528                    git_sha: row.get(2)?,
529                    created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str)
530                        .map(|dt| dt.with_timezone(&chrono::Utc))
531                        .unwrap_or_else(|_| chrono::Utc::now()),
532                    created_by: None,
533                    is_current: false,
534                    source: match row.get::<_, String>(3)?.as_str() {
535                        "promote" => BaselineSource::Promote,
536                        "migrate" => BaselineSource::Migrate,
537                        "rollback" => BaselineSource::Rollback,
538                        _ => BaselineSource::Upload,
539                    },
540                })
541            })?
542            .collect::<Result<Vec<_>, _>>()?;
543        if let Some(first) = versions.first_mut() {
544            first.is_current = true;
545        }
546        Ok(versions)
547    }
548
549    async fn health_check(&self) -> Result<StorageHealth, StoreError> {
550        let conn = self
551            .conn
552            .lock()
553            .map_err(|e| StoreError::LockError(e.to_string()))?;
554        match conn.query_row("SELECT 1", [], |_| Ok(())) {
555            Ok(_) => Ok(StorageHealth::Healthy),
556            Err(error) => Err(StoreError::QueryError(error.to_string())),
557        }
558    }
559
560    fn backend_type(&self) -> &'static str {
561        "sqlite"
562    }
563
564    async fn create_verdict(&self, record: &VerdictRecord) -> Result<(), StoreError> {
565        let conn = self
566            .conn
567            .lock()
568            .map_err(|e| StoreError::LockError(e.to_string()))?;
569
570        let counts_json =
571            serde_json::to_string(&record.counts).map_err(StoreError::SerializationError)?;
572        let reasons_json =
573            serde_json::to_string(&record.reasons).map_err(StoreError::SerializationError)?;
574        let status_str = record.status.as_str();
575        let created_at_str = record.created_at.to_rfc3339();
576
577        conn.execute(
578            r#"
579            INSERT INTO verdicts (
580                id, schema_id, project, benchmark, run_id, status, counts, reasons,
581                git_ref, git_sha, wall_ms_cv, flakiness_score, created_at
582            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
583            "#,
584            params![
585                record.id,
586                record.schema,
587                record.project,
588                record.benchmark,
589                record.run_id,
590                status_str,
591                counts_json,
592                reasons_json,
593                record.git_ref,
594                record.git_sha,
595                record.wall_ms_cv,
596                record.flakiness_score,
597                created_at_str
598            ],
599        )?;
600
601        Ok(())
602    }
603
604    async fn list_verdicts(
605        &self,
606        project: &str,
607        query: &ListVerdictsQuery,
608    ) -> Result<ListVerdictsResponse, StoreError> {
609        let mut sql = "SELECT * FROM verdicts WHERE project = ?".to_string();
610        let mut params_vec: Vec<rusqlite::types::Value> = vec![project.to_string().into()];
611
612        if let Some(bench) = &query.benchmark {
613            sql.push_str(" AND benchmark = ?");
614            params_vec.push(bench.clone().into());
615        }
616
617        if let Some(status) = &query.status {
618            sql.push_str(" AND status = ?");
619            params_vec.push(status.as_str().to_string().into());
620        }
621
622        if let Some(since) = &query.since {
623            sql.push_str(" AND created_at >= ?");
624            params_vec.push(since.to_rfc3339().into());
625        }
626
627        if let Some(until) = &query.until {
628            sql.push_str(" AND created_at <= ?");
629            params_vec.push(until.to_rfc3339().into());
630        }
631
632        sql.push_str(" ORDER BY created_at DESC");
633
634        // Limit and offset
635        sql.push_str(" LIMIT ? OFFSET ?");
636        params_vec.push((query.limit as i64).into());
637        params_vec.push((query.offset as i64).into());
638
639        let conn = self
640            .conn
641            .lock()
642            .map_err(|e| StoreError::LockError(e.to_string()))?;
643
644        let mut stmt = conn
645            .prepare(&sql)
646            .map_err(|e| StoreError::QueryError(e.to_string()))?;
647        let rows = stmt
648            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
649                Self::row_to_verdict(row)
650            })
651            .map_err(|e| StoreError::QueryError(e.to_string()))?;
652
653        let mut verdicts = Vec::new();
654        for row in rows {
655            verdicts.push(row?);
656        }
657
658        // For total count
659        let count_sql = "SELECT COUNT(*) FROM verdicts WHERE project = ?";
660        let total: i64 = conn.query_row(count_sql, params![project], |row| row.get(0))?;
661
662        Ok(ListVerdictsResponse {
663            verdicts,
664            pagination: PaginationInfo {
665                total: total as u64,
666                offset: query.offset,
667                limit: query.limit,
668                has_more: (query.offset + query.limit as u64) < total as u64,
669            },
670        })
671    }
672
673    async fn create_decision(&self, record: &DecisionRecord) -> Result<(), StoreError> {
674        let accepted_rules_json = serde_json::to_string(&record.accepted_rules)
675            .map_err(StoreError::SerializationError)?;
676        let review_reasons_json = serde_json::to_string(&record.review_reasons)
677            .map_err(StoreError::SerializationError)?;
678        let scenario_receipt_json = record
679            .scenario_receipt
680            .as_ref()
681            .map(serde_json::to_string)
682            .transpose()
683            .map_err(StoreError::SerializationError)?;
684        let tradeoff_receipt_json = serde_json::to_string(&record.tradeoff_receipt)
685            .map_err(StoreError::SerializationError)?;
686        let artifact_index_json = record
687            .artifact_index
688            .as_ref()
689            .map(serde_json::to_string)
690            .transpose()
691            .map_err(StoreError::SerializationError)?;
692
693        let conn = self
694            .conn
695            .lock()
696            .map_err(|e| StoreError::LockError(e.to_string()))?;
697
698        conn.execute(
699            r#"
700            INSERT INTO decisions (
701                id, schema_id, project, scenario, status, verdict, accepted_rules,
702                review_required, review_reasons, git_ref, git_sha, scenario_receipt,
703                tradeoff_receipt, artifact_index, created_at
704            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
705            "#,
706            params![
707                record.id,
708                record.schema,
709                record.project,
710                record.scenario,
711                record.status.as_str(),
712                record.verdict.as_str(),
713                accepted_rules_json,
714                if record.review_required { 1i64 } else { 0i64 },
715                review_reasons_json,
716                record.git_ref,
717                record.git_sha,
718                scenario_receipt_json,
719                tradeoff_receipt_json,
720                artifact_index_json,
721                record.created_at.to_rfc3339(),
722            ],
723        )?;
724
725        Ok(())
726    }
727
728    async fn latest_decision(&self, project: &str) -> Result<Option<DecisionRecord>, StoreError> {
729        let conn = self
730            .conn
731            .lock()
732            .map_err(|e| StoreError::LockError(e.to_string()))?;
733        conn.query_row(
734            "SELECT * FROM decisions WHERE project = ?1 ORDER BY created_at DESC LIMIT 1",
735            params![project],
736            Self::row_to_decision,
737        )
738        .optional()
739        .map_err(StoreError::from)
740    }
741
742    async fn list_decisions(
743        &self,
744        project: &str,
745        query: &ListDecisionsQuery,
746    ) -> Result<ListDecisionsResponse, StoreError> {
747        let mut sql = "SELECT * FROM decisions WHERE project = ?".to_string();
748        let mut params_vec: Vec<rusqlite::types::Value> = vec![project.to_string().into()];
749
750        if let Some(scenario) = &query.scenario {
751            sql.push_str(" AND scenario = ?");
752            params_vec.push(scenario.clone().into());
753        }
754        if let Some(status) = query.status {
755            sql.push_str(" AND status = ?");
756            params_vec.push(status.as_str().to_string().into());
757        }
758        if let Some(verdict) = query.verdict {
759            sql.push_str(" AND verdict = ?");
760            params_vec.push(verdict.as_str().to_string().into());
761        }
762        if let Some(review_required) = query.review_required {
763            sql.push_str(" AND review_required = ?");
764            params_vec.push((if review_required { 1i64 } else { 0i64 }).into());
765        }
766        if let Some(accepted) = query.accepted {
767            if accepted {
768                sql.push_str(" AND accepted_rules != '[]'");
769            } else {
770                sql.push_str(" AND accepted_rules = '[]'");
771            }
772        }
773        if let Some(rule) = &query.rule {
774            sql.push_str(" AND instr(accepted_rules, ?) > 0");
775            let encoded_rule =
776                serde_json::to_string(rule).map_err(StoreError::SerializationError)?;
777            params_vec.push(encoded_rule.into());
778        }
779
780        let count_sql = format!("SELECT COUNT(*) FROM ({})", sql);
781        sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
782        params_vec.push((query.limit as i64).into());
783        params_vec.push((query.offset as i64).into());
784
785        let conn = self
786            .conn
787            .lock()
788            .map_err(|e| StoreError::LockError(e.to_string()))?;
789        let count_params: Vec<rusqlite::types::Value> =
790            params_vec[..params_vec.len().saturating_sub(2)].to_vec();
791        let total: i64 = conn
792            .query_row(
793                &count_sql,
794                rusqlite::params_from_iter(count_params.iter()),
795                |row| row.get(0),
796            )
797            .map_err(|e| StoreError::QueryError(e.to_string()))?;
798
799        let mut stmt = conn
800            .prepare(&sql)
801            .map_err(|e| StoreError::QueryError(e.to_string()))?;
802        let rows = stmt
803            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
804                Self::row_to_decision(row)
805            })
806            .map_err(|e| StoreError::QueryError(e.to_string()))?;
807
808        let mut decisions = Vec::new();
809        for row in rows {
810            decisions.push(row?);
811        }
812
813        Ok(ListDecisionsResponse {
814            decisions,
815            pagination: PaginationInfo {
816                total: total as u64,
817                offset: query.offset,
818                limit: query.limit,
819                has_more: (query.offset + query.limit as u64) < total as u64,
820            },
821        })
822    }
823
824    async fn prune_decisions(
825        &self,
826        project: &str,
827        older_than: chrono::DateTime<chrono::Utc>,
828        dry_run: bool,
829    ) -> Result<PruneDecisionsResponse, StoreError> {
830        let cutoff = older_than.to_rfc3339();
831        let conn = self
832            .conn
833            .lock()
834            .map_err(|e| StoreError::LockError(e.to_string()))?;
835
836        let mut stmt = conn
837            .prepare(
838                "SELECT id FROM decisions WHERE project = ?1 AND created_at < ?2 ORDER BY created_at ASC",
839            )
840            .map_err(|e| StoreError::QueryError(e.to_string()))?;
841        let rows = stmt
842            .query_map(params![project, cutoff], |row| row.get::<_, String>(0))
843            .map_err(|e| StoreError::QueryError(e.to_string()))?;
844        let mut decision_ids = Vec::new();
845        for row in rows {
846            decision_ids.push(row.map_err(StoreError::from)?);
847        }
848        let matched = decision_ids.len() as u64;
849        drop(stmt);
850
851        let deleted = if dry_run {
852            0
853        } else {
854            conn.execute(
855                "DELETE FROM decisions WHERE project = ?1 AND created_at < ?2",
856                params![project, older_than.to_rfc3339()],
857            )
858            .map_err(|e| StoreError::QueryError(e.to_string()))? as u64
859        };
860
861        Ok(PruneDecisionsResponse {
862            project: project.to_string(),
863            older_than,
864            dry_run,
865            matched,
866            deleted,
867            decision_ids,
868        })
869    }
870}
871
872impl SqliteStore {
873    fn row_to_verdict(row: &rusqlite::Row) -> Result<VerdictRecord, rusqlite::Error> {
874        let status_str: String = row.get(5)?;
875        let status = match status_str.as_str() {
876            "pass" => VerdictStatus::Pass,
877            "warn" => VerdictStatus::Warn,
878            "fail" => VerdictStatus::Fail,
879            "skip" => VerdictStatus::Skip,
880            _ => VerdictStatus::Pass,
881        };
882
883        let counts_json: String = row.get(6)?;
884        let counts = serde_json::from_str(&counts_json).unwrap_or(VerdictCounts {
885            pass: 0,
886            warn: 0,
887            fail: 0,
888            skip: 0,
889        });
890
891        let reasons_json: String = row.get(7)?;
892        let reasons = serde_json::from_str(&reasons_json).unwrap_or_default();
893
894        let created_at_str: String = row.get(12)?;
895        let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
896            .map(|dt| dt.with_timezone(&chrono::Utc))
897            .unwrap_or_else(|_| chrono::Utc::now());
898
899        Ok(VerdictRecord {
900            id: row.get(0)?,
901            schema: row.get(1)?,
902            project: row.get(2)?,
903            benchmark: row.get(3)?,
904            run_id: row.get(4)?,
905            status,
906            counts,
907            reasons,
908            git_ref: row.get(8)?,
909            git_sha: row.get(9)?,
910            wall_ms_cv: row.get(10)?,
911            flakiness_score: row.get(11)?,
912            created_at,
913        })
914    }
915
916    fn row_to_decision(row: &rusqlite::Row) -> Result<DecisionRecord, rusqlite::Error> {
917        let status = match row.get::<_, String>(4)?.as_str() {
918            "pass" => MetricStatus::Pass,
919            "warn" => MetricStatus::Warn,
920            "fail" => MetricStatus::Fail,
921            "skip" => MetricStatus::Skip,
922            _ => MetricStatus::Warn,
923        };
924        let verdict = match row.get::<_, String>(5)?.as_str() {
925            "pass" => VerdictStatus::Pass,
926            "warn" => VerdictStatus::Warn,
927            "fail" => VerdictStatus::Fail,
928            "skip" => VerdictStatus::Skip,
929            _ => VerdictStatus::Warn,
930        };
931        let created_at_str: String = row.get(14)?;
932        let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
933            .map(|dt| dt.with_timezone(&chrono::Utc))
934            .unwrap_or_else(|_| chrono::Utc::now());
935        let scenario_receipt_json: Option<String> = row.get(11)?;
936        let artifact_index_json: Option<String> = row.get(13)?;
937
938        Ok(DecisionRecord {
939            id: row.get(0)?,
940            schema: row.get(1)?,
941            project: row.get(2)?,
942            scenario: row.get(3)?,
943            status,
944            verdict,
945            accepted_rules: serde_json::from_str(&row.get::<_, String>(6)?).unwrap_or_default(),
946            review_required: row.get::<_, i64>(7)? != 0,
947            review_reasons: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
948            git_ref: row.get(9)?,
949            git_sha: row.get(10)?,
950            scenario_receipt: scenario_receipt_json
951                .as_deref()
952                .and_then(|json| serde_json::from_str(json).ok()),
953            tradeoff_receipt: serde_json::from_str(&row.get::<_, String>(12)?)
954                .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?,
955            artifact_index: artifact_index_json
956                .as_deref()
957                .and_then(|json| serde_json::from_str(json).ok()),
958            created_at,
959        })
960    }
961}
962
963#[async_trait]
964impl AuditStore for SqliteStore {
965    async fn log_event(&self, event: &AuditEvent) -> Result<(), StoreError> {
966        let metadata_json =
967            serde_json::to_string(&event.metadata).map_err(StoreError::SerializationError)?;
968
969        let conn = self
970            .conn
971            .lock()
972            .map_err(|e| StoreError::LockError(e.to_string()))?;
973        conn.execute(
974            r#"
975            INSERT INTO audit_events (
976                id, timestamp, actor, action, resource_type, resource_id, project, metadata
977            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
978            "#,
979            params![
980                event.id,
981                event.timestamp.to_rfc3339(),
982                event.actor,
983                event.action.to_string(),
984                event.resource_type.to_string(),
985                event.resource_id,
986                event.project,
987                metadata_json,
988            ],
989        )?;
990
991        Ok(())
992    }
993
994    async fn list_events(
995        &self,
996        query: &ListAuditEventsQuery,
997    ) -> Result<ListAuditEventsResponse, StoreError> {
998        let mut sql = "SELECT * FROM audit_events WHERE 1=1".to_string();
999        let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
1000
1001        if let Some(ref project) = query.project {
1002            sql.push_str(" AND project = ?");
1003            params_vec.push(project.clone().into());
1004        }
1005
1006        if let Some(ref action) = query.action {
1007            sql.push_str(" AND action = ?");
1008            params_vec.push(action.clone().into());
1009        }
1010
1011        if let Some(ref resource_type) = query.resource_type {
1012            sql.push_str(" AND resource_type = ?");
1013            params_vec.push(resource_type.clone().into());
1014        }
1015
1016        if let Some(ref actor) = query.actor {
1017            sql.push_str(" AND actor = ?");
1018            params_vec.push(actor.clone().into());
1019        }
1020
1021        if let Some(ref since) = query.since {
1022            sql.push_str(" AND timestamp >= ?");
1023            params_vec.push(since.to_rfc3339().into());
1024        }
1025
1026        if let Some(ref until) = query.until {
1027            sql.push_str(" AND timestamp <= ?");
1028            params_vec.push(until.to_rfc3339().into());
1029        }
1030
1031        // Count before pagination
1032        let count_sql = format!("SELECT COUNT(*) FROM ({})", sql);
1033
1034        sql.push_str(" ORDER BY timestamp DESC LIMIT ? OFFSET ?");
1035        params_vec.push((query.limit as i64).into());
1036        params_vec.push((query.offset as i64).into());
1037
1038        let conn = self
1039            .conn
1040            .lock()
1041            .map_err(|e| StoreError::LockError(e.to_string()))?;
1042
1043        // Get total (without LIMIT/OFFSET params)
1044        let count_params: Vec<rusqlite::types::Value> =
1045            params_vec[..params_vec.len().saturating_sub(2)].to_vec();
1046        let total: i64 = conn
1047            .query_row(
1048                &count_sql,
1049                rusqlite::params_from_iter(count_params.iter()),
1050                |row| row.get(0),
1051            )
1052            .map_err(|e| StoreError::QueryError(e.to_string()))?;
1053
1054        let mut stmt = conn
1055            .prepare(&sql)
1056            .map_err(|e| StoreError::QueryError(e.to_string()))?;
1057        let rows = stmt
1058            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
1059                Self::row_to_audit_event(row)
1060            })
1061            .map_err(|e| StoreError::QueryError(e.to_string()))?;
1062
1063        let mut events = Vec::new();
1064        for row in rows {
1065            events.push(row?);
1066        }
1067
1068        Ok(ListAuditEventsResponse {
1069            events,
1070            pagination: PaginationInfo {
1071                total: total as u64,
1072                offset: query.offset,
1073                limit: query.limit,
1074                has_more: (query.offset + query.limit as u64) < total as u64,
1075            },
1076        })
1077    }
1078}
1079
1080impl SqliteStore {
1081    fn row_to_audit_event(row: &rusqlite::Row) -> Result<AuditEvent, rusqlite::Error> {
1082        let timestamp_str: String = row.get(1)?;
1083        let timestamp = chrono::DateTime::parse_from_rfc3339(&timestamp_str)
1084            .map(|dt| dt.with_timezone(&chrono::Utc))
1085            .unwrap_or_else(|_| chrono::Utc::now());
1086
1087        let action_str: String = row.get(3)?;
1088        let action = action_str
1089            .parse::<AuditAction>()
1090            .unwrap_or(AuditAction::Create);
1091
1092        let resource_type_str: String = row.get(4)?;
1093        let resource_type = resource_type_str
1094            .parse::<AuditResourceType>()
1095            .unwrap_or(AuditResourceType::Baseline);
1096
1097        let metadata_json: String = row.get(7)?;
1098        let metadata: serde_json::Value = serde_json::from_str(&metadata_json)
1099            .unwrap_or(serde_json::Value::Object(Default::default()));
1100
1101        Ok(AuditEvent {
1102            id: row.get(0)?,
1103            timestamp,
1104            actor: row.get(2)?,
1105            action,
1106            resource_type,
1107            resource_id: row.get(5)?,
1108            project: row.get(6)?,
1109            metadata,
1110        })
1111    }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use crate::models::{BaselineRecordExt, BaselineSource};
1118    use perfgate_types::{BenchMeta, HostInfo, RunMeta, RunReceipt, Stats, ToolInfo, U64Summary};
1119    use std::collections::BTreeMap;
1120    use tempfile::tempdir;
1121
1122    fn create_test_receipt(name: &str) -> RunReceipt {
1123        RunReceipt {
1124            schema: "perfgate.run.v1".to_string(),
1125            tool: ToolInfo {
1126                name: "perfgate".to_string(),
1127                version: "0.3.0".to_string(),
1128            },
1129            run: RunMeta {
1130                id: "test-run-id".to_string(),
1131                started_at: "2026-01-01T00:00:00Z".to_string(),
1132                ended_at: "2026-01-01T00:01:00Z".to_string(),
1133                host: HostInfo {
1134                    os: "linux".to_string(),
1135                    arch: "x86_64".to_string(),
1136                    cpu_count: Some(8),
1137                    memory_bytes: Some(16 * 1024 * 1024 * 1024),
1138                    hostname_hash: None,
1139                },
1140            },
1141            bench: BenchMeta {
1142                name: name.to_string(),
1143                cwd: None,
1144                command: vec!["./bench.sh".to_string()],
1145                repeat: 5,
1146                warmup: 1,
1147                work_units: None,
1148                timeout_ms: None,
1149            },
1150            samples: vec![],
1151            stats: Stats {
1152                wall_ms: U64Summary::new(100, 90, 110),
1153                cpu_ms: None,
1154                page_faults: None,
1155                ctx_switches: None,
1156                max_rss_kb: None,
1157                io_read_bytes: None,
1158                io_write_bytes: None,
1159                network_packets: None,
1160                energy_uj: None,
1161                binary_bytes: None,
1162                throughput_per_s: None,
1163            },
1164        }
1165    }
1166
1167    fn create_test_record(project: &str, benchmark: &str, version: &str) -> BaselineRecord {
1168        BaselineRecord::new(
1169            project.to_string(),
1170            benchmark.to_string(),
1171            version.to_string(),
1172            create_test_receipt(benchmark),
1173            Some("refs/heads/main".to_string()),
1174            Some("abc123".to_string()),
1175            BTreeMap::new(),
1176            vec!["test".to_string()],
1177            BaselineSource::Upload,
1178        )
1179    }
1180
1181    fn create_test_decision(
1182        id: &str,
1183        project: &str,
1184        scenario: &str,
1185        status: perfgate_types::MetricStatus,
1186        created_at: &str,
1187        review_required: bool,
1188    ) -> DecisionRecord {
1189        let mut tradeoff: perfgate_types::TradeoffReceipt =
1190            serde_json::from_value(serde_json::json!({
1191                "schema": "perfgate.tradeoff.v1",
1192                "tool": {"name": "perfgate", "version": "0.16.0"},
1193                "run": {
1194                    "id": id,
1195                    "started_at": "2026-05-08T00:00:00Z",
1196                    "ended_at": "2026-05-08T00:00:01Z",
1197                    "host": {"os": "linux", "arch": "x86_64"}
1198                },
1199                "scenario": scenario,
1200                "configured_rules": [],
1201                "rules": [{
1202                    "name": "memory_for_speed",
1203                    "status": "accepted",
1204                    "accepted": true,
1205                    "downgrade_to": "warn",
1206                    "reason": "accepted"
1207                }],
1208                "weighted_deltas": {},
1209                "decision": {
1210                    "accepted_tradeoff": true,
1211                    "review_required": review_required,
1212                    "review_reasons": [],
1213                    "status": status.as_str(),
1214                    "reason": "accepted"
1215                },
1216                "verdict": {
1217                    "status": status.as_str(),
1218                    "counts": {"pass": 1, "warn": 0, "fail": 0, "skip": 0},
1219                    "reasons": []
1220                }
1221            }))
1222            .expect("test tradeoff receipt should deserialize");
1223        tradeoff.decision.status = status;
1224        tradeoff.verdict.status = match status {
1225            perfgate_types::MetricStatus::Pass => VerdictStatus::Pass,
1226            perfgate_types::MetricStatus::Warn => VerdictStatus::Warn,
1227            perfgate_types::MetricStatus::Fail => VerdictStatus::Fail,
1228            perfgate_types::MetricStatus::Skip => VerdictStatus::Skip,
1229        };
1230
1231        DecisionRecord {
1232            schema: perfgate_types::baseline_service::DECISION_RECORD_SCHEMA_V1.to_string(),
1233            id: id.to_string(),
1234            project: project.to_string(),
1235            scenario: Some(scenario.to_string()),
1236            status,
1237            verdict: tradeoff.verdict.status,
1238            accepted_rules: vec!["memory_for_speed".to_string()],
1239            review_required,
1240            review_reasons: Vec::new(),
1241            git_ref: Some("refs/heads/main".to_string()),
1242            git_sha: Some("abc123".to_string()),
1243            scenario_receipt: None,
1244            tradeoff_receipt: tradeoff,
1245            artifact_index: None,
1246            created_at: chrono::DateTime::parse_from_rfc3339(created_at)
1247                .unwrap()
1248                .with_timezone(&chrono::Utc),
1249        }
1250    }
1251
1252    #[tokio::test(flavor = "multi_thread")]
1253    async fn test_in_memory_database() {
1254        let store = SqliteStore::in_memory().unwrap();
1255        let record = create_test_record("my-project", "my-bench", "v1.0.0");
1256        store.create(&record).await.unwrap();
1257        let retrieved = store.get("my-project", "my-bench", "v1.0.0").await.unwrap();
1258        assert!(retrieved.is_some());
1259        let retrieved = retrieved.unwrap();
1260        assert_eq!(retrieved.project, "my-project");
1261    }
1262
1263    #[tokio::test(flavor = "multi_thread")]
1264    async fn test_decision_records_round_trip_and_filter() {
1265        let store = SqliteStore::in_memory().unwrap();
1266        let first = create_test_decision(
1267            "decision-1",
1268            "my-project",
1269            "release_workload",
1270            perfgate_types::MetricStatus::Warn,
1271            "2026-05-08T00:00:00Z",
1272            false,
1273        );
1274        let mut second = create_test_decision(
1275            "decision-2",
1276            "my-project",
1277            "interactive",
1278            perfgate_types::MetricStatus::Fail,
1279            "2026-05-09T00:00:00Z",
1280            true,
1281        );
1282        second.accepted_rules = vec!["latency_guard".to_string()];
1283        let mut no_tradeoff = create_test_decision(
1284            "decision-4",
1285            "my-project",
1286            "batch",
1287            perfgate_types::MetricStatus::Pass,
1288            "2026-05-07T00:00:00Z",
1289            false,
1290        );
1291        no_tradeoff.accepted_rules.clear();
1292        let other_project = create_test_decision(
1293            "decision-3",
1294            "other-project",
1295            "release_workload",
1296            perfgate_types::MetricStatus::Pass,
1297            "2026-05-10T00:00:00Z",
1298            false,
1299        );
1300
1301        store.create_decision(&first).await.unwrap();
1302        store.create_decision(&second).await.unwrap();
1303        store.create_decision(&no_tradeoff).await.unwrap();
1304        store.create_decision(&other_project).await.unwrap();
1305
1306        let latest = store
1307            .latest_decision("my-project")
1308            .await
1309            .unwrap()
1310            .expect("latest decision should exist");
1311        assert_eq!(latest.id, "decision-2");
1312        assert_eq!(latest.tradeoff_receipt.schema, "perfgate.tradeoff.v1");
1313
1314        let filtered = store
1315            .list_decisions(
1316                "my-project",
1317                &ListDecisionsQuery::new()
1318                    .with_scenario("release_workload")
1319                    .with_status(perfgate_types::MetricStatus::Warn),
1320            )
1321            .await
1322            .unwrap();
1323        assert_eq!(filtered.pagination.total, 1);
1324        assert_eq!(filtered.decisions[0].id, "decision-1");
1325
1326        let review_required = store
1327            .list_decisions(
1328                "my-project",
1329                &ListDecisionsQuery::new().with_review_required(true),
1330            )
1331            .await
1332            .unwrap();
1333        assert_eq!(review_required.decisions.len(), 1);
1334        assert_eq!(review_required.decisions[0].id, "decision-2");
1335
1336        let accepted = store
1337            .list_decisions("my-project", &ListDecisionsQuery::new().with_accepted(true))
1338            .await
1339            .unwrap();
1340        assert_eq!(accepted.pagination.total, 2);
1341
1342        let no_accepted_tradeoff = store
1343            .list_decisions(
1344                "my-project",
1345                &ListDecisionsQuery::new().with_accepted(false),
1346            )
1347            .await
1348            .unwrap();
1349        assert_eq!(no_accepted_tradeoff.decisions.len(), 1);
1350        assert_eq!(no_accepted_tradeoff.decisions[0].id, "decision-4");
1351
1352        let rule = store
1353            .list_decisions(
1354                "my-project",
1355                &ListDecisionsQuery::new().with_rule("latency_guard"),
1356            )
1357            .await
1358            .unwrap();
1359        assert_eq!(rule.decisions.len(), 1);
1360        assert_eq!(rule.decisions[0].id, "decision-2");
1361
1362        let verdict = store
1363            .list_decisions(
1364                "my-project",
1365                &ListDecisionsQuery::new().with_verdict(perfgate_types::VerdictStatus::Fail),
1366            )
1367            .await
1368            .unwrap();
1369        assert_eq!(verdict.decisions.len(), 1);
1370        assert_eq!(verdict.decisions[0].id, "decision-2");
1371    }
1372
1373    #[tokio::test(flavor = "multi_thread")]
1374    async fn test_decision_prune_dry_run_and_delete() {
1375        let store = SqliteStore::in_memory().unwrap();
1376        let old = create_test_decision(
1377            "decision-old",
1378            "my-project",
1379            "release_workload",
1380            perfgate_types::MetricStatus::Warn,
1381            "2026-05-01T00:00:00Z",
1382            false,
1383        );
1384        let recent = create_test_decision(
1385            "decision-recent",
1386            "my-project",
1387            "release_workload",
1388            perfgate_types::MetricStatus::Warn,
1389            "2026-05-09T00:00:00Z",
1390            false,
1391        );
1392        let other_project = create_test_decision(
1393            "decision-other",
1394            "other-project",
1395            "release_workload",
1396            perfgate_types::MetricStatus::Warn,
1397            "2026-05-01T00:00:00Z",
1398            false,
1399        );
1400        store.create_decision(&old).await.unwrap();
1401        store.create_decision(&recent).await.unwrap();
1402        store.create_decision(&other_project).await.unwrap();
1403
1404        let cutoff = chrono::DateTime::parse_from_rfc3339("2026-05-05T00:00:00Z")
1405            .unwrap()
1406            .with_timezone(&chrono::Utc);
1407        let dry_run = store
1408            .prune_decisions("my-project", cutoff, true)
1409            .await
1410            .unwrap();
1411        assert!(dry_run.dry_run);
1412        assert_eq!(dry_run.matched, 1);
1413        assert_eq!(dry_run.deleted, 0);
1414        assert_eq!(dry_run.decision_ids, vec!["decision-old".to_string()]);
1415        assert_eq!(
1416            store
1417                .list_decisions("my-project", &ListDecisionsQuery::new())
1418                .await
1419                .unwrap()
1420                .pagination
1421                .total,
1422            2
1423        );
1424
1425        let pruned = store
1426            .prune_decisions("my-project", cutoff, false)
1427            .await
1428            .unwrap();
1429        assert!(!pruned.dry_run);
1430        assert_eq!(pruned.matched, 1);
1431        assert_eq!(pruned.deleted, 1);
1432        assert_eq!(pruned.decision_ids, vec!["decision-old".to_string()]);
1433
1434        let remaining = store
1435            .list_decisions("my-project", &ListDecisionsQuery::new())
1436            .await
1437            .unwrap();
1438        assert_eq!(remaining.pagination.total, 1);
1439        assert_eq!(remaining.decisions[0].id, "decision-recent");
1440
1441        let other = store
1442            .list_decisions("other-project", &ListDecisionsQuery::new())
1443            .await
1444            .unwrap();
1445        assert_eq!(other.pagination.total, 1);
1446        assert_eq!(other.decisions[0].id, "decision-other");
1447    }
1448
1449    #[tokio::test(flavor = "multi_thread")]
1450    async fn test_persistent_database() {
1451        let dir = tempdir().unwrap();
1452        let db_path = dir.path().join("test.db");
1453        {
1454            let store = SqliteStore::new(&db_path, None).unwrap();
1455            let record = create_test_record("my-project", "my-bench", "v1.0.0");
1456            store.create(&record).await.unwrap();
1457        }
1458        {
1459            let store = SqliteStore::new(&db_path, None).unwrap();
1460            let retrieved = store.get("my-project", "my-bench", "v1.0.0").await.unwrap();
1461            assert!(retrieved.is_some());
1462        }
1463    }
1464
1465    #[tokio::test(flavor = "multi_thread")]
1466    async fn test_wal_mode_enabled() {
1467        let dir = tempdir().unwrap();
1468        let db_path = dir.path().join("wal_test.db");
1469        let store = SqliteStore::new(&db_path, None).unwrap();
1470
1471        let conn = store.conn.lock().unwrap();
1472        let journal_mode: String = conn
1473            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
1474            .unwrap();
1475        assert_eq!(journal_mode.to_lowercase(), "wal");
1476
1477        let busy_timeout: i64 = conn
1478            .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
1479            .unwrap();
1480        assert_eq!(busy_timeout, 5000);
1481    }
1482
1483    #[test]
1484    fn test_in_memory_database_skips_wal_but_sets_busy_timeout() {
1485        let conn = open_configured_memory_connection().unwrap();
1486
1487        let journal_mode: String = conn
1488            .query_row("PRAGMA journal_mode", [], |row| row.get(0))
1489            .unwrap();
1490        assert_eq!(journal_mode.to_lowercase(), "memory");
1491
1492        let busy_timeout: i64 = conn
1493            .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
1494            .unwrap();
1495        assert_eq!(busy_timeout, 5000);
1496    }
1497
1498    #[test]
1499    fn test_wal_allows_writer_while_reader_transaction_is_open() {
1500        let dir = tempdir().unwrap();
1501        let db_path = dir.path().join("wal_concurrent_read.db");
1502
1503        let setup = open_configured_connection(&db_path).unwrap();
1504        setup
1505            .execute_batch(
1506                r#"
1507                CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);
1508                INSERT INTO items (value) VALUES ('before');
1509                "#,
1510            )
1511            .unwrap();
1512        drop(setup);
1513
1514        let reader = open_configured_connection(&db_path).unwrap();
1515        let writer = open_configured_connection(&db_path).unwrap();
1516
1517        reader.execute_batch("BEGIN;").unwrap();
1518        let reader_count: i64 = reader
1519            .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
1520            .unwrap();
1521        assert_eq!(reader_count, 1);
1522
1523        writer
1524            .execute_batch(
1525                r#"
1526                BEGIN IMMEDIATE;
1527                INSERT INTO items (value) VALUES ('during-read');
1528                COMMIT;
1529                "#,
1530            )
1531            .unwrap();
1532
1533        let reader_snapshot_count: i64 = reader
1534            .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
1535            .unwrap();
1536        assert_eq!(reader_snapshot_count, 1);
1537        reader.execute_batch("COMMIT;").unwrap();
1538
1539        let final_count: i64 = reader
1540            .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
1541            .unwrap();
1542        assert_eq!(final_count, 2);
1543    }
1544
1545    #[tokio::test(flavor = "multi_thread")]
1546    async fn test_audit_log_event_and_list() {
1547        use crate::models::{ListAuditEventsQuery, generate_ulid};
1548
1549        let store = SqliteStore::in_memory().unwrap();
1550
1551        let event = AuditEvent {
1552            id: generate_ulid(),
1553            timestamp: chrono::Utc::now(),
1554            actor: "key-abc".to_string(),
1555            action: AuditAction::Create,
1556            resource_type: AuditResourceType::Baseline,
1557            resource_id: "my-project/my-bench/v1".to_string(),
1558            project: "my-project".to_string(),
1559            metadata: serde_json::json!({"benchmark": "my-bench"}),
1560        };
1561
1562        store.log_event(&event).await.unwrap();
1563
1564        // List all
1565        let query = ListAuditEventsQuery::default();
1566        let result = store.list_events(&query).await.unwrap();
1567        assert_eq!(result.events.len(), 1);
1568        assert_eq!(result.events[0].actor, "key-abc");
1569        assert_eq!(result.events[0].action, AuditAction::Create);
1570        assert_eq!(result.events[0].resource_type, AuditResourceType::Baseline);
1571
1572        // Filter by project
1573        let query = ListAuditEventsQuery {
1574            project: Some("my-project".to_string()),
1575            ..Default::default()
1576        };
1577        let result = store.list_events(&query).await.unwrap();
1578        assert_eq!(result.events.len(), 1);
1579
1580        // Filter by wrong project
1581        let query = ListAuditEventsQuery {
1582            project: Some("other-project".to_string()),
1583            ..Default::default()
1584        };
1585        let result = store.list_events(&query).await.unwrap();
1586        assert_eq!(result.events.len(), 0);
1587
1588        // Filter by action
1589        let query = ListAuditEventsQuery {
1590            action: Some("create".to_string()),
1591            ..Default::default()
1592        };
1593        let result = store.list_events(&query).await.unwrap();
1594        assert_eq!(result.events.len(), 1);
1595
1596        let query = ListAuditEventsQuery {
1597            action: Some("delete".to_string()),
1598            ..Default::default()
1599        };
1600        let result = store.list_events(&query).await.unwrap();
1601        assert_eq!(result.events.len(), 0);
1602    }
1603
1604    #[tokio::test(flavor = "multi_thread")]
1605    async fn test_audit_log_multiple_events() {
1606        use crate::models::{ListAuditEventsQuery, generate_ulid};
1607
1608        let store = SqliteStore::in_memory().unwrap();
1609
1610        for i in 0..5 {
1611            let event = AuditEvent {
1612                id: generate_ulid(),
1613                timestamp: chrono::Utc::now(),
1614                actor: format!("key-{}", i),
1615                action: if i % 2 == 0 {
1616                    AuditAction::Create
1617                } else {
1618                    AuditAction::Delete
1619                },
1620                resource_type: AuditResourceType::Baseline,
1621                resource_id: format!("resource-{}", i),
1622                project: "proj".to_string(),
1623                metadata: serde_json::json!({}),
1624            };
1625            store.log_event(&event).await.unwrap();
1626        }
1627
1628        let query = ListAuditEventsQuery::default();
1629        let result = store.list_events(&query).await.unwrap();
1630        assert_eq!(result.events.len(), 5);
1631        assert_eq!(result.pagination.total, 5);
1632
1633        // Pagination
1634        let query = ListAuditEventsQuery {
1635            limit: 2,
1636            ..Default::default()
1637        };
1638        let result = store.list_events(&query).await.unwrap();
1639        assert_eq!(result.events.len(), 2);
1640        assert!(result.pagination.has_more);
1641
1642        // Filter by action
1643        let query = ListAuditEventsQuery {
1644            action: Some("create".to_string()),
1645            ..Default::default()
1646        };
1647        let result = store.list_events(&query).await.unwrap();
1648        assert_eq!(result.events.len(), 3); // indices 0, 2, 4
1649    }
1650}