scryer-db 0.1.0

Database models and Turso/SQLite storage layer for Scryer code intelligence
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex, MutexGuard};

use crate::migration::{apply_indexes, upgrade_legacy_timestamps};
use crate::models::{
    ArchitecturalDecision, CodeGraphEdge, DependencyPackage, Project, ProjectDependency, Scope,
    SourceFile, Symbol, SymbolReference, ToolInvocationMetric,
};

/// High-level Turso database handle managing Toasty ORM connectivity and schema migrations.
#[derive(Clone, Debug)]
pub struct ScryerDb {
    inner: Arc<Mutex<toasty::Db>>,
}

impl ScryerDb {
    /// Initialize an in-memory Turso database, suitable for unit and integration testing.
    pub async fn new_in_memory() -> anyhow::Result<Self> {
        Self::connect("turso::memory:").await
    }

    /// Connect to a Turso database using a connection URL (e.g. `turso::memory:`, `turso:/path/to/db.db`, or remote URL).
    pub async fn connect(url: &str) -> anyhow::Result<Self> {
        let mut builder = toasty::Db::builder();
        builder.models(toasty::models![
            Project,
            SourceFile,
            Scope,
            Symbol,
            SymbolReference,
            CodeGraphEdge,
            ArchitecturalDecision,
            ToolInvocationMetric,
            DependencyPackage,
            ProjectDependency,
        ]);

        let mut db = builder.connect(url).await?;
        init_schema_and_indexes(&mut db).await?;

        Ok(Self {
            inner: Arc::new(Mutex::new(db)),
        })
    }

    /// Connect to a local Turso database file at the given filesystem path.
    ///
    /// Automatically creates any missing parent directories, enables concurrent
    /// writes for Turso MVCC concurrency, pushes schema models, and applies composite indexes.
    pub async fn connect_file(path: &Path) -> anyhow::Result<Self> {
        if let Some(parent) = path.parent()
            && !parent.as_os_str().is_empty()
        {
            std::fs::create_dir_all(parent)?;
        }

        let driver = toasty_driver_turso::Turso::file(path).concurrent_writes();

        let mut builder = toasty::Db::builder();
        builder.models(toasty::models![
            Project,
            SourceFile,
            Scope,
            Symbol,
            SymbolReference,
            CodeGraphEdge,
            ArchitecturalDecision,
            ToolInvocationMetric,
            DependencyPackage,
            ProjectDependency,
        ]);

        let mut db = builder
            .build(driver)
            .await
            .map_err(|e| explain_open_error(path, e.into()))?;
        init_schema_and_indexes(&mut db).await?;

        Ok(Self {
            inner: Arc::new(Mutex::new(db)),
        })
    }

    /// Connect using either a connection URL or local file path, with an optional Turso auth token.
    pub async fn connect_with_auth(
        url_or_path: &str,
        auth_token: Option<&str>,
    ) -> anyhow::Result<Self> {
        if url_or_path == "turso::memory:" || url_or_path == ":memory:" {
            return Self::new_in_memory().await;
        }

        if url_or_path.starts_with("turso:")
            || url_or_path.starts_with("libsql:")
            || url_or_path.starts_with("https:")
            || url_or_path.starts_with("http:")
        {
            let url = if let Some(token) = auth_token {
                if url_or_path.contains('?') {
                    format!("{url_or_path}&authToken={token}")
                } else {
                    format!("{url_or_path}?authToken={token}")
                }
            } else {
                url_or_path.to_string()
            };
            return Self::connect(&url).await;
        }

        let path = Path::new(url_or_path);
        Self::connect_file(path).await
    }

    /// Acquire exclusive lock guard on the underlying Toasty `Db` instance.
    pub async fn lock(&self) -> MutexGuard<'_, toasty::Db> {
        self.inner.lock().await
    }

    /// Returns a clone of the `Arc<Mutex<toasty::Db>>` inner handle.
    pub fn inner(&self) -> Arc<Mutex<toasty::Db>> {
        Arc::clone(&self.inner)
    }
}

async fn init_schema_and_indexes(db: &mut toasty::Db) -> anyhow::Result<()> {
    // Check if tables already exist to make initialization idempotent for persistent files
    let existing_tables =
        toasty::sql::query("SELECT name FROM sqlite_master WHERE type='table' AND name='project'")
            .exec(db)
            .await?;

    if existing_tables.is_empty() {
        db.push_schema().await?;
    }

    apply_indexes(db).await?;
    upgrade_legacy_timestamps(db).await?;
    Ok(())
}

/// Replace Turso's file-lock failure with an explanation of how to share the database.
fn explain_open_error(path: &Path, err: anyhow::Error) -> anyhow::Error {
    // Turso nests the same lock message several times; its root cause is the useful part.
    let text = err.root_cause().to_string();
    if !(text.contains("Failed locking file") || text.contains("locked by another process")) {
        return err;
    }
    anyhow::anyhow!(
        "Scryer database {} is in use by another Scryer process.\n\
         A local Scryer database can only be opened by one process. `scryer serve` shares it \
         through a background daemon, so:\n  \
         - run `scryer daemon status` to see the daemon that owns it, or `scryer daemon stop` to stop it\n  \
         - drop `--no-daemon` so `scryer serve` connects to that daemon\n  \
         - or point this command at another database with `--db-url` / `SCRYER_DB_URL`\n\
         (Turso: {text})",
        path.display()
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn lock_errors_explain_how_to_share_the_database() {
        let err = anyhow::anyhow!(
            "Locking error: Failed locking file '/tmp/scryer.db'. File is locked by another process"
        );
        let msg = explain_open_error(Path::new("/tmp/scryer.db"), err).to_string();
        assert!(msg.contains("in use by another Scryer process"));
        assert!(msg.contains("scryer daemon status"));
        assert!(msg.contains("Failed locking file"));
    }

    #[test]
    fn other_errors_pass_through() {
        let err = anyhow::anyhow!("disk full");
        let msg = explain_open_error(Path::new("/tmp/scryer.db"), err).to_string();
        assert_eq!(msg, "disk full");
    }
}