1use 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#[derive(Debug)]
20pub struct SqliteStore {
21 _path: std::path::PathBuf,
23
24 conn: Arc<Mutex<rusqlite::Connection>>,
26
27 artifacts: Option<Arc<dyn ArtifactStore>>,
29}
30
31impl SqliteStore {
32 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 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
70pub(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
87fn configure_pragmas(conn: &rusqlite::Connection, is_memory: bool) -> Result<(), StoreError> {
96 conn.execute_batch("PRAGMA busy_timeout=5000;")?;
97
98 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 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 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 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 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(×tamp_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 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 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 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 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 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 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); }
1650}