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::params;
5use std::path::Path;
6use std::sync::{Arc, Mutex};
7
8use super::{BaselineStore, StorageHealth};
9use crate::error::StoreError;
10use crate::models::{
11    BaselineRecord, BaselineSource, BaselineSummary, BaselineVersion, ListBaselinesQuery,
12    ListBaselinesResponse, PaginationInfo,
13};
14
15/// SQLite storage backend for baselines.
16///
17/// This implementation persists all data to a SQLite database file,
18/// making it suitable for production deployments that need durability
19/// without the complexity of a full database server.
20#[derive(Debug)]
21pub struct SqliteStore {
22    /// Path to the database file (for debugging/display purposes)
23    _path: std::path::PathBuf,
24
25    /// Connection pool (simplified: single connection wrapped in Mutex)
26    conn: Arc<Mutex<rusqlite::Connection>>,
27}
28
29impl SqliteStore {
30    /// Opens or creates a SQLite database at the specified path.
31    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
32        let path = path.as_ref().to_path_buf();
33
34        // Create parent directories if needed
35        if let Some(parent) = path.parent().filter(|p| !p.exists()) {
36            std::fs::create_dir_all(parent)?;
37        }
38
39        let conn = rusqlite::Connection::open(&path)?;
40
41        let store = Self {
42            _path: path,
43            conn: Arc::new(Mutex::new(conn)),
44        };
45
46        store.initialize()?;
47        Ok(store)
48    }
49
50    /// Creates an in-memory SQLite database (for testing).
51    pub fn in_memory() -> Result<Self, StoreError> {
52        let conn = rusqlite::Connection::open_in_memory()?;
53
54        let store = Self {
55            _path: std::path::PathBuf::from(":memory:"),
56            conn: Arc::new(Mutex::new(conn)),
57        };
58
59        store.initialize()?;
60        Ok(store)
61    }
62
63    /// Initializes the database schema.
64    fn initialize(&self) -> Result<(), StoreError> {
65        let conn = self
66            .conn
67            .lock()
68            .map_err(|e| StoreError::LockError(e.to_string()))?;
69
70        conn.execute_batch(
71            r#"
72            -- Baselines table
73            CREATE TABLE IF NOT EXISTS baselines (
74                id TEXT PRIMARY KEY,
75                project TEXT NOT NULL,
76                benchmark TEXT NOT NULL,
77                version TEXT NOT NULL,
78                git_ref TEXT,
79                git_sha TEXT,
80                receipt TEXT NOT NULL,
81                metadata TEXT NOT NULL DEFAULT '{}',
82                tags TEXT NOT NULL DEFAULT '[]',
83                source TEXT NOT NULL DEFAULT 'upload',
84                content_hash TEXT NOT NULL,
85                deleted INTEGER NOT NULL DEFAULT 0,
86                created_at TEXT NOT NULL,
87                updated_at TEXT NOT NULL,
88                
89                UNIQUE(project, benchmark, version)
90            );
91
92            -- Indexes for common queries
93            CREATE INDEX IF NOT EXISTS idx_baselines_project_benchmark 
94                ON baselines(project, benchmark);
95            CREATE INDEX IF NOT EXISTS idx_baselines_project_git_ref 
96                ON baselines(project, git_ref);
97            CREATE INDEX IF NOT EXISTS idx_baselines_created_at 
98                ON baselines(created_at DESC);
99            "#,
100        )?;
101
102        Ok(())
103    }
104
105    /// Parses tags from JSON array string.
106    fn parse_tags(tags_json: &str) -> Vec<String> {
107        serde_json::from_str(tags_json).unwrap_or_default()
108    }
109
110    /// Serializes tags to JSON array string.
111    fn serialize_tags(tags: &[String]) -> String {
112        serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
113    }
114
115    /// Parses metadata from JSON object string.
116    fn parse_metadata(metadata_json: &str) -> std::collections::BTreeMap<String, String> {
117        serde_json::from_str(metadata_json).unwrap_or_default()
118    }
119
120    /// Serializes metadata to JSON object string.
121    fn serialize_metadata(metadata: &std::collections::BTreeMap<String, String>) -> String {
122        serde_json::to_string(metadata).unwrap_or_else(|_| "{}".to_string())
123    }
124
125    /// Maps a database row to a BaselineRecord.
126    fn row_to_record(row: &rusqlite::Row) -> Result<BaselineRecord, rusqlite::Error> {
127        // Column indices based on schema:
128        // 0: id, 1: project, 2: benchmark, 3: version, 4: git_ref, 5: git_sha
129        // 6: receipt, 7: metadata, 8: tags, 9: source, 10: content_hash
130        // 11: deleted, 12: created_at, 13: updated_at
131        let schema = crate::models::BASELINE_SCHEMA_V1.to_string();
132        let source_str: String = row.get(9)?;
133        let source = match source_str.as_str() {
134            "upload" => BaselineSource::Upload,
135            "promote" => BaselineSource::Promote,
136            "migrate" => BaselineSource::Migrate,
137            "rollback" => BaselineSource::Rollback,
138            _ => BaselineSource::Upload,
139        };
140
141        let receipt_json: String = row.get(6)?;
142        let receipt: perfgate_types::RunReceipt =
143            serde_json::from_str(&receipt_json).map_err(|e| {
144                rusqlite::Error::FromSqlConversionFailure(
145                    6,
146                    rusqlite::types::Type::Text,
147                    Box::new(std::io::Error::new(
148                        std::io::ErrorKind::InvalidData,
149                        format!("Failed to parse receipt JSON: {}", e),
150                    )),
151                )
152            })?;
153
154        let created_at_str: String = row.get(12)?;
155        let updated_at_str: String = row.get(13)?;
156
157        Ok(BaselineRecord {
158            schema,
159            id: row.get(0)?,
160            project: row.get(1)?,
161            benchmark: row.get(2)?,
162            version: row.get(3)?,
163            git_ref: row.get(4)?,
164            git_sha: row.get(5)?,
165            receipt,
166            metadata: Self::parse_metadata(&row.get::<_, String>(7)?),
167            tags: Self::parse_tags(&row.get::<_, String>(8)?),
168            created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str)
169                .map(|dt| dt.with_timezone(&chrono::Utc))
170                .unwrap_or_else(|_| chrono::Utc::now()),
171            updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at_str)
172                .map(|dt| dt.with_timezone(&chrono::Utc))
173                .unwrap_or_else(|_| chrono::Utc::now()),
174            content_hash: row.get(10)?,
175            source,
176            deleted: row.get::<_, i64>(11)? != 0,
177        })
178    }
179}
180
181#[async_trait]
182impl BaselineStore for SqliteStore {
183    async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
184        let conn = self
185            .conn
186            .lock()
187            .map_err(|e| StoreError::LockError(e.to_string()))?;
188
189        let receipt_json = serde_json::to_string(&record.receipt)?;
190        let metadata_json = Self::serialize_metadata(&record.metadata);
191        let tags_json = Self::serialize_tags(&record.tags);
192        let source_str = match record.source {
193            BaselineSource::Upload => "upload",
194            BaselineSource::Promote => "promote",
195            BaselineSource::Migrate => "migrate",
196            BaselineSource::Rollback => "rollback",
197        };
198
199        let result = conn.execute(
200            r#"
201            INSERT INTO baselines (
202                id, project, benchmark, version, git_ref, git_sha,
203                receipt, metadata, tags, source, content_hash,
204                deleted, created_at, updated_at
205            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
206            "#,
207            params![
208                record.id,
209                record.project,
210                record.benchmark,
211                record.version,
212                record.git_ref,
213                record.git_sha,
214                receipt_json,
215                metadata_json,
216                tags_json,
217                source_str,
218                record.content_hash,
219                if record.deleted { 1i64 } else { 0i64 },
220                record.created_at.to_rfc3339(),
221                record.updated_at.to_rfc3339(),
222            ],
223        );
224
225        match result {
226            Ok(_) => Ok(()),
227            Err(rusqlite::Error::SqliteFailure(err, _)) => {
228                if err.code == rusqlite::ErrorCode::ConstraintViolation {
229                    Err(StoreError::AlreadyExists(format!(
230                        "project={}, benchmark={}, version={}",
231                        record.project, record.benchmark, record.version
232                    )))
233                } else {
234                    Err(StoreError::SqliteError(rusqlite::Error::SqliteFailure(
235                        err, None,
236                    )))
237                }
238            }
239            Err(e) => Err(StoreError::SqliteError(e)),
240        }
241    }
242
243    async fn get(
244        &self,
245        project: &str,
246        benchmark: &str,
247        version: &str,
248    ) -> Result<Option<BaselineRecord>, StoreError> {
249        let conn = self
250            .conn
251            .lock()
252            .map_err(|e| StoreError::LockError(e.to_string()))?;
253
254        let mut stmt = conn.prepare(
255            r#"
256            SELECT id, project, benchmark, version, git_ref, git_sha,
257                   receipt, metadata, tags, source, content_hash, deleted,
258                   created_at, updated_at
259            FROM baselines
260            WHERE project = ?1 AND benchmark = ?2 AND version = ?3 AND deleted = 0
261            "#,
262        )?;
263
264        let result = stmt.query_row(params![project, benchmark, version], Self::row_to_record);
265
266        match result {
267            Ok(record) => Ok(Some(record)),
268            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
269            Err(e) => Err(StoreError::SqliteError(e)),
270        }
271    }
272
273    async fn get_latest(
274        &self,
275        project: &str,
276        benchmark: &str,
277    ) -> Result<Option<BaselineRecord>, StoreError> {
278        let conn = self
279            .conn
280            .lock()
281            .map_err(|e| StoreError::LockError(e.to_string()))?;
282
283        let mut stmt = conn.prepare(
284            r#"
285            SELECT id, project, benchmark, version, git_ref, git_sha,
286                   receipt, metadata, tags, source, content_hash, deleted,
287                   created_at, updated_at
288            FROM baselines
289            WHERE project = ?1 AND benchmark = ?2 AND deleted = 0
290            ORDER BY created_at DESC
291            LIMIT 1
292            "#,
293        )?;
294
295        let result = stmt.query_row(params![project, benchmark], Self::row_to_record);
296
297        match result {
298            Ok(record) => Ok(Some(record)),
299            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
300            Err(e) => Err(StoreError::SqliteError(e)),
301        }
302    }
303
304    async fn list(
305        &self,
306        project: &str,
307        query: &ListBaselinesQuery,
308    ) -> Result<ListBaselinesResponse, StoreError> {
309        let conn = self
310            .conn
311            .lock()
312            .map_err(|e| StoreError::LockError(e.to_string()))?;
313
314        // Build WHERE clause conditions dynamically with numbered params
315        let mut conditions = Vec::new();
316        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(project.to_string())];
317
318        if let Some(ref b) = query.benchmark {
319            conditions.push("benchmark = ?".to_string());
320            params.push(Box::new(b.clone()));
321        }
322        if let Some(ref p) = query.benchmark_prefix {
323            conditions.push("benchmark LIKE ? || '%'".to_string());
324            params.push(Box::new(p.clone()));
325        }
326        if let Some(ref r) = query.git_ref {
327            conditions.push("git_ref = ?".to_string());
328            params.push(Box::new(r.clone()));
329        }
330        if let Some(ref s) = query.git_sha {
331            conditions.push("git_sha = ?".to_string());
332            params.push(Box::new(s.clone()));
333        }
334
335        // Build the WHERE clause string
336        let where_clause = if conditions.is_empty() {
337            String::new()
338        } else {
339            format!(" AND {}", conditions.join(" AND "))
340        };
341
342        // Count query
343        let count_sql = format!(
344            "SELECT COUNT(*) FROM baselines WHERE project = ? AND deleted = 0{}",
345            where_clause
346        );
347
348        // Get total count
349        let count_param_count = params.len();
350        let count_params: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
351        let total: u64 = conn.query_row(&count_sql, &count_params[..count_param_count], |row| {
352            row.get(0)
353        })?;
354
355        // Main query with pagination
356        let sql = format!(
357            r#"
358            SELECT id, project, benchmark, version, git_ref, git_sha,
359                   receipt, metadata, tags, source, content_hash, deleted,
360                   created_at, updated_at
361            FROM baselines
362            WHERE project = ? AND deleted = 0{}
363            ORDER BY created_at DESC
364            LIMIT ? OFFSET ?
365            "#,
366            where_clause
367        );
368
369        // Add pagination params
370        params.push(Box::new(query.limit as i64));
371        params.push(Box::new(query.offset as i64));
372
373        let mut stmt = conn.prepare(&sql)?;
374        let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
375        let records = stmt
376            .query_map(&param_refs[..], Self::row_to_record)?
377            .collect::<Result<Vec<_>, _>>()?;
378
379        let baselines: Vec<BaselineSummary> = records
380            .into_iter()
381            .map(|r| {
382                let mut summary: BaselineSummary = r.clone().into();
383                if query.include_receipt {
384                    summary.receipt = Some(r.receipt);
385                }
386                summary
387            })
388            .collect();
389
390        let has_more = (query.offset + baselines.len() as u64) < total;
391
392        Ok(ListBaselinesResponse {
393            baselines,
394            pagination: PaginationInfo {
395                total,
396                limit: query.limit,
397                offset: query.offset,
398                has_more,
399            },
400        })
401    }
402
403    async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
404        let conn = self
405            .conn
406            .lock()
407            .map_err(|e| StoreError::LockError(e.to_string()))?;
408
409        let receipt_json = serde_json::to_string(&record.receipt)?;
410        let metadata_json = Self::serialize_metadata(&record.metadata);
411        let tags_json = Self::serialize_tags(&record.tags);
412        let source_str = match record.source {
413            BaselineSource::Upload => "upload",
414            BaselineSource::Promote => "promote",
415            BaselineSource::Migrate => "migrate",
416            BaselineSource::Rollback => "rollback",
417        };
418
419        let rows_affected = conn.execute(
420            r#"
421            UPDATE baselines SET
422                git_ref = ?1, git_sha = ?2, receipt = ?3,
423                metadata = ?4, tags = ?5, source = ?6,
424                content_hash = ?7, deleted = ?8, updated_at = ?9
425            WHERE project_id = ?10 AND benchmark = ?11 AND version = ?12
426            "#,
427            params![
428                record.git_ref,
429                record.git_sha,
430                receipt_json,
431                metadata_json,
432                tags_json,
433                source_str,
434                record.content_hash,
435                if record.deleted { 1i64 } else { 0i64 },
436                record.updated_at.to_rfc3339(),
437                record.project,
438                record.benchmark,
439                record.version,
440            ],
441        )?;
442
443        if rows_affected == 0 {
444            return Err(StoreError::NotFound(format!(
445                "project={}, benchmark={}, version={}",
446                record.project, record.benchmark, record.version
447            )));
448        }
449
450        Ok(())
451    }
452
453    async fn delete(
454        &self,
455        project: &str,
456        benchmark: &str,
457        version: &str,
458    ) -> Result<bool, StoreError> {
459        let conn = self
460            .conn
461            .lock()
462            .map_err(|e| StoreError::LockError(e.to_string()))?;
463
464        let rows_affected = conn.execute(
465            r#"
466            UPDATE baselines SET deleted = 1, updated_at = ?1
467            WHERE project = ?2 AND benchmark = ?3 AND version = ?4 AND deleted = 0
468            "#,
469            params![chrono::Utc::now().to_rfc3339(), project, benchmark, version,],
470        )?;
471
472        Ok(rows_affected > 0)
473    }
474
475    async fn hard_delete(
476        &self,
477        project: &str,
478        benchmark: &str,
479        version: &str,
480    ) -> Result<bool, StoreError> {
481        let conn = self
482            .conn
483            .lock()
484            .map_err(|e| StoreError::LockError(e.to_string()))?;
485
486        let rows_affected = conn.execute(
487            "DELETE FROM baselines WHERE project = ?1 AND benchmark = ?2 AND version = ?3",
488            params![project, benchmark, version],
489        )?;
490
491        Ok(rows_affected > 0)
492    }
493
494    async fn list_versions(
495        &self,
496        project: &str,
497        benchmark: &str,
498    ) -> Result<Vec<BaselineVersion>, StoreError> {
499        let conn = self
500            .conn
501            .lock()
502            .map_err(|e| StoreError::LockError(e.to_string()))?;
503
504        let mut stmt = conn.prepare(
505            r#"
506            SELECT version, git_ref, git_sha, source, created_at
507            FROM baselines
508            WHERE project = ?1 AND benchmark = ?2 AND deleted = 0
509            ORDER BY created_at DESC
510            "#,
511        )?;
512
513        let versions: Vec<BaselineVersion> = stmt
514            .query_map(params![project, benchmark], |row| {
515                let source_str: String = row.get(3)?;
516                let source = match source_str.as_str() {
517                    "upload" => BaselineSource::Upload,
518                    "promote" => BaselineSource::Promote,
519                    "migrate" => BaselineSource::Migrate,
520                    "rollback" => BaselineSource::Rollback,
521                    _ => BaselineSource::Upload,
522                };
523
524                let created_at_str: String = row.get(4)?;
525                let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
526                    .map(|dt| dt.with_timezone(&chrono::Utc))
527                    .unwrap_or_else(|_| chrono::Utc::now());
528
529                Ok(BaselineVersion {
530                    version: row.get(0)?,
531                    git_ref: row.get(1)?,
532                    git_sha: row.get(2)?,
533                    created_at,
534                    created_by: None,
535                    is_current: false,
536                    source,
537                })
538            })?
539            .collect::<Result<Vec<_>, _>>()?;
540
541        // Mark first (latest) as current
542        let mut versions = versions;
543        if let Some(first) = versions.first_mut() {
544            first.is_current = true;
545        }
546
547        Ok(versions)
548    }
549
550    async fn health_check(&self) -> Result<StorageHealth, StoreError> {
551        let conn = self
552            .conn
553            .lock()
554            .map_err(|e| StoreError::LockError(e.to_string()))?;
555
556        match conn.query_row("SELECT 1", [], |_| Ok(())) {
557            Ok(_) => Ok(StorageHealth::Healthy),
558            Err(_) => Ok(StorageHealth::Unhealthy),
559        }
560    }
561
562    fn backend_type(&self) -> &'static str {
563        "sqlite"
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::models::BaselineSource;
571    use perfgate_types::{BenchMeta, HostInfo, RunMeta, RunReceipt, Stats, ToolInfo, U64Summary};
572    use std::collections::BTreeMap;
573    use tempfile::tempdir;
574
575    fn create_test_receipt(name: &str) -> RunReceipt {
576        RunReceipt {
577            schema: "perfgate.run.v1".to_string(),
578            tool: ToolInfo {
579                name: "perfgate".to_string(),
580                version: "0.3.0".to_string(),
581            },
582            run: RunMeta {
583                id: "test-run-id".to_string(),
584                started_at: "2026-01-01T00:00:00Z".to_string(),
585                ended_at: "2026-01-01T00:01:00Z".to_string(),
586                host: HostInfo {
587                    os: "linux".to_string(),
588                    arch: "x86_64".to_string(),
589                    cpu_count: Some(8),
590                    memory_bytes: Some(16 * 1024 * 1024 * 1024),
591                    hostname_hash: None,
592                },
593            },
594            bench: BenchMeta {
595                name: name.to_string(),
596                cwd: None,
597                command: vec!["./bench.sh".to_string()],
598                repeat: 5,
599                warmup: 1,
600                work_units: None,
601                timeout_ms: None,
602            },
603            samples: vec![],
604            stats: Stats {
605                wall_ms: U64Summary {
606                    median: 100,
607                    min: 90,
608                    max: 110,
609                },
610                cpu_ms: None,
611                page_faults: None,
612                ctx_switches: None,
613                max_rss_kb: None,
614                binary_bytes: None,
615                throughput_per_s: None,
616            },
617        }
618    }
619
620    fn create_test_record(project: &str, benchmark: &str, version: &str) -> BaselineRecord {
621        BaselineRecord::new(
622            project.to_string(),
623            benchmark.to_string(),
624            version.to_string(),
625            create_test_receipt(benchmark),
626            Some("refs/heads/main".to_string()),
627            Some("abc123".to_string()),
628            BTreeMap::new(),
629            vec!["test".to_string()],
630            BaselineSource::Upload,
631        )
632    }
633
634    #[tokio::test(flavor = "multi_thread")]
635    async fn test_in_memory_database() {
636        let store = SqliteStore::in_memory().unwrap();
637
638        let record = create_test_record("my-project", "my-bench", "v1.0.0");
639        store.create(&record).await.unwrap();
640
641        let retrieved = store.get("my-project", "my-bench", "v1.0.0").await.unwrap();
642
643        assert!(retrieved.is_some());
644        let retrieved = retrieved.unwrap();
645        assert_eq!(retrieved.project, "my-project");
646        assert_eq!(retrieved.benchmark, "my-bench");
647        assert_eq!(retrieved.version, "v1.0.0");
648    }
649
650    #[tokio::test(flavor = "multi_thread")]
651    async fn test_persistent_database() {
652        let dir = tempdir().unwrap();
653        let db_path = dir.path().join("test.db");
654
655        // Create and write
656        {
657            let store = SqliteStore::new(&db_path).unwrap();
658            let record = create_test_record("my-project", "my-bench", "v1.0.0");
659            store.create(&record).await.unwrap();
660        }
661
662        // Reopen and verify
663        {
664            let store = SqliteStore::new(&db_path).unwrap();
665            let retrieved = store.get("my-project", "my-bench", "v1.0.0").await.unwrap();
666
667            assert!(retrieved.is_some());
668        }
669    }
670
671    #[tokio::test(flavor = "multi_thread")]
672    async fn test_create_duplicate_fails() {
673        let store = SqliteStore::in_memory().unwrap();
674        let record = create_test_record("my-project", "my-bench", "v1.0.0");
675
676        store.create(&record).await.unwrap();
677
678        // Second create should fail
679        let result = store.create(&record).await;
680        assert!(result.is_err());
681    }
682
683    #[tokio::test(flavor = "multi_thread")]
684    async fn test_get_latest() {
685        let store = SqliteStore::in_memory().unwrap();
686
687        let record1 = create_test_record("my-project", "my-bench", "v1.0.0");
688        store.create(&record1).await.unwrap();
689
690        // Small delay to ensure different timestamps
691        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
692
693        let record2 = create_test_record("my-project", "my-bench", "v1.1.0");
694        store.create(&record2).await.unwrap();
695
696        let latest = store.get_latest("my-project", "my-bench").await.unwrap();
697
698        assert!(latest.is_some());
699        let latest = latest.unwrap();
700        assert_eq!(latest.version, "v1.1.0");
701    }
702
703    #[tokio::test(flavor = "multi_thread")]
704    async fn test_list_with_filters() {
705        let store = SqliteStore::in_memory().unwrap();
706
707        store
708            .create(&create_test_record("my-project", "bench-a", "v1.0.0"))
709            .await
710            .unwrap();
711        store
712            .create(&create_test_record("my-project", "bench-b", "v1.0.0"))
713            .await
714            .unwrap();
715        store
716            .create(&create_test_record("my-project", "bench-a", "v2.0.0"))
717            .await
718            .unwrap();
719
720        // List all
721        let query = ListBaselinesQuery::default();
722        let result = store.list("my-project", &query).await.unwrap();
723        assert_eq!(result.baselines.len(), 3);
724
725        // Filter by benchmark
726        let query = ListBaselinesQuery {
727            benchmark: Some("bench-a".to_string()),
728            ..Default::default()
729        };
730        let result = store.list("my-project", &query).await.unwrap();
731        assert_eq!(result.baselines.len(), 2);
732
733        // Pagination
734        let query = ListBaselinesQuery {
735            limit: 2,
736            offset: 0,
737            ..Default::default()
738        };
739        let result = store.list("my-project", &query).await.unwrap();
740        assert_eq!(result.baselines.len(), 2);
741        assert!(result.pagination.has_more);
742    }
743
744    #[tokio::test(flavor = "multi_thread")]
745    async fn test_delete() {
746        let store = SqliteStore::in_memory().unwrap();
747        let record = create_test_record("my-project", "my-bench", "v1.0.0");
748
749        store.create(&record).await.unwrap();
750
751        // Soft delete
752        let deleted = store
753            .delete("my-project", "my-bench", "v1.0.0")
754            .await
755            .unwrap();
756        assert!(deleted);
757
758        // Should not be retrievable
759        let retrieved = store.get("my-project", "my-bench", "v1.0.0").await.unwrap();
760        assert!(retrieved.is_none());
761    }
762
763    #[tokio::test(flavor = "multi_thread")]
764    async fn test_list_versions() {
765        let store = SqliteStore::in_memory().unwrap();
766
767        store
768            .create(&create_test_record("my-project", "my-bench", "v1.0.0"))
769            .await
770            .unwrap();
771        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
772        store
773            .create(&create_test_record("my-project", "my-bench", "v1.1.0"))
774            .await
775            .unwrap();
776
777        let versions = store.list_versions("my-project", "my-bench").await.unwrap();
778
779        assert_eq!(versions.len(), 2);
780        assert!(versions[0].is_current);
781        assert!(!versions[1].is_current);
782    }
783
784    #[tokio::test(flavor = "multi_thread")]
785    async fn test_health_check() {
786        let store = SqliteStore::in_memory().unwrap();
787        let health = store.health_check().await.unwrap();
788        assert_eq!(health, StorageHealth::Healthy);
789    }
790
791    #[test]
792    fn test_backend_type() {
793        let store = SqliteStore::in_memory().unwrap();
794        assert_eq!(store.backend_type(), "sqlite");
795    }
796}