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/// # Atomicity (dactyl #24)
125///
126/// On any per-statement error the whole unit rolls back and the function
127/// returns [`DactylError`]; **no partial state is committed**. Semantics:
128///
129/// | Backend | Mechanism |
130/// |---|---|
131/// | SQLite | Single rusqlite transaction: begin → statements → commit, or drop = rollback |
132/// | Neon | One `POST {endpoint}/batch` request; the server must accept/reject the batch as a unit |
133///
134/// An empty `statements` slice is a successful no-op and returns `Ok(vec![])`.
135///
136/// # Nesting
137///
138/// **Not supported.** Each call builds a fresh short-lived adapter. There is no
139/// SAVEPOINT API and no nesting of `transaction` inside another open unit.
140/// Independent concurrent `transaction` calls are separate atomic units, not
141/// nested subtransactions. Callers that need multi-step atomicity must put
142/// every statement in a **single** `transaction(&[...])` slice.
143///
144/// # Retry
145///
146/// **dactyl does not retry.** A failed batch leaves no committed partial state
147/// on either adapter (when the Neon transport returns a definitive error).
148/// Callers own retry policy. After a **transport timeout or dropped connection**,
149/// the client cannot distinguish “never applied” from “applied but response
150/// lost”; retries must use **idempotent** statement design (deterministic keys,
151/// upserts) if re-execution is possible.
152///
153/// # Timeout
154///
155/// **No public deadline parameter.** SQLite is process-local. Neon uses the
156/// reqwest client’s default timeouts. Callers that need tighter bounds should
157/// enforce them outside dactyl (process supervisor, HTTP proxy, or a future
158/// env-based client config — not part of this surface).
159///
160/// # Idempotency
161///
162/// `transaction` itself is **not** idempotent. Replaying a previously
163/// successful batch may insert duplicates or hit primary-key conflicts.
164/// Design statements for safe replay when the caller’s retry policy may
165/// re-submit after ambiguous failures.
166///
167/// # Returns
168///
169/// On success, one [`Rows`] per input statement (writes often yield empty
170/// row sets; `SELECT` statements yield projections). On failure, an
171/// [`DactylError::Adapter`] (or conversion error while decoding Neon rows)
172/// and no committed partial state.
173pub fn transaction(statements: &[Statement]) -> Result<Vec<Rows>, DactylError> {
174    if statements.is_empty() {
175        return Ok(Vec::new());
176    }
177    let (kind, route, token) = resolve_env()?;
178    let adapter = build_adapter(kind, &route, token.as_deref())?;
179    adapter.execute_batch(statements)
180}
181
182/// Construct a fresh, short-lived adapter for one call. No caching, no
183/// shared mutable state — the returned adapter is dropped at the end of the
184/// caller's call. Thread safety follows from this: nothing is shared between
185/// calls, so there is no lock acquisition order to define.
186fn build_adapter(
187    kind: &str,
188    route: &str,
189    token: Option<&str>,
190) -> Result<Box<dyn Adapter>, DactylError> {
191    match kind {
192        "sqlite" => {
193            #[cfg(feature = "sqlite")]
194            {
195                let a = crate::adapter::sqlite::SqliteAdapter::open(route)
196                    .map_err(|e| DactylError::Adapter(format!("sqlite open: {e}")))?;
197                Ok(Box::new(a))
198            }
199            #[cfg(not(feature = "sqlite"))]
200            {
201                let _ = (route, token);
202                Err(DactylError::Adapter(
203                    "sqlite adapter requested but `sqlite` feature is disabled".into(),
204                ))
205            }
206        }
207        "neon" => {
208            #[cfg(feature = "neon")]
209            {
210                let a = crate::adapter::neon::NeonAdapter::new(route, token.map(|s| s.to_string()));
211                Ok(Box::new(a))
212            }
213            #[cfg(not(feature = "neon"))]
214            {
215                let _ = (route, token);
216                Err(DactylError::Adapter(
217                    "neon adapter requested but `neon` feature is disabled".into(),
218                ))
219            }
220        }
221        other => Err(DactylError::Adapter(format!(
222            "unknown datastore {other:?}: must be 'sqlite' or 'neon'"
223        ))),
224    }
225}