Skip to main content

rullst_core/
db.rs

1//! Rullst Database Extensions (`rullst::db`)
2//!
3//! Provides configuration types for future distributed SQLite replication.
4//! Remote synchronization is explicitly unsupported in this release and fails
5//! closed instead of simulating a successful sync.
6
7/// Configuration for distributed SQLite replica database sync.
8///
9/// `Debug` redacts `auth_token` and prints only the scheme of `sync_url`.
10#[non_exhaustive]
11#[derive(Clone)]
12pub struct ReplicationConfig {
13    /// Local SQLite file path representing the local database replica.
14    pub replica_path: String,
15    /// Remote SQLite database sync master URL.
16    pub sync_url: Option<String>,
17    /// Security authentication bearer token for connection validation.
18    pub auth_token: Option<String>,
19    /// Synchronization check interval in seconds (default is 10 seconds).
20    pub sync_interval_secs: u64,
21}
22
23impl ReplicationConfig {
24    /// Creates a new `ReplicationConfig` using the constructor and builder pattern.
25    pub fn new(replica_path: impl Into<String>) -> Self {
26        Self {
27            replica_path: replica_path.into(),
28            sync_url: None,
29            auth_token: None,
30            sync_interval_secs: 10,
31        }
32    }
33
34    /// Attaches the remote sync master URL (e.g. "libsql://my-db.turso.io").
35    pub fn with_sync_url(mut self, sync_url: impl Into<String>) -> Self {
36        self.sync_url = Some(sync_url.into());
37        self
38    }
39
40    /// Sets the remote connection authentication token.
41    pub fn with_auth_token(mut self, auth_token: impl Into<String>) -> Self {
42        self.auth_token = Some(auth_token.into());
43        self
44    }
45
46    /// Sets the interval duration between sync queries in seconds.
47    pub fn with_sync_interval(mut self, secs: u64) -> Self {
48        self.sync_interval_secs = secs;
49        self
50    }
51}
52
53impl std::fmt::Debug for ReplicationConfig {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("ReplicationConfig")
56            .field("replica_path", &self.replica_path)
57            .field(
58                "sync_url",
59                &self.sync_url.as_deref().map(crate::config::redacted_url),
60            )
61            .field(
62                "auth_token",
63                &self.auth_token.as_ref().map(|_| "<redacted>"),
64            )
65            .field("sync_interval_secs", &self.sync_interval_secs)
66            .finish()
67    }
68}
69
70/// Fail-closed facade reserved for a future SQLite replication backend.
71pub struct ReplicationManager;
72
73/// Failures reported by the replication facade.
74#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum ReplicationError {
77    /// Remote replication has no production backend in this release.
78    #[error("SQLite replication backend is not implemented for `{sync_url}`")]
79    Unsupported {
80        /// Requested remote replication endpoint.
81        sync_url: String,
82    },
83}
84
85impl ReplicationManager {
86    /// Validates replication configuration.
87    ///
88    /// Remote synchronization is deliberately fail-closed until a real libSQL/D1
89    /// backend exists; this method never reports a simulated synchronization as
90    /// successful.
91    #[cfg_attr(mutants, mutants::skip)]
92    pub fn start(config: ReplicationConfig) -> Result<(), ReplicationError> {
93        if let Some(sync_url) = config.sync_url {
94            return Err(ReplicationError::Unsupported { sync_url });
95        }
96        Ok(())
97    }
98}
99
100// ─── Dependency Shielding cascades (Roadmap Milestone 8) ────────────────────
101#[cfg(all(not(target_arch = "wasm32"), feature = "orm"))]
102pub use rullst_orm::{Orm, RullstModel, RullstPool, async_trait, schema};
103#[cfg(all(not(target_arch = "wasm32"), feature = "orm"))]
104pub use sqlx;
105#[cfg(all(not(target_arch = "wasm32"), feature = "orm"))]
106pub use sqlx::FromRow;
107
108/// Safely retrieves the database pool, returning `None` if uninitialized.
109#[cfg(all(not(target_arch = "wasm32"), feature = "orm"))]
110#[cfg_attr(mutants, mutants::skip)]
111pub fn safe_pool() -> Option<&'static rullst_orm::RullstPool> {
112    rullst_orm::Orm::try_pool().ok()
113}
114
115/// Safely retrieves the database driver name, returning `None` if uninitialized.
116#[cfg(all(not(target_arch = "wasm32"), feature = "orm"))]
117#[cfg_attr(mutants, mutants::skip)]
118pub fn safe_driver() -> Option<&'static str> {
119    rullst_orm::Orm::try_driver().ok()
120}
121
122#[cfg(test)]
123#[allow(clippy::unwrap_used, clippy::expect_used)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn replication_debug_output_redacts_credentials() {
129        let config = ReplicationConfig::new("replica.db")
130            .with_sync_url("libsql://db-org.turso.io?authToken=url-secret")
131            .with_auth_token("bearer-secret");
132        let debug = format!("{config:?}");
133        assert!(
134            !debug.contains("bearer-secret"),
135            "Debug must redact the auth token"
136        );
137        assert!(
138            !debug.contains("url-secret"),
139            "Debug must redact the sync URL"
140        );
141        assert!(
142            debug.contains("replica.db"),
143            "Debug must keep the replica path"
144        );
145        assert!(
146            debug.contains("libsql://<redacted>"),
147            "Debug must keep the URL scheme"
148        );
149        assert!(
150            debug.contains("auth_token: Some(\"<redacted>\")"),
151            "Debug must mark the auth token as redacted"
152        );
153    }
154
155    #[tokio::test]
156    async fn test_db_get_pool() {
157        let config = ReplicationConfig::new("test.db")
158            .with_sync_interval(20)
159            .with_auth_token("secret");
160        assert_eq!(config.replica_path, "test.db");
161        assert_eq!(config.sync_interval_secs, 20);
162        assert_eq!(config.auth_token, Some("secret".to_string()));
163    }
164
165    #[test]
166    fn test_replication_config_with_sync_url() {
167        let config = ReplicationConfig::new("test.db").with_sync_url("http://sync");
168        assert_eq!(config.sync_url, Some("http://sync".to_string()));
169    }
170
171    #[test]
172    fn test_replication_config_with_auth_token() {
173        let config = ReplicationConfig::new("test.db").with_auth_token("token123");
174        assert_eq!(config.auth_token, Some("token123".to_string()));
175    }
176
177    #[test]
178    #[cfg(all(not(target_arch = "wasm32"), feature = "orm"))]
179    fn test_safe_pool_uninitialized() {
180        // Assuming Orm isn't initialized in this isolated test, safe_pool should safely return None
181        let pool = safe_pool();
182        assert!(pool.is_none());
183    }
184
185    #[test]
186    #[cfg(all(not(target_arch = "wasm32"), feature = "orm"))]
187    fn test_safe_driver_uninitialized() {
188        let driver = safe_driver();
189        assert!(driver.is_none());
190    }
191
192    #[test]
193    fn test_replication_manager_start() {
194        let config = ReplicationConfig::new("test.db")
195            .with_sync_url("https://sync.rullst.dev")
196            .with_sync_interval(1);
197        assert_eq!(
198            ReplicationManager::start(config),
199            Err(ReplicationError::Unsupported {
200                sync_url: "https://sync.rullst.dev".to_string(),
201            })
202        );
203    }
204
205    #[test]
206    fn test_replication_manager_start_no_url() {
207        let config = ReplicationConfig::new("test.db");
208        assert!(ReplicationManager::start(config).is_ok());
209    }
210}