Skip to main content

engram/storage/
connection.rs

1//! Database connection management with WAL mode support (RML-874)
2//!
3//! Implements SQLite connection pooling with configurable storage modes
4//! for both local (WAL) and cloud-safe (DELETE journal) operation.
5
6use parking_lot::Mutex;
7use rusqlite::{Connection, OpenFlags};
8use std::path::Path;
9use std::sync::Arc;
10
11use super::migrations::run_migrations;
12use crate::error::Result;
13use crate::types::{StorageConfig, StorageMode};
14
15/// Storage engine wrapping SQLite with connection pooling
16pub struct Storage {
17    config: StorageConfig,
18    conn: Arc<Mutex<Connection>>,
19}
20
21/// Connection pool for concurrent access
22pub struct StoragePool {
23    config: StorageConfig,
24    pool: Vec<Arc<Mutex<Connection>>>,
25    next: std::sync::atomic::AtomicUsize,
26}
27
28impl Storage {
29    /// Open or create a database with the given configuration
30    pub fn open(config: StorageConfig) -> Result<Self> {
31        let conn = Self::create_connection(&config)?;
32
33        // Run migrations
34        run_migrations(&conn)?;
35
36        Ok(Self {
37            config,
38            conn: Arc::new(Mutex::new(conn)),
39        })
40    }
41
42    /// Open with default configuration (in-memory for testing)
43    pub fn open_in_memory() -> Result<Self> {
44        let config = StorageConfig {
45            db_path: ":memory:".to_string(),
46            storage_mode: StorageMode::Local,
47            cloud_uri: None,
48            encrypt_cloud: false,
49            confidence_half_life_days: 30.0,
50            auto_sync: false,
51            sync_debounce_ms: 5000,
52        };
53        Self::open(config)
54    }
55
56    /// Create a new connection with appropriate pragmas
57    fn create_connection(config: &StorageConfig) -> Result<Connection> {
58        let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
59            | OpenFlags::SQLITE_OPEN_CREATE
60            | OpenFlags::SQLITE_OPEN_NO_MUTEX;
61
62        let conn = if config.db_path == ":memory:" {
63            Connection::open_in_memory()?
64        } else {
65            // Ensure parent directory exists
66            if let Some(parent) = Path::new(&config.db_path).parent() {
67                std::fs::create_dir_all(parent)?;
68            }
69            Connection::open_with_flags(&config.db_path, flags)?
70        };
71
72        // Configure based on storage mode (RML-874, RML-900)
73        Self::configure_pragmas(&conn, config.storage_mode)?;
74
75        Ok(conn)
76    }
77
78    /// Configure SQLite pragmas based on storage mode
79    ///
80    /// Local mode (RML-874): WAL for performance and crash recovery
81    /// Cloud-safe mode (RML-900): DELETE journal for cloud sync compatibility
82    fn configure_pragmas(conn: &Connection, mode: StorageMode) -> Result<()> {
83        match mode {
84            StorageMode::Local => {
85                // WAL mode for better concurrency and crash recovery
86                conn.execute_batch(
87                    r#"
88                    PRAGMA journal_mode=WAL;
89                    PRAGMA synchronous=NORMAL;
90                    PRAGMA wal_autocheckpoint=1000;
91                    PRAGMA busy_timeout=30000;
92                    PRAGMA cache_size=-64000;
93                    PRAGMA temp_store=MEMORY;
94                    PRAGMA mmap_size=268435456;
95                    PRAGMA foreign_keys=ON;
96                    "#,
97                )?;
98            }
99            StorageMode::CloudSafe => {
100                // Single-file mode for cloud sync (Dropbox, OneDrive, iCloud)
101                conn.execute_batch(
102                    r#"
103                    PRAGMA journal_mode=DELETE;
104                    PRAGMA synchronous=FULL;
105                    PRAGMA busy_timeout=30000;
106                    PRAGMA cache_size=-32000;
107                    PRAGMA temp_store=MEMORY;
108                    PRAGMA foreign_keys=ON;
109                    "#,
110                )?;
111            }
112        }
113        Ok(())
114    }
115
116    /// Get a reference to the connection (for single-threaded use)
117    pub fn connection(&self) -> parking_lot::MutexGuard<'_, Connection> {
118        self.conn.lock()
119    }
120
121    /// Execute a function with the connection
122    pub fn with_connection<F, T>(&self, f: F) -> Result<T>
123    where
124        F: FnOnce(&Connection) -> Result<T>,
125    {
126        let conn = self.conn.lock();
127        f(&conn)
128    }
129
130    /// Execute a function with a transaction
131    pub fn with_transaction<F, T>(&self, f: F) -> Result<T>
132    where
133        F: FnOnce(&Connection) -> Result<T>,
134    {
135        let mut conn = self.conn.lock();
136        let tx = conn.transaction()?;
137        let result = f(&tx)?;
138        tx.commit()?;
139        Ok(result)
140    }
141
142    /// Get current storage mode
143    pub fn storage_mode(&self) -> StorageMode {
144        self.config.storage_mode
145    }
146
147    /// Get database path
148    pub fn db_path(&self) -> &str {
149        &self.config.db_path
150    }
151
152    /// Check if database is in a cloud-synced folder
153    pub fn is_in_cloud_folder(&self) -> bool {
154        let path = self.config.db_path.to_lowercase();
155        path.contains("dropbox")
156            || path.contains("onedrive")
157            || path.contains("icloud")
158            || path.contains("google drive")
159    }
160
161    /// Get warning if storage mode doesn't match folder type
162    pub fn storage_mode_warning(&self) -> Option<String> {
163        if self.is_in_cloud_folder() && self.config.storage_mode == StorageMode::Local {
164            Some(format!(
165                "WARNING: Database '{}' appears to be in a cloud-synced folder. \
166                WAL mode may cause corruption. Consider:\n\
167                1. Set ENGRAM_STORAGE_MODE=cloud-safe\n\
168                2. Move database to a local folder with backup sync",
169                self.config.db_path
170            ))
171        } else {
172            None
173        }
174    }
175
176    /// Checkpoint WAL file (for local mode)
177    pub fn checkpoint(&self) -> Result<()> {
178        if self.config.storage_mode == StorageMode::Local {
179            let conn = self.conn.lock();
180            conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
181        }
182        Ok(())
183    }
184
185    /// Get database size in bytes
186    pub fn db_size(&self) -> Result<i64> {
187        let conn = self.conn.lock();
188        let size: i64 = conn.query_row(
189            "SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()",
190            [],
191            |row| row.get(0),
192        )?;
193        Ok(size)
194    }
195
196    /// Vacuum the database to reclaim space
197    pub fn vacuum(&self) -> Result<()> {
198        let conn = self.conn.lock();
199        conn.execute_batch("VACUUM;")?;
200        Ok(())
201    }
202
203    /// Get configuration
204    pub fn config(&self) -> &StorageConfig {
205        &self.config
206    }
207}
208
209impl StoragePool {
210    /// Create a connection pool with the specified size
211    pub fn new(config: StorageConfig, pool_size: usize) -> Result<Self> {
212        let mut pool = Vec::with_capacity(pool_size);
213
214        for _ in 0..pool_size {
215            let conn = Storage::create_connection(&config)?;
216            pool.push(Arc::new(Mutex::new(conn)));
217        }
218
219        // Run migrations on first connection
220        if let Some(first) = pool.first() {
221            let conn = first.lock();
222            run_migrations(&conn)?;
223        }
224
225        Ok(Self {
226            config,
227            pool,
228            next: std::sync::atomic::AtomicUsize::new(0),
229        })
230    }
231
232    /// Get a connection from the pool (round-robin)
233    pub fn get(&self) -> Arc<Mutex<Connection>> {
234        let idx = self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % self.pool.len();
235        self.pool[idx].clone()
236    }
237
238    /// Execute a function with a connection from the pool
239    pub fn with_connection<F, T>(&self, f: F) -> Result<T>
240    where
241        F: FnOnce(&Connection) -> Result<T>,
242    {
243        let conn_arc = self.get();
244        let conn = conn_arc.lock();
245        f(&conn)
246    }
247
248    /// Get configuration
249    pub fn config(&self) -> &StorageConfig {
250        &self.config
251    }
252}
253
254impl Clone for Storage {
255    fn clone(&self) -> Self {
256        Self {
257            config: self.config.clone(),
258            conn: self.conn.clone(),
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_open_in_memory() {
269        let storage = Storage::open_in_memory().unwrap();
270        assert_eq!(storage.db_path(), ":memory:");
271    }
272
273    #[test]
274    fn test_storage_modes() {
275        // Test local mode
276        let config = StorageConfig {
277            db_path: ":memory:".to_string(),
278            storage_mode: StorageMode::Local,
279            cloud_uri: None,
280            encrypt_cloud: false,
281            confidence_half_life_days: 30.0,
282            auto_sync: false,
283            sync_debounce_ms: 5000,
284        };
285        let storage = Storage::open(config).unwrap();
286        assert_eq!(storage.storage_mode(), StorageMode::Local);
287
288        // Test cloud-safe mode
289        let config = StorageConfig {
290            db_path: ":memory:".to_string(),
291            storage_mode: StorageMode::CloudSafe,
292            cloud_uri: None,
293            encrypt_cloud: false,
294            confidence_half_life_days: 30.0,
295            auto_sync: false,
296            sync_debounce_ms: 5000,
297        };
298        let storage = Storage::open(config).unwrap();
299        assert_eq!(storage.storage_mode(), StorageMode::CloudSafe);
300    }
301
302    #[test]
303    fn test_cloud_folder_detection() {
304        let config = StorageConfig {
305            db_path: "/Users/test/Dropbox/memories.db".to_string(),
306            storage_mode: StorageMode::Local,
307            cloud_uri: None,
308            encrypt_cloud: false,
309            confidence_half_life_days: 30.0,
310            auto_sync: false,
311            sync_debounce_ms: 5000,
312        };
313        // Can't actually open this path in tests, but we can test detection
314        let path = config.db_path.to_lowercase();
315        assert!(path.contains("dropbox"));
316    }
317}