Skip to main content

dactyl_db/
lib.rs

1//! Dactyl — a lightweight Rust storage driver for local and Neon routes.
2//!
3//! Dactyl owns backend selection, parameter binding, response normalization,
4//! physical atomic batches, access mode, and local durability. It does not own
5//! schema policy, migration ids/order, retries, analytics, or business logic.
6
7mod adapter;
8mod contract;
9mod rows;
10mod schema;
11
12pub mod error;
13
14pub use crate::contract::{
15    AccessMode, AtomicResult, GeneratedKey, OpenOptions, Operation, OperationKind, OperationResult,
16    StorageContext, WriteResult, STORAGE_CONTEXT_VERSION,
17};
18pub use crate::error::{AdapterErrorKind, DactylError};
19pub use crate::rows::{Parameter, Row, Rows};
20pub use crate::schema::{
21    ColumnSchema, ForeignKeyAction, ForeignKeySchema, IndexSchema, StoreSchema, TableSchema,
22};
23
24use crate::adapter::Adapter;
25
26/// A supported application datastore.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Datastore {
29    Sqlite,
30    Neon,
31}
32
33/// The route needed to send an application read or write.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DatastoreRoute {
36    datastore: Datastore,
37    route: String,
38    token: Option<String>,
39}
40
41const DATASTORE_ENV: &str = "DATASTORE";
42const DATASTORE_ROUTE_ENV: &str = "DATASTORE_ROUTE";
43const DATASTORE_TOKEN_ENV: &str = "DATASTORE_TOKEN";
44
45impl DatastoreRoute {
46    pub fn sqlite(path: impl Into<String>) -> Self {
47        Self {
48            datastore: Datastore::Sqlite,
49            route: path.into(),
50            token: None,
51        }
52    }
53
54    pub fn neon(endpoint: impl Into<String>, token: Option<String>) -> Self {
55        Self {
56            datastore: Datastore::Neon,
57            route: endpoint.into(),
58            token,
59        }
60    }
61
62    pub fn datastore(&self) -> Datastore {
63        self.datastore
64    }
65
66    pub fn route(&self) -> &str {
67        &self.route
68    }
69
70    pub fn token(&self) -> Option<&str> {
71        self.token.as_deref()
72    }
73
74    /// Resolve the ambient route configuration.
75    ///
76    /// `DATASTORE` is the sole selector. `DATASTORE_ROUTE` is required for
77    /// the selected backend, and `DATASTORE_TOKEN` is read only for Neon.
78    /// There is deliberately no implicit local fallback: a missing or empty
79    /// selector/route fails before an adapter is constructed.
80    pub fn from_env() -> Result<Self, DactylError> {
81        let datastore = std::env::var(DATASTORE_ENV)
82            .map_err(|_| DactylError::Config("DATASTORE is not set: use sqlite or neon".into()))?;
83        let route = std::env::var(DATASTORE_ROUTE_ENV)
84            .map_err(|_| DactylError::Config("DATASTORE_ROUTE is not set".into()))?;
85        Self::from_env_values(
86            Some(&datastore),
87            Some(&route),
88            std::env::var(DATASTORE_TOKEN_ENV).ok().as_deref(),
89        )
90    }
91
92    fn from_env_values(
93        datastore: Option<&str>,
94        route: Option<&str>,
95        token: Option<&str>,
96    ) -> Result<Self, DactylError> {
97        let datastore = datastore.ok_or_else(|| {
98            DactylError::Config("DATASTORE is not set: use sqlite or neon".into())
99        })?;
100        let route =
101            route.ok_or_else(|| DactylError::Config("DATASTORE_ROUTE is not set".into()))?;
102        if route.trim().is_empty() {
103            return Err(DactylError::Config(
104                "DATASTORE_ROUTE must not be empty".into(),
105            ));
106        }
107
108        match datastore {
109            "sqlite" => Ok(Self::sqlite(route)),
110            "neon" => Ok(Self::neon(
111                route,
112                token
113                    .filter(|value| !value.trim().is_empty())
114                    .map(str::to_owned),
115            )),
116            other => Err(DactylError::Config(format!(
117                "invalid DATASTORE value {other:?}: use sqlite or neon"
118            ))),
119        }
120    }
121}
122
123/// A route-scoped application driver. Backend handles remain private.
124pub struct Connection {
125    adapter: Box<dyn Adapter>,
126    route: DatastoreRoute,
127    context: Option<StorageContext>,
128}
129
130impl Connection {
131    pub fn open(route: DatastoreRoute) -> Result<Self, DactylError> {
132        Self::open_with_options_and_context(route, OpenOptions::default(), None)
133    }
134
135    pub fn open_with_options(
136        route: DatastoreRoute,
137        options: OpenOptions,
138    ) -> Result<Self, DactylError> {
139        Self::open_with_options_and_context(route, options, None)
140    }
141
142    /// Open a route with an optional caller-owned storage context.
143    ///
144    /// Local routes ignore the context. Neon routes require it for every
145    /// operation and forward it without interpreting its payload.
146    pub fn open_with_context(
147        route: DatastoreRoute,
148        context: Option<StorageContext>,
149    ) -> Result<Self, DactylError> {
150        Self::open_with_options_and_context(route, OpenOptions::default(), context)
151    }
152
153    pub fn open_with_options_and_context(
154        route: DatastoreRoute,
155        options: OpenOptions,
156        context: Option<StorageContext>,
157    ) -> Result<Self, DactylError> {
158        if let Some(context) = &context {
159            context.validate()?;
160        }
161        let adapter = build_adapter(&route, options, context.clone())?;
162        Ok(Self {
163            adapter,
164            route,
165            context,
166        })
167    }
168
169    pub fn from_env() -> Result<Self, DactylError> {
170        Self::open(DatastoreRoute::from_env()?)
171    }
172
173    pub fn datastore(&self) -> Datastore {
174        self.route.datastore
175    }
176
177    pub fn route(&self) -> &DatastoreRoute {
178        &self.route
179    }
180
181    pub fn context(&self) -> Option<&StorageContext> {
182        self.context.as_ref()
183    }
184
185    /// Read application rows from the selected backend.
186    pub fn read(&self, sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
187        self.adapter.read(sql, params)
188    }
189
190    /// Write application data and return the explicit physical result.
191    pub fn write_result(
192        &self,
193        sql: &str,
194        params: &[Parameter],
195    ) -> Result<WriteResult, DactylError> {
196        self.adapter.write(sql, params)
197    }
198
199    /// Write application data and return the affected count for compatibility.
200    pub fn write(&self, sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
201        Ok(self.write_result(sql, params)?.affected_rows)
202    }
203
204    pub fn atomic(&self, operations: &[Operation]) -> Result<AtomicResult, DactylError> {
205        self.adapter.atomic(operations)
206    }
207
208    pub fn access_mode(&self) -> AccessMode {
209        self.adapter.access_mode()
210    }
211
212    /// Inspect the local SQLite catalog through the backend-neutral schema type.
213    pub fn inspect_schema(&self) -> Result<StoreSchema, DactylError> {
214        self.adapter.inspect_schema()
215    }
216}
217
218/// Alias that makes the application-driver role explicit.
219pub type Driver = Connection;
220
221pub fn read(sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
222    Connection::from_env()?.read(sql, params)
223}
224
225pub fn read_with_context(
226    context: Option<StorageContext>,
227    sql: &str,
228    params: &[Parameter],
229) -> Result<Rows, DactylError> {
230    Connection::open_with_context(DatastoreRoute::from_env()?, context)?.read(sql, params)
231}
232
233pub fn write(sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
234    Connection::from_env()?.write(sql, params)
235}
236
237pub fn write_with_context(
238    context: Option<StorageContext>,
239    sql: &str,
240    params: &[Parameter],
241) -> Result<u64, DactylError> {
242    Connection::open_with_context(DatastoreRoute::from_env()?, context)?.write(sql, params)
243}
244
245#[deprecated(note = "use dactyl_db::read")]
246pub fn query(sql: &str, params: &[Parameter]) -> Result<Rows, DactylError> {
247    read(sql, params)
248}
249
250#[deprecated(note = "use dactyl_db::write")]
251pub fn execute(sql: &str, params: &[Parameter]) -> Result<u64, DactylError> {
252    write(sql, params)
253}
254
255fn build_adapter(
256    route: &DatastoreRoute,
257    _options: OpenOptions,
258    _context: Option<StorageContext>,
259) -> Result<Box<dyn Adapter>, DactylError> {
260    match route.datastore {
261        Datastore::Sqlite => {
262            #[cfg(feature = "sqlite")]
263            {
264                Ok(Box::new(
265                    crate::adapter::sqlite::SqliteAdapter::open_with_options(
266                        &route.route,
267                        _options,
268                    )?,
269                ))
270            }
271            #[cfg(not(feature = "sqlite"))]
272            {
273                Err(DactylError::Config(
274                    "sqlite support is disabled; enable the `sqlite` feature".into(),
275                ))
276            }
277        }
278        Datastore::Neon => {
279            #[cfg(feature = "neon")]
280            {
281                Ok(Box::new(
282                    crate::adapter::neon::NeonAdapter::new_with_options(
283                        &route.route,
284                        route.token.clone(),
285                        _options,
286                        _context,
287                    ),
288                ))
289            }
290            #[cfg(not(feature = "neon"))]
291            {
292                Err(DactylError::Config(
293                    "neon support is disabled; enable the `neon` feature".into(),
294                ))
295            }
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::{Datastore, DatastoreRoute};
303
304    #[test]
305    fn ambient_route_requires_selector_and_non_empty_route() {
306        assert!(DatastoreRoute::from_env_values(None, Some("/tmp/app.db"), None).is_err());
307        assert!(DatastoreRoute::from_env_values(Some("sqlite"), None, None).is_err());
308        assert!(DatastoreRoute::from_env_values(Some("sqlite"), Some("  "), None).is_err());
309        assert!(DatastoreRoute::from_env_values(Some("unknown"), Some("route"), None).is_err());
310    }
311
312    #[test]
313    fn ambient_selector_controls_backend_and_token_is_neon_only() {
314        let sqlite =
315            DatastoreRoute::from_env_values(Some("sqlite"), Some("/tmp/app.db"), Some("secret"))
316                .unwrap();
317        assert_eq!(sqlite.datastore(), Datastore::Sqlite);
318        assert_eq!(sqlite.route(), "/tmp/app.db");
319        assert_eq!(sqlite.token(), None);
320
321        let neon = DatastoreRoute::from_env_values(
322            Some("neon"),
323            Some("https://propodus.example"),
324            Some("secret"),
325        )
326        .unwrap();
327        assert_eq!(neon.datastore(), Datastore::Neon);
328        assert_eq!(neon.route(), "https://propodus.example");
329        assert_eq!(neon.token(), Some("secret"));
330
331        let blank_token = DatastoreRoute::from_env_values(
332            Some("neon"),
333            Some("https://propodus.example"),
334            Some("  "),
335        )
336        .unwrap();
337        assert_eq!(blank_token.token(), None);
338    }
339}