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, BaselineStore, StorageHealth};
9use crate::error::StoreError;
10use crate::models::{
11    BaselineRecord, BaselineSource, BaselineVersion, ListBaselinesQuery, ListBaselinesResponse,
12    ListVerdictsQuery, ListVerdictsResponse, PaginationInfo, VerdictRecord,
13};
14use perfgate_types::{VerdictCounts, VerdictStatus};
15
16/// SQLite storage backend for baselines.
17#[derive(Debug)]
18pub struct SqliteStore {
19    /// Path to the database file
20    _path: std::path::PathBuf,
21
22    /// Connection pool (simplified: single connection wrapped in Mutex)
23    conn: Arc<Mutex<rusqlite::Connection>>,
24
25    /// Optional artifact store for raw receipts
26    artifacts: Option<Arc<dyn ArtifactStore>>,
27}
28
29impl SqliteStore {
30    /// Opens or creates a SQLite database at the specified path.
31    pub fn new<P: AsRef<Path>>(
32        path: P,
33        artifacts: Option<Arc<dyn ArtifactStore>>,
34    ) -> Result<Self, StoreError> {
35        let path = path.as_ref().to_path_buf();
36
37        if let Some(parent) = path.parent().filter(|p| !p.exists()) {
38            std::fs::create_dir_all(parent)?;
39        }
40
41        let conn = rusqlite::Connection::open(&path)?;
42
43        let store = Self {
44            _path: path,
45            conn: Arc::new(Mutex::new(conn)),
46            artifacts,
47        };
48
49        store.initialize()?;
50        Ok(store)
51    }
52
53    /// Creates an in-memory SQLite database (for testing).
54    pub fn in_memory() -> Result<Self, StoreError> {
55        let conn = rusqlite::Connection::open_in_memory()?;
56
57        let store = Self {
58            _path: std::path::PathBuf::from(":memory:"),
59            conn: Arc::new(Mutex::new(conn)),
60            artifacts: None,
61        };
62
63        store.initialize()?;
64        Ok(store)
65    }
66
67    fn initialize(&self) -> Result<(), StoreError> {
68        let conn = self
69            .conn
70            .lock()
71            .map_err(|e| StoreError::LockError(e.to_string()))?;
72
73        conn.execute_batch(
74            r#"
75            CREATE TABLE IF NOT EXISTS baselines (
76                id TEXT PRIMARY KEY,
77                project TEXT NOT NULL,
78                benchmark TEXT NOT NULL,
79                version TEXT NOT NULL,
80                git_ref TEXT,
81                git_sha TEXT,
82                receipt TEXT,
83                artifact_path TEXT,
84                metadata TEXT NOT NULL DEFAULT '{}',
85                tags TEXT NOT NULL DEFAULT '[]',
86                source TEXT NOT NULL DEFAULT 'upload',
87                content_hash TEXT NOT NULL,
88                deleted INTEGER NOT NULL DEFAULT 0,
89                created_at TEXT NOT NULL,
90                updated_at TEXT NOT NULL,
91                UNIQUE(project, benchmark, version)
92            );
93            CREATE INDEX IF NOT EXISTS idx_baselines_project_benchmark ON baselines(project, benchmark);
94            CREATE INDEX IF NOT EXISTS idx_baselines_created_at ON baselines(created_at DESC);
95
96            CREATE TABLE IF NOT EXISTS verdicts (
97                id TEXT PRIMARY KEY,
98                schema_id TEXT NOT NULL,
99                project TEXT NOT NULL,
100                benchmark TEXT NOT NULL,
101                run_id TEXT NOT NULL,
102                status TEXT NOT NULL,
103                counts TEXT NOT NULL,
104                reasons TEXT NOT NULL,
105                git_ref TEXT,
106                git_sha TEXT,
107                created_at TEXT NOT NULL
108            );
109            CREATE INDEX IF NOT EXISTS idx_verdicts_project_benchmark ON verdicts(project, benchmark);
110            CREATE INDEX IF NOT EXISTS idx_verdicts_created_at ON verdicts(created_at DESC);
111            "#,
112        )?;
113        Ok(())
114    }
115
116    fn row_to_record_tuple(
117        row: &rusqlite::Row,
118    ) -> Result<(BaselineRecord, Option<String>), rusqlite::Error> {
119        let created_at_str: String = row.get(13)?;
120        let updated_at_str: String = row.get(14)?;
121
122        let receipt_json: Option<String> = row.get(6)?;
123        let receipt = if let Some(json) = receipt_json {
124            serde_json::from_str(&json).unwrap_or_else(|_| Self::placeholder_receipt())
125        } else {
126            Self::placeholder_receipt()
127        };
128
129        let record = BaselineRecord {
130            schema: crate::models::BASELINE_SCHEMA_V1.to_string(),
131            id: row.get(0)?,
132            project: row.get(1)?,
133            benchmark: row.get(2)?,
134            version: row.get(3)?,
135            git_ref: row.get(4)?,
136            git_sha: row.get(5)?,
137            receipt,
138            metadata: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
139            tags: serde_json::from_str(&row.get::<_, String>(9)?).unwrap_or_default(),
140            created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str)
141                .map(|dt| dt.with_timezone(&chrono::Utc))
142                .unwrap_or_else(|_| chrono::Utc::now()),
143            updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at_str)
144                .map(|dt| dt.with_timezone(&chrono::Utc))
145                .unwrap_or_else(|_| chrono::Utc::now()),
146            content_hash: row.get(11)?,
147            source: match row.get::<_, String>(10)?.as_str() {
148                "promote" => BaselineSource::Promote,
149                "migrate" => BaselineSource::Migrate,
150                "rollback" => BaselineSource::Rollback,
151                _ => BaselineSource::Upload,
152            },
153            deleted: row.get::<_, i64>(12)? != 0,
154        };
155
156        Ok((record, row.get(7)?))
157    }
158
159    fn placeholder_receipt() -> perfgate_types::RunReceipt {
160        serde_json::from_value(serde_json::json!({
161            "schema": "perfgate.run.v1",
162            "tool": {"name": "placeholder", "version": "0"},
163            "run": {
164                "id": "placeholder",
165                "started_at": "1970-01-01T00:00:00Z",
166                "ended_at": "1970-01-01T00:00:00Z",
167                "host": {"os": "unknown", "arch": "unknown"}
168            },
169            "bench": {"name": "placeholder", "command": [], "repeat": 0, "warmup": 0},
170            "samples": [],
171            "stats": {"wall_ms": {"median": 0, "min": 0, "max": 0}}
172        }))
173        .unwrap()
174    }
175
176    async fn store_artifact(&self, record: &BaselineRecord) -> Result<Option<String>, StoreError> {
177        if let Some(store) = &self.artifacts {
178            let path = format!(
179                "{}/{}/{}.json",
180                record.project, record.benchmark, record.version
181            );
182            let data =
183                serde_json::to_vec(&record.receipt).map_err(StoreError::SerializationError)?;
184            store.put(&path, data).await?;
185            Ok(Some(path))
186        } else {
187            Ok(None)
188        }
189    }
190
191    async fn load_artifact(
192        &self,
193        path: Option<String>,
194        mut record: BaselineRecord,
195    ) -> Result<BaselineRecord, StoreError> {
196        if let (Some(store), Some(path)) = (&self.artifacts, path) {
197            let data = store.get(&path).await?;
198            record.receipt =
199                serde_json::from_slice(&data).map_err(StoreError::SerializationError)?;
200        }
201        Ok(record)
202    }
203}
204
205#[async_trait]
206impl BaselineStore for SqliteStore {
207    async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
208        let artifact_path = self.store_artifact(record).await?;
209        let receipt_json = if artifact_path.is_none() {
210            Some(serde_json::to_string(&record.receipt)?)
211        } else {
212            None
213        };
214
215        let conn = self
216            .conn
217            .lock()
218            .map_err(|e| StoreError::LockError(e.to_string()))?;
219        conn.execute(
220            r#"
221            INSERT INTO baselines (
222                id, project, benchmark, version, git_ref, git_sha,
223                receipt, artifact_path, metadata, tags, source, content_hash,
224                deleted, created_at, updated_at
225            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
226            "#,
227            params![
228                record.id,
229                record.project,
230                record.benchmark,
231                record.version,
232                record.git_ref,
233                record.git_sha,
234                receipt_json,
235                artifact_path,
236                serde_json::to_string(&record.metadata)?,
237                serde_json::to_string(&record.tags)?,
238                format!("{:?}", record.source).to_lowercase(),
239                record.content_hash,
240                if record.deleted { 1i64 } else { 0i64 },
241                record.created_at.to_rfc3339(),
242                record.updated_at.to_rfc3339(),
243            ],
244        )
245        .map_err(|e| match &e {
246            rusqlite::Error::SqliteFailure(err, _)
247                if err.code == rusqlite::ErrorCode::ConstraintViolation =>
248            {
249                StoreError::AlreadyExists(format!(
250                    "project={}, benchmark={}, version={}",
251                    record.project, record.benchmark, record.version
252                ))
253            }
254            _ => StoreError::SqliteError(e),
255        })?;
256        Ok(())
257    }
258
259    async fn get(
260        &self,
261        project: &str,
262        benchmark: &str,
263        version: &str,
264    ) -> Result<Option<BaselineRecord>, StoreError> {
265        let res = {
266            let conn = self
267                .conn
268                .lock()
269                .map_err(|e| StoreError::LockError(e.to_string()))?;
270            let mut stmt = conn.prepare(
271                "SELECT * FROM baselines WHERE project = ?1 AND benchmark = ?2 AND version = ?3 AND deleted = 0"
272            )?;
273            stmt.query_row(
274                params![project, benchmark, version],
275                Self::row_to_record_tuple,
276            )
277            .optional()?
278        };
279
280        match res {
281            Some((record, path)) => Ok(Some(self.load_artifact(path, record).await?)),
282            None => Ok(None),
283        }
284    }
285
286    async fn get_latest(
287        &self,
288        project: &str,
289        benchmark: &str,
290    ) -> Result<Option<BaselineRecord>, StoreError> {
291        let res = {
292            let conn = self
293                .conn
294                .lock()
295                .map_err(|e| StoreError::LockError(e.to_string()))?;
296            let mut stmt = conn.prepare(
297                "SELECT * FROM baselines WHERE project = ?1 AND benchmark = ?2 AND deleted = 0 ORDER BY created_at DESC LIMIT 1"
298            )?;
299            stmt.query_row(params![project, benchmark], Self::row_to_record_tuple)
300                .optional()?
301        };
302
303        match res {
304            Some((record, path)) => Ok(Some(self.load_artifact(path, record).await?)),
305            None => Ok(None),
306        }
307    }
308
309    async fn list(
310        &self,
311        project: &str,
312        query: &ListBaselinesQuery,
313    ) -> Result<ListBaselinesResponse, StoreError> {
314        let (records_with_paths, total) = {
315            let conn = self
316                .conn
317                .lock()
318                .map_err(|e| StoreError::LockError(e.to_string()))?;
319            let mut sql =
320                String::from("SELECT * FROM baselines WHERE project = ?1 AND deleted = 0");
321            let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(project.to_string())];
322
323            if let Some(ref b) = query.benchmark {
324                sql.push_str(" AND benchmark = ?");
325                params.push(Box::new(b.clone()));
326            }
327
328            let count_sql = format!("SELECT COUNT(*) FROM ({})", sql);
329            let total: u64 =
330                conn.query_row(&count_sql, rusqlite::params_from_iter(params.iter()), |r| {
331                    r.get(0)
332                })?;
333
334            sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
335            params.push(Box::new(query.limit as i64));
336            params.push(Box::new(query.offset as i64));
337
338            let mut stmt = conn.prepare(&sql)?;
339            let rows = stmt
340                .query_map(
341                    rusqlite::params_from_iter(params.iter()),
342                    Self::row_to_record_tuple,
343                )?
344                .collect::<Result<Vec<_>, _>>()?;
345            (rows, total)
346        };
347
348        let mut baselines = Vec::with_capacity(records_with_paths.len());
349        for (mut record, path) in records_with_paths {
350            if query.include_receipt {
351                record = self.load_artifact(path, record).await?;
352            }
353            baselines.push(record.into());
354        }
355
356        let count = baselines.len() as u64;
357
358        Ok(ListBaselinesResponse {
359            baselines,
360            pagination: PaginationInfo {
361                total,
362                limit: query.limit,
363                offset: query.offset,
364                has_more: (query.offset + count) < total,
365            },
366        })
367    }
368
369    async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
370        let artifact_path = self.store_artifact(record).await?;
371        let receipt_json = if artifact_path.is_none() {
372            Some(serde_json::to_string(&record.receipt)?)
373        } else {
374            None
375        };
376
377        let conn = self
378            .conn
379            .lock()
380            .map_err(|e| StoreError::LockError(e.to_string()))?;
381        conn.execute(
382            "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",
383            params![
384                record.git_ref, record.git_sha, receipt_json, artifact_path,
385                serde_json::to_string(&record.metadata)?, serde_json::to_string(&record.tags)?,
386                format!("{:?}", record.source).to_lowercase(), record.content_hash,
387                record.updated_at.to_rfc3339(), record.project, record.benchmark, record.version
388            ]
389        )?;
390        Ok(())
391    }
392
393    async fn delete(
394        &self,
395        project: &str,
396        benchmark: &str,
397        version: &str,
398    ) -> Result<bool, StoreError> {
399        let conn = self
400            .conn
401            .lock()
402            .map_err(|e| StoreError::LockError(e.to_string()))?;
403        let n = conn.execute("UPDATE baselines SET deleted = 1, updated_at = ?1 WHERE project = ?2 AND benchmark = ?3 AND version = ?4 AND deleted = 0",
404            params![chrono::Utc::now().to_rfc3339(), project, benchmark, version])?;
405        Ok(n > 0)
406    }
407
408    async fn hard_delete(
409        &self,
410        project: &str,
411        benchmark: &str,
412        version: &str,
413    ) -> Result<bool, StoreError> {
414        let conn = self
415            .conn
416            .lock()
417            .map_err(|e| StoreError::LockError(e.to_string()))?;
418        let n = conn.execute(
419            "DELETE FROM baselines WHERE project = ?1 AND benchmark = ?2 AND version = ?3",
420            params![project, benchmark, version],
421        )?;
422        Ok(n > 0)
423    }
424
425    async fn list_versions(
426        &self,
427        project: &str,
428        benchmark: &str,
429    ) -> Result<Vec<BaselineVersion>, StoreError> {
430        let conn = self
431            .conn
432            .lock()
433            .map_err(|e| StoreError::LockError(e.to_string()))?;
434        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")?;
435        let mut versions: Vec<BaselineVersion> = stmt
436            .query_map(params![project, benchmark], |row| {
437                let created_at_str: String = row.get(4)?;
438                Ok(BaselineVersion {
439                    version: row.get(0)?,
440                    git_ref: row.get(1)?,
441                    git_sha: row.get(2)?,
442                    created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str)
443                        .map(|dt| dt.with_timezone(&chrono::Utc))
444                        .unwrap_or_else(|_| chrono::Utc::now()),
445                    created_by: None,
446                    is_current: false,
447                    source: match row.get::<_, String>(3)?.as_str() {
448                        "promote" => BaselineSource::Promote,
449                        "migrate" => BaselineSource::Migrate,
450                        "rollback" => BaselineSource::Rollback,
451                        _ => BaselineSource::Upload,
452                    },
453                })
454            })?
455            .collect::<Result<Vec<_>, _>>()?;
456        if let Some(first) = versions.first_mut() {
457            first.is_current = true;
458        }
459        Ok(versions)
460    }
461
462    async fn health_check(&self) -> Result<StorageHealth, StoreError> {
463        let conn = self
464            .conn
465            .lock()
466            .map_err(|e| StoreError::LockError(e.to_string()))?;
467        match conn.query_row("SELECT 1", [], |_| Ok(())) {
468            Ok(_) => Ok(StorageHealth::Healthy),
469            Err(_) => Ok(StorageHealth::Unhealthy),
470        }
471    }
472
473    fn backend_type(&self) -> &'static str {
474        "sqlite"
475    }
476
477    async fn create_verdict(&self, record: &VerdictRecord) -> Result<(), StoreError> {
478        let conn = self
479            .conn
480            .lock()
481            .map_err(|e| StoreError::LockError(e.to_string()))?;
482
483        let counts_json =
484            serde_json::to_string(&record.counts).map_err(StoreError::SerializationError)?;
485        let reasons_json =
486            serde_json::to_string(&record.reasons).map_err(StoreError::SerializationError)?;
487        let status_str = record.status.as_str();
488        let created_at_str = record.created_at.to_rfc3339();
489
490        conn.execute(
491            r#"
492            INSERT INTO verdicts (
493                id, schema_id, project, benchmark, run_id, status, counts, reasons,
494                git_ref, git_sha, created_at
495            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
496            "#,
497            params![
498                record.id,
499                record.schema,
500                record.project,
501                record.benchmark,
502                record.run_id,
503                status_str,
504                counts_json,
505                reasons_json,
506                record.git_ref,
507                record.git_sha,
508                created_at_str
509            ],
510        )?;
511
512        Ok(())
513    }
514
515    async fn list_verdicts(
516        &self,
517        project: &str,
518        query: &ListVerdictsQuery,
519    ) -> Result<ListVerdictsResponse, StoreError> {
520        let mut sql = "SELECT * FROM verdicts WHERE project = ?".to_string();
521        let mut params_vec: Vec<rusqlite::types::Value> = vec![project.to_string().into()];
522
523        if let Some(bench) = &query.benchmark {
524            sql.push_str(" AND benchmark = ?");
525            params_vec.push(bench.clone().into());
526        }
527
528        if let Some(status) = &query.status {
529            sql.push_str(" AND status = ?");
530            params_vec.push(status.as_str().to_string().into());
531        }
532
533        if let Some(since) = &query.since {
534            sql.push_str(" AND created_at >= ?");
535            params_vec.push(since.to_rfc3339().into());
536        }
537
538        if let Some(until) = &query.until {
539            sql.push_str(" AND created_at <= ?");
540            params_vec.push(until.to_rfc3339().into());
541        }
542
543        sql.push_str(" ORDER BY created_at DESC");
544
545        // Limit and offset
546        sql.push_str(" LIMIT ? OFFSET ?");
547        params_vec.push((query.limit as i64).into());
548        params_vec.push((query.offset as i64).into());
549
550        let conn = self
551            .conn
552            .lock()
553            .map_err(|e| StoreError::LockError(e.to_string()))?;
554
555        let mut stmt = conn
556            .prepare(&sql)
557            .map_err(|e| StoreError::QueryError(e.to_string()))?;
558        let rows = stmt
559            .query_map(rusqlite::params_from_iter(params_vec.iter()), |row| {
560                Self::row_to_verdict(row)
561            })
562            .map_err(|e| StoreError::QueryError(e.to_string()))?;
563
564        let mut verdicts = Vec::new();
565        for row in rows {
566            verdicts.push(row?);
567        }
568
569        // For total count
570        let count_sql = "SELECT COUNT(*) FROM verdicts WHERE project = ?";
571        let total: i64 = conn.query_row(count_sql, params![project], |row| row.get(0))?;
572
573        Ok(ListVerdictsResponse {
574            verdicts,
575            pagination: PaginationInfo {
576                total: total as u64,
577                offset: query.offset,
578                limit: query.limit,
579                has_more: (query.offset + query.limit as u64) < total as u64,
580            },
581        })
582    }
583}
584
585impl SqliteStore {
586    fn row_to_verdict(row: &rusqlite::Row) -> Result<VerdictRecord, rusqlite::Error> {
587        let status_str: String = row.get(5)?;
588        let status = match status_str.as_str() {
589            "pass" => VerdictStatus::Pass,
590            "warn" => VerdictStatus::Warn,
591            "fail" => VerdictStatus::Fail,
592            "skip" => VerdictStatus::Skip,
593            _ => VerdictStatus::Pass,
594        };
595
596        let counts_json: String = row.get(6)?;
597        let counts = serde_json::from_str(&counts_json).unwrap_or(VerdictCounts {
598            pass: 0,
599            warn: 0,
600            fail: 0,
601            skip: 0,
602        });
603
604        let reasons_json: String = row.get(7)?;
605        let reasons = serde_json::from_str(&reasons_json).unwrap_or_default();
606
607        let created_at_str: String = row.get(10)?;
608        let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
609            .map(|dt| dt.with_timezone(&chrono::Utc))
610            .unwrap_or_else(|_| chrono::Utc::now());
611
612        Ok(VerdictRecord {
613            id: row.get(0)?,
614            schema: row.get(1)?,
615            project: row.get(2)?,
616            benchmark: row.get(3)?,
617            run_id: row.get(4)?,
618            status,
619            counts,
620            reasons,
621            git_ref: row.get(8)?,
622            git_sha: row.get(9)?,
623            created_at,
624        })
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::models::{BaselineRecordExt, BaselineSource};
632    use perfgate_types::{BenchMeta, HostInfo, RunMeta, RunReceipt, Stats, ToolInfo, U64Summary};
633    use std::collections::BTreeMap;
634    use tempfile::tempdir;
635
636    fn create_test_receipt(name: &str) -> RunReceipt {
637        RunReceipt {
638            schema: "perfgate.run.v1".to_string(),
639            tool: ToolInfo {
640                name: "perfgate".to_string(),
641                version: "0.3.0".to_string(),
642            },
643            run: RunMeta {
644                id: "test-run-id".to_string(),
645                started_at: "2026-01-01T00:00:00Z".to_string(),
646                ended_at: "2026-01-01T00:01:00Z".to_string(),
647                host: HostInfo {
648                    os: "linux".to_string(),
649                    arch: "x86_64".to_string(),
650                    cpu_count: Some(8),
651                    memory_bytes: Some(16 * 1024 * 1024 * 1024),
652                    hostname_hash: None,
653                },
654            },
655            bench: BenchMeta {
656                name: name.to_string(),
657                cwd: None,
658                command: vec!["./bench.sh".to_string()],
659                repeat: 5,
660                warmup: 1,
661                work_units: None,
662                timeout_ms: None,
663            },
664            samples: vec![],
665            stats: Stats {
666                wall_ms: U64Summary::new(100, 90, 110),
667                cpu_ms: None,
668                page_faults: None,
669                ctx_switches: None,
670                max_rss_kb: None,
671                io_read_bytes: None,
672                io_write_bytes: None,
673                network_packets: None,
674                energy_uj: None,
675                binary_bytes: None,
676                throughput_per_s: None,
677            },
678        }
679    }
680
681    fn create_test_record(project: &str, benchmark: &str, version: &str) -> BaselineRecord {
682        BaselineRecord::new(
683            project.to_string(),
684            benchmark.to_string(),
685            version.to_string(),
686            create_test_receipt(benchmark),
687            Some("refs/heads/main".to_string()),
688            Some("abc123".to_string()),
689            BTreeMap::new(),
690            vec!["test".to_string()],
691            BaselineSource::Upload,
692        )
693    }
694
695    #[tokio::test(flavor = "multi_thread")]
696    async fn test_in_memory_database() {
697        let store = SqliteStore::in_memory().unwrap();
698        let record = create_test_record("my-project", "my-bench", "v1.0.0");
699        store.create(&record).await.unwrap();
700        let retrieved = store.get("my-project", "my-bench", "v1.0.0").await.unwrap();
701        assert!(retrieved.is_some());
702        let retrieved = retrieved.unwrap();
703        assert_eq!(retrieved.project, "my-project");
704    }
705
706    #[tokio::test(flavor = "multi_thread")]
707    async fn test_persistent_database() {
708        let dir = tempdir().unwrap();
709        let db_path = dir.path().join("test.db");
710        {
711            let store = SqliteStore::new(&db_path, None).unwrap();
712            let record = create_test_record("my-project", "my-bench", "v1.0.0");
713            store.create(&record).await.unwrap();
714        }
715        {
716            let store = SqliteStore::new(&db_path, None).unwrap();
717            let retrieved = store.get("my-project", "my-bench", "v1.0.0").await.unwrap();
718            assert!(retrieved.is_some());
719        }
720    }
721}