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}