Skip to main content

storage_kit/databases/config/
surreal.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Password, StorageError};
6
7/// Selects the SurrealDB deployment mode.
8///
9/// # Examples
10///
11/// ```
12/// let mode = storage_kit::database::config::SurrealModeConfig::RemoteWs {
13///     endpoint: "ws://127.0.0.1:8000".into(),
14/// };
15/// assert!(matches!(mode, storage_kit::database::config::SurrealModeConfig::RemoteWs { .. }));
16/// ```
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub enum SurrealModeConfig {
19    /// Uses the in-memory engine.
20    Mem,
21    /// Uses the embedded RocksDB engine rooted at `path`.
22    RocksDb {
23        /// Filesystem path that stores the RocksDB state.
24        path: PathBuf,
25    },
26    /// Uses the embedded SurrealKV engine rooted at `path`.
27    SurrealKv {
28        /// Filesystem path that stores the SurrealKV state.
29        path: PathBuf,
30    },
31    /// Connects to a remote websocket endpoint.
32    RemoteWs {
33        /// Websocket endpoint such as `ws://127.0.0.1:8000`.
34        endpoint: String,
35    },
36    /// Connects to a remote HTTP endpoint.
37    RemoteHttp {
38        /// HTTP endpoint such as `http://127.0.0.1:8000`.
39        endpoint: String,
40    },
41}
42
43/// SurrealDB-specific backend configuration.
44///
45/// Text-search field selection is request-owned through
46/// [`ScopedQuery::text_field`](crate::database::query::ScopedQuery::text_field)
47/// on the request's text options; this config does not provide a text-field
48/// fallback. The default permits two retries for confirmed rolled-back native
49/// transaction conflicts within the common operation deadline.
50///
51/// # Examples
52///
53/// ```
54/// let config = storage_kit::database::config::SurrealConfig::default();
55/// assert!(matches!(config.mode, storage_kit::database::config::SurrealModeConfig::Mem));
56/// assert_eq!(config.namespace.as_deref(), Some("test"));
57/// assert_eq!(config.max_retries, 2);
58/// let request = storage_kit::database::query::TextSearchRequest {
59///     query: storage_kit::database::query::ScopedQuery::text(
60///         storage_kit::database::StorageLocator::new("documents"),
61///     ).text_field("body"),
62///     text: "rust".to_owned(),
63/// };
64/// assert_eq!(request.query.options.text_field.as_deref(), Some("body"));
65/// ```
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SurrealConfig {
68    /// Maximum retries after confirmed rollback of a native transaction conflict.
69    pub max_retries: u32,
70    /// Deployment mode used to connect to SurrealDB.
71    pub mode: SurrealModeConfig,
72    /// Default native namespace used when the locator does not provide one.
73    pub namespace: Option<String>,
74    /// Default database used when the locator does not provide one.
75    pub database: Option<String>,
76    /// Optional root username for remote or embedded engines that require auth.
77    pub username: Option<String>,
78    /// Optional root password for remote or embedded engines that require auth.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub password: Option<Password>,
81}
82
83impl SurrealConfig {
84    pub(crate) fn validate(&self) -> Result<(), StorageError> {
85        match &self.mode {
86            SurrealModeConfig::Mem => {}
87            SurrealModeConfig::RocksDb { path } => validate_non_empty_path("mode.path", path)?,
88            SurrealModeConfig::SurrealKv { path } => validate_non_empty_path("mode.path", path)?,
89            SurrealModeConfig::RemoteWs { endpoint } => {
90                validate_endpoint("mode.endpoint", endpoint, &["ws", "wss"])?
91            }
92            SurrealModeConfig::RemoteHttp { endpoint } => {
93                validate_endpoint("mode.endpoint", endpoint, &["http", "https"])?
94            }
95        }
96
97        validate_optional_non_empty("namespace", self.namespace.as_deref())?;
98        validate_optional_non_empty("database", self.database.as_deref())?;
99        validate_optional_non_empty("username", self.username.as_deref())?;
100
101        Ok(())
102    }
103}
104
105impl Default for SurrealConfig {
106    fn default() -> Self {
107        Self {
108            mode: SurrealModeConfig::Mem,
109            max_retries: 2,
110            namespace: Some("test".into()),
111            database: Some("test".into()),
112            username: None,
113            password: None,
114        }
115    }
116}
117
118fn validate_non_empty_path(field: &str, path: &Path) -> Result<(), StorageError> {
119    if path.as_os_str().is_empty() {
120        return Err(StorageError::InvalidConfig(format!(
121            "{field} must not be empty"
122        )));
123    }
124    Ok(())
125}
126
127fn validate_optional_non_empty(field: &str, value: Option<&str>) -> Result<(), StorageError> {
128    if let Some(value) = value
129        && value.trim().is_empty()
130    {
131        return Err(StorageError::InvalidConfig(format!(
132            "{field} must not be empty when configured"
133        )));
134    }
135    Ok(())
136}
137
138fn validate_endpoint(field: &str, endpoint: &str, schemes: &[&str]) -> Result<(), StorageError> {
139    validate_optional_non_empty(field, Some(endpoint))?;
140    let parsed = url::Url::parse(endpoint).map_err(|err| {
141        StorageError::InvalidConfig(format!("{field} must be a valid URL: {err}"))
142    })?;
143    if !schemes.iter().any(|scheme| parsed.scheme() == *scheme) {
144        return Err(StorageError::InvalidConfig(format!(
145            "{field} must use one of the following schemes: {}",
146            schemes.join(", ")
147        )));
148    }
149    Ok(())
150}