Skip to main content

dactyl_db/
lib.rs

1//! Dactyl — the governed datastore boundary for Decapod.
2//!
3//! Interchangeably read and write to local SQLite or cloud-hosted Vercel Neon
4//! instances behind a single unified facade.
5//!
6//! # Configuration
7//!
8//! The active datastore is selected by ambient environment variables — no
9//! `init()` call and no per-call datastore argument:
10//!
11//! | Variable           | Required | Meaning                                                         |
12//! |--------------------|----------|-----------------------------------------------------------------|
13//! | `DATASTORE`        | yes      | `"sqlite"` or `"neon"`. Any other value is a typed error.        |
14//! | `DATASTORE_ROUTE`  | yes      | SQLite file path (sqlite) or Neon/Propodus endpoint URL (neon).  |
15//! | `DATASTORE_TOKEN`  | no       | Opaque bearer token forwarded to the Neon adapter. Ignored by sqlite. |
16//!
17//! Each call opens its own short-lived adapter execution and drops it on
18//! return — there is no process-wide connection cache, so workspace and
19//! session isolation is automatic and the public surface is `Send + Sync`
20//! without any lock.
21//!
22//! # Public surface
23//!
24//! - [`query`]    — one entry point for any SQL (read or write).
25//! - [`execute`]  — DDL / migration / affected-row operations.
26//! - [`transaction`] — atomic batch of parameterized statements.
27//! - [`query!`]   — compile-time SQL literal analyzer.
28//!
29//! No `init`, no `read`/`write` split, no `optimize` flag. The query analyzer
30//! is still available via [`query::QueryAnalyzer`] for callers that want
31//! compile-time dialect visibility; runtime dispatch always passes the SQL
32//! straight to the active adapter.
33
34pub mod adapter;
35pub mod error;
36pub mod query;
37mod rows;
38
39#[doc(hidden)]
40pub mod __private;
41
42pub use dactyl_db_macros::query;
43
44pub use crate::error::DactylError;
45pub use crate::rows::{Parameter, Row, Rows};
46
47use crate::adapter::Adapter;
48
49/// A parameterized SQL statement for batch execution.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
51pub struct Statement {
52    pub sql: String,
53    pub params: Vec<Parameter>,
54}
55
56impl Statement {
57    /// Construct a new parameter-bound statement.
58    pub fn new(sql: &str, params: Vec<Parameter>) -> Self {
59        Self {
60            sql: sql.to_string(),
61            params,
62        }
63    }
64}
65
66/// Test-only helper retained for the conformance harness. With no process-wide
67/// cache there is nothing to clear; the function is a no-op kept only so
68/// existing tests do not have to be rewritten around its removal.
69#[doc(hidden)]
70pub fn reset() {}
71
72/// Resolve the active datastore triple from ambient env vars.
73///
74/// Returns a typed error if `DATASTORE` is missing or unrecognized, or if
75/// `DATASTORE_ROUTE` is missing.
76fn resolve_env() -> Result<(&'static str, String, Option<String>), DactylError> {
77    let kind = std::env::var("DATASTORE").map_err(|_| {
78        DactylError::Adapter("DATASTORE is not set: set DATASTORE and DATASTORE_ROUTE".into())
79    })?;
80    let kind_static: &'static str = match kind.as_str() {
81        "sqlite" => "sqlite",
82        "neon" => "neon",
83        other => {
84            return Err(DactylError::Adapter(format!(
85                "invalid DATASTORE value {other:?}: must be 'sqlite' or 'neon'"
86            )))
87        }
88    };
89    let route = std::env::var("DATASTORE_ROUTE").map_err(|_| {
90        DactylError::Adapter("DATASTORE_ROUTE is not set: set DATASTORE and DATASTORE_ROUTE".into())
91    })?;
92    let token = std::env::var("DATASTORE_TOKEN").ok();
93    Ok((kind_static, route, token))
94}
95
96/// Execute any SQL statement against the active datastore and return its
97/// rows. Reads return the matched rows; writes return any returned rows
98/// (Neon/`RETURNING`) or an empty [`Rows`] when the adapter surfaces no rows.
99///
100/// Parameters are bound by the adapter — never interpolated into the SQL.
101/// The statement is executed against the datastore selected by the ambient
102/// `DATASTORE` / `DATASTORE_ROUTE` / optional `DATASTORE_TOKEN` env vars.
103pub fn query(sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
104    let (kind, route, token) = resolve_env()?;
105    let adapter = build_adapter(kind, &route, token.as_deref())?;
106    adapter.execute(sql, params)
107}
108
109/// Execute a DDL / migration / affected-row operation against the active
110/// datastore and return the number of affected rows.
111///
112/// This is the caller-owned schema surface (dactyl #27): dactyl never
113/// silently creates tables — callers own and version their schema through
114/// explicit `execute` calls. Schemas opened against a caller-owned database
115/// are not mutated beyond the statements the caller issues.
116pub fn execute(sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
117    let (kind, route, token) = resolve_env()?;
118    let adapter = build_adapter(kind, &route, token.as_deref())?;
119    adapter.execute_raw(sql, params)
120}
121
122/// Execute an atomic batch of parameterized statements.
123///
124/// On any per-statement error the whole unit rolls back and the function
125/// returns the error; no partial state is committed. Equivalent semantics
126/// are provided for SQLite (transaction) and Neon (`/batch` endpoint).
127pub fn transaction(statements: &[Statement]) -> Result<Vec<Rows>, DactylError> {
128    if statements.is_empty() {
129        return Ok(Vec::new());
130    }
131    let (kind, route, token) = resolve_env()?;
132    let adapter = build_adapter(kind, &route, token.as_deref())?;
133    adapter.execute_batch(statements)
134}
135
136/// Construct a fresh, short-lived adapter for one call. No caching, no
137/// shared mutable state — the returned adapter is dropped at the end of the
138/// caller's call. Thread safety follows from this: nothing is shared between
139/// calls, so there is no lock acquisition order to define.
140fn build_adapter(
141    kind: &str,
142    route: &str,
143    token: Option<&str>,
144) -> Result<Box<dyn Adapter>, DactylError> {
145    match kind {
146        "sqlite" => {
147            #[cfg(feature = "sqlite")]
148            {
149                let a = crate::adapter::sqlite::SqliteAdapter::open(route)
150                    .map_err(|e| DactylError::Adapter(format!("sqlite open: {e}")))?;
151                Ok(Box::new(a))
152            }
153            #[cfg(not(feature = "sqlite"))]
154            {
155                let _ = (route, token);
156                Err(DactylError::Adapter(
157                    "sqlite adapter requested but `sqlite` feature is disabled".into(),
158                ))
159            }
160        }
161        "neon" => {
162            #[cfg(feature = "neon")]
163            {
164                let a = crate::adapter::neon::NeonAdapter::new(route, token.map(|s| s.to_string()));
165                Ok(Box::new(a))
166            }
167            #[cfg(not(feature = "neon"))]
168            {
169                let _ = (route, token);
170                Err(DactylError::Adapter(
171                    "neon adapter requested but `neon` feature is disabled".into(),
172                ))
173            }
174        }
175        other => Err(DactylError::Adapter(format!(
176            "unknown datastore {other:?}: must be 'sqlite' or 'neon'"
177        ))),
178    }
179}