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}