dactyl-db 0.8.2

Interchangeably read and write to a lightweight Rust local store or cloud-hosted Vercel Neon instances behind one facade.
Documentation
//! Dactyl — a lightweight Rust storage driver for local and Neon routes.
//!
//! Dactyl owns backend selection, parameter binding, response normalization,
//! physical atomic batches, access mode, and local durability. It does not own
//! schema policy, migration ids/order, retries, analytics, or business logic.

mod adapter;
mod contract;
mod rows;
mod schema;

pub mod error;

pub use crate::contract::{
    AccessMode, AtomicResult, GeneratedKey, OpenOptions, Operation, OperationKind, OperationResult,
    StorageContext, WriteResult, STORAGE_CONTEXT_VERSION,
};
pub use crate::error::{AdapterErrorKind, DactylError};
pub use crate::rows::{Parameter, Row, Rows};
pub use crate::schema::{
    ColumnSchema, ForeignKeyAction, ForeignKeySchema, IndexSchema, StoreSchema, TableSchema,
};

use crate::adapter::Adapter;

/// A supported application datastore.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Datastore {
    Sqlite,
    Neon,
}

/// The route needed to send an application read or write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatastoreRoute {
    datastore: Datastore,
    route: String,
    token: Option<String>,
}

impl DatastoreRoute {
    pub fn sqlite(path: impl Into<String>) -> Self {
        Self {
            datastore: Datastore::Sqlite,
            route: path.into(),
            token: None,
        }
    }

    pub fn neon(endpoint: impl Into<String>, token: Option<String>) -> Self {
        Self {
            datastore: Datastore::Neon,
            route: endpoint.into(),
            token,
        }
    }

    pub fn datastore(&self) -> Datastore {
        self.datastore
    }

    pub fn route(&self) -> &str {
        &self.route
    }

    pub fn token(&self) -> Option<&str> {
        self.token.as_deref()
    }

    /// Resolve `DATASTORE`, `DATASTORE_ROUTE`, and `DATASTORE_TOKEN`.
    pub fn from_env() -> Result<Self, DactylError> {
        let datastore = std::env::var("DATASTORE")
            .map_err(|_| DactylError::Config("DATASTORE is not set: use sqlite or neon".into()))?;
        let route = std::env::var("DATASTORE_ROUTE")
            .map_err(|_| DactylError::Config("DATASTORE_ROUTE is not set".into()))?;
        match datastore.as_str() {
            "sqlite" => Ok(Self::sqlite(route)),
            "neon" => Ok(Self::neon(route, std::env::var("DATASTORE_TOKEN").ok())),
            other => Err(DactylError::Config(format!(
                "invalid DATASTORE value {other:?}: use sqlite or neon"
            ))),
        }
    }
}

/// A route-scoped application driver. Backend handles remain private.
pub struct Connection {
    adapter: Box<dyn Adapter>,
    route: DatastoreRoute,
    context: Option<StorageContext>,
}

impl Connection {
    pub fn open(route: DatastoreRoute) -> Result<Self, DactylError> {
        Self::open_with_options_and_context(route, OpenOptions::default(), None)
    }

    pub fn open_with_options(
        route: DatastoreRoute,
        options: OpenOptions,
    ) -> Result<Self, DactylError> {
        Self::open_with_options_and_context(route, options, None)
    }

    /// Open a route with an optional caller-owned storage context.
    ///
    /// Local routes ignore the context. Neon routes require it for every
    /// operation and forward it without interpreting its payload.
    pub fn open_with_context(
        route: DatastoreRoute,
        context: Option<StorageContext>,
    ) -> Result<Self, DactylError> {
        Self::open_with_options_and_context(route, OpenOptions::default(), context)
    }

    pub fn open_with_options_and_context(
        route: DatastoreRoute,
        options: OpenOptions,
        context: Option<StorageContext>,
    ) -> Result<Self, DactylError> {
        if let Some(context) = &context {
            context.validate()?;
        }
        let adapter = build_adapter(&route, options, context.clone())?;
        Ok(Self {
            adapter,
            route,
            context,
        })
    }

    pub fn from_env() -> Result<Self, DactylError> {
        Self::open(DatastoreRoute::from_env()?)
    }

    pub fn datastore(&self) -> Datastore {
        self.route.datastore
    }

    pub fn route(&self) -> &DatastoreRoute {
        &self.route
    }

    pub fn context(&self) -> Option<&StorageContext> {
        self.context.as_ref()
    }

    /// Read application rows from the selected backend.
    pub fn read(&self, sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
        self.adapter.read(sql, params)
    }

    /// Write application data and return the explicit physical result.
    pub fn write_result(
        &self,
        sql: &str,
        params: &[Parameter],
    ) -> Result<WriteResult, DactylError> {
        self.adapter.write(sql, params)
    }

    /// Write application data and return the affected count for compatibility.
    pub fn write(&self, sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
        Ok(self.write_result(sql, params)?.affected_rows)
    }

    pub fn atomic(&self, operations: &[Operation]) -> Result<AtomicResult, DactylError> {
        self.adapter.atomic(operations)
    }

    pub fn access_mode(&self) -> AccessMode {
        self.adapter.access_mode()
    }

    /// Inspect the local SQLite catalog through the backend-neutral schema type.
    pub fn inspect_schema(&self) -> Result<StoreSchema, DactylError> {
        self.adapter.inspect_schema()
    }
}

/// Alias that makes the application-driver role explicit.
pub type Driver = Connection;

pub fn read(sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
    Connection::from_env()?.read(sql, params)
}

pub fn read_with_context(
    context: Option<StorageContext>,
    sql: &str,
    params: &[Parameter],
) -> Result<Rows, DactylError> {
    Connection::open_with_context(DatastoreRoute::from_env()?, context)?.read(sql, params)
}

pub fn write(sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
    Connection::from_env()?.write(sql, params)
}

pub fn write_with_context(
    context: Option<StorageContext>,
    sql: &str,
    params: &[Parameter],
) -> Result<u64, DactylError> {
    Connection::open_with_context(DatastoreRoute::from_env()?, context)?.write(sql, params)
}

#[deprecated(note = "use dactyl_db::read")]
pub fn query(sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
    read(sql, params)
}

#[deprecated(note = "use dactyl_db::write")]
pub fn execute(sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
    write(sql, params)
}

fn build_adapter(
    route: &DatastoreRoute,
    _options: OpenOptions,
    _context: Option<StorageContext>,
) -> Result<Box<dyn Adapter>, DactylError> {
    match route.datastore {
        Datastore::Sqlite => {
            #[cfg(feature = "sqlite")]
            {
                Ok(Box::new(
                    crate::adapter::sqlite::SqliteAdapter::open_with_options(
                        &route.route,
                        _options,
                    )?,
                ))
            }
            #[cfg(not(feature = "sqlite"))]
            {
                Err(DactylError::Config(
                    "sqlite support is disabled; enable the `sqlite` feature".into(),
                ))
            }
        }
        Datastore::Neon => {
            #[cfg(feature = "neon")]
            {
                Ok(Box::new(
                    crate::adapter::neon::NeonAdapter::new_with_options(
                        &route.route,
                        route.token.clone(),
                        _options,
                        _context,
                    ),
                ))
            }
            #[cfg(not(feature = "neon"))]
            {
                Err(DactylError::Config(
                    "neon support is disabled; enable the `neon` feature".into(),
                ))
            }
        }
    }
}