use std::path::Path; use std::str::FromStr; use std::time::Duration; use crate::atuin_common::record::HostId; use eyre::{Result, eyre}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::sync::OnceCell; use tracing::debug; use uuid::Uuid; const KEY_HOST_ID: &str = "host_id"; const KEY_LAST_SYNC: &str = "last_sync_time"; const KEY_SESSION: &str = "session"; pub(crate) struct MetaStore { pool: SqlitePool, cached_host_id: OnceCell, } impl MetaStore { pub(crate) async fn new(path: impl AsRef, timeout: f64) -> Result { let path = path.as_ref(); let path_str = path .as_os_str() .to_str() .ok_or_else(|| eyre!("meta database path is not valid UTF-8: {path:?}"))?; debug!("opening meta sqlite database at {path:?}"); let is_memory = path_str.contains(":memory:"); if !is_memory && !path.exists() && let Some(dir) = path.parent() { fs_err::create_dir_all(dir)?; } // Use DELETE journal mode instead of WAL. This is a small, infrequently- // written KV store — WAL's concurrency benefits aren't needed, and DELETE // mode avoids creating auxiliary -wal/-shm files that complicate // permission handling. let opts = SqliteConnectOptions::from_str(path_str)? .journal_mode(SqliteJournalMode::Delete) .optimize_on_close(true, None) .create_if_missing(true); let pool = SqlitePoolOptions::new() .acquire_timeout(Duration::from_secs_f64(timeout)) .connect_with(opts) .await?; sqlx::migrate!("./db/client-meta-migrations") .run(&pool) .await?; // Session tokens are stored in this database, so restrict permissions. #[cfg(unix)] if !is_memory { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } let store = Self { pool, cached_host_id: OnceCell::const_new(), }; Ok(store) } // Generic key-value operations pub(crate) async fn get(&self, key: &str) -> Result> { let row: Option<(String,)> = sqlx::query_as("SELECT value FROM meta WHERE key = ?1") .bind(key) .fetch_optional(&self.pool) .await?; Ok(row.map(|r| r.0)) } pub(crate) async fn set(&self, key: &str, value: &str) -> Result<()> { sqlx::query( " INSERT INTO meta (key, value, updated_at) VALUES (?1, ?2, strftime('%s', 'now')) ON CONFLICT(key) DO UPDATE SET value = ?2, updated_at = strftime('%s', 'now') ", ) .bind(key) .bind(value) .execute(&self.pool) .await?; Ok(()) } pub(crate) async fn delete(&self, key: &str) -> Result<()> { sqlx::query("DELETE FROM meta WHERE key = ?1") .bind(key) .execute(&self.pool) .await?; Ok(()) } // Typed accessors pub(crate) async fn host_id(&self) -> Result { self.cached_host_id .get_or_try_init(|| async { if let Some(id) = self.get(KEY_HOST_ID).await? { let parsed = Uuid::from_str(id.as_str()) .map_err(|e| eyre!("failed to parse host ID: {e}"))?; return Ok(HostId(parsed)); } let uuid = crate::atuin_common::utils::uuid_v7(); self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref()) .await?; Ok(HostId(uuid)) }) .await .copied() } pub(crate) async fn last_sync(&self) -> Result { match self.get(KEY_LAST_SYNC).await? { Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?), None => Ok(OffsetDateTime::UNIX_EPOCH), } } pub(crate) async fn save_sync_time(&self) -> Result<()> { self.set( KEY_LAST_SYNC, OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(), ) .await } } #[cfg(test)] mod tests { use super::*; async fn new_test_store() -> MetaStore { MetaStore::new("sqlite::memory:", 2.0).await.unwrap() } #[tokio::test] async fn test_get_set_delete() { let store = new_test_store().await; assert_eq!(store.get("foo").await.unwrap(), None); store.set("foo", "bar").await.unwrap(); assert_eq!(store.get("foo").await.unwrap(), Some("bar".to_string())); store.set("foo", "baz").await.unwrap(); assert_eq!(store.get("foo").await.unwrap(), Some("baz".to_string())); store.delete("foo").await.unwrap(); assert_eq!(store.get("foo").await.unwrap(), None); } #[tokio::test] async fn test_host_id_generation_and_stability() { let store = new_test_store().await; let id1 = store.host_id().await.unwrap(); let id2 = store.host_id().await.unwrap(); assert_eq!(id1, id2, "host_id should be stable across calls"); } #[tokio::test] async fn test_sync_time() { let store = new_test_store().await; let t = store.last_sync().await.unwrap(); assert_eq!(t, OffsetDateTime::UNIX_EPOCH); store.save_sync_time().await.unwrap(); let t = store.last_sync().await.unwrap(); assert!(t > OffsetDateTime::UNIX_EPOCH); } }