lorekeeper 0.3.3

Agent long-term memory bank — MCP server with SQLite and FTS5
Documentation
//! Database connection management and schema initialization.

pub mod schema;

use crate::error::LoreError;
use rusqlite::Connection;
use std::path::Path;

/// A wrapper around a `SQLite` database connection.
#[derive(Debug)]
pub struct Database {
    conn: Connection,
}

impl Database {
    /// Returns a reference to the underlying `SQLite` connection.
    pub const fn connection(&self) -> &Connection {
        &self.conn
    }

    /// Opens a connection to a `SQLite` database at the specified path.
    ///
    /// This creates parent directories if they don't exist, enables WAL mode,
    /// and initializes the schema.
    ///
    /// # Errors
    ///
    /// Returns [`LoreError`] if the directory cannot be created, the database
    /// connection fails, or the schema initialization fails.
    pub fn open(path: &Path) -> Result<Self, LoreError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| LoreError::Validation(format!("failed to create db dir: {e}")))?;
        }

        let conn = Connection::open(path)?;
        conn.pragma_update(None, "journal_mode", "WAL")?;

        schema::init_schema(&conn)?;

        Ok(Self { conn })
    }

    /// Opens an in-memory `SQLite` database connection.
    ///
    /// Useful for testing. Initializes the schema.
    ///
    /// # Errors
    ///
    /// Returns [`LoreError`] if the connection or schema initialization fails.
    pub fn open_in_memory() -> Result<Self, LoreError> {
        let conn = Connection::open_in_memory()?;
        // Even for in-memory, setting WAL won't hurt, though it might stay 'memory'
        conn.pragma_update(None, "journal_mode", "WAL")?;

        schema::init_schema(&conn)?;

        Ok(Self { conn })
    }

    /// Transfer ownership of the connection (used by `SqliteEntryRepo`).
    pub fn into_connection(self) -> Connection {
        self.conn
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, clippy::panic)]

    use super::*;

    #[test]
    fn test_open_in_memory_creates_schema() {
        let db = Database::open_in_memory().unwrap();
        // Check if table exists
        let count: i64 = db
            .connection()
            .query_row(
                "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='entry'",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(count, 1);
    }

    #[test]
    fn test_fts_table_exists() {
        let db = Database::open_in_memory().unwrap();
        let count: i64 = db
            .connection()
            .query_row(
                "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='entry_fts'",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(count, 1);
    }

    #[test]
    fn test_schema_version_initialized() {
        let db = Database::open_in_memory().unwrap();
        let version: i32 = db
            .connection()
            .query_row(
                "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(version, 2);
    }

    #[test]
    fn test_wal_mode_enabled() {
        let db = Database::open_in_memory().unwrap();
        let mode: String =
            db.connection().query_row("PRAGMA journal_mode", [], |row| row.get(0)).unwrap();
        // In-memory format typically uses 'memory' journal mode, but we requested WAL.
        // Actually SQLite ignores WAL for purely in-memory databases (:memory:),
        // but it accepts the pragma. So the result might be 'memory'.
        // Let's assert it doesn't fail, but not strictly 'wal' because of :memory: limits.
        assert!(!mode.is_empty());
    }
}