Skip to main content

modkit_db/
manager.rs

1//! Database manager for per-module database connections.
2//!
3//! The `DbManager` is responsible for:
4//! - Loading global database configuration from Figment
5//! - Building and caching database handles per module
6//! - Merging global server configurations with module-specific settings
7
8use crate::config::{DbConnConfig, GlobalDatabaseConfig};
9use crate::options::build_db_handle;
10use crate::{DbError, DbHandle, Result};
11use dashmap::DashMap;
12use figment::Figment;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16/// Central database manager that handles per-module database connections.
17pub struct DbManager {
18    /// Global database configuration loaded from Figment
19    global: Option<GlobalDatabaseConfig>,
20    /// Figment instance for reading module configurations
21    figment: Figment,
22    /// Base home directory for modules
23    home_dir: PathBuf,
24    /// Cache of database handles per module
25    cache: DashMap<String, Arc<DbHandle>>,
26}
27
28impl DbManager {
29    /// Create a new `DbManager` from a Figment configuration.
30    ///
31    /// # Errors
32    /// Returns an error if the configuration cannot be parsed.
33    pub fn from_figment(figment: Figment, home_dir: PathBuf) -> Result<Self> {
34        // Parse global database configuration from "db.*" section
35        let all_data: serde_json::Value = figment
36            .extract()
37            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
38
39        let global: Option<GlobalDatabaseConfig> = all_data
40            .get("database")
41            .and_then(|db| serde_json::from_value(db.clone()).ok());
42
43        Ok(Self {
44            global,
45            figment,
46            home_dir,
47            cache: DashMap::new(),
48        })
49    }
50
51    /// Get a database handle for the specified module.
52    /// Returns cached handle if available, otherwise builds a new one.
53    ///
54    /// # Errors
55    /// Returns an error if the database connection cannot be established.
56    pub async fn get(&self, module: &str) -> Result<Option<Arc<DbHandle>>> {
57        // Check cache first
58        if let Some(handle) = self.cache.get(module) {
59            return Ok(Some(handle.clone()));
60        }
61
62        // Build new handle
63        match self.build_for_module(module).await? {
64            Some(handle) => {
65                // Use entry API to handle race conditions properly
66                match self.cache.entry(module.to_owned()) {
67                    dashmap::mapref::entry::Entry::Occupied(entry) => {
68                        // Another thread beat us to it, return the cached version
69                        Ok(Some(entry.get().clone()))
70                    }
71                    dashmap::mapref::entry::Entry::Vacant(entry) => {
72                        // We're first, insert our handle
73                        entry.insert(handle.clone());
74                        Ok(Some(handle))
75                    }
76                }
77            }
78            _ => Ok(None),
79        }
80    }
81
82    /// Build a database handle for the specified module.
83    async fn build_for_module(&self, module: &str) -> Result<Option<Arc<DbHandle>>> {
84        // Read module database configuration from Figment
85        let module_data: serde_json::Value = self
86            .figment
87            .extract()
88            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
89
90        let module_cfg: Option<DbConnConfig> = module_data
91            .get("modules")
92            .and_then(|modules| modules.get(module))
93            .and_then(|m| m.get("database"))
94            .and_then(|db| serde_json::from_value(db.clone()).ok());
95
96        let Some(mut cfg) = module_cfg else {
97            tracing::debug!(
98                module = %module,
99                "Module has no database configuration; skipping"
100            );
101            return Ok(None);
102        };
103
104        // If module references a global server, merge configurations
105        if let Some(server_name) = &cfg.server {
106            let server_cfg = self
107                .global
108                .as_ref()
109                .and_then(|g| g.servers.get(server_name))
110                .ok_or_else(|| {
111                    DbError::InvalidConfig(format!(
112                        "Referenced server '{server_name}' not found in global database configuration"
113                    ))
114                })?;
115
116            cfg = Self::merge_server_into_module(cfg, server_cfg.clone());
117        }
118
119        // Finalize SQLite paths if needed
120        let module_home_dir = self.home_dir.join(module);
121        cfg = self.finalize_sqlite_paths(cfg, &module_home_dir)?;
122
123        // Build the database handle
124        let handle = build_db_handle(cfg, self.global.as_ref()).await?;
125
126        tracing::info!(
127            module = %module,
128            engine = ?handle.engine(),
129            dsn = %crate::options::redact_credentials_in_dsn(Some(handle.dsn())),
130            "Built database handle for module"
131        );
132
133        Ok(Some(Arc::new(handle)))
134    }
135
136    /// Merge global server configuration into module configuration.
137    /// Module fields override server fields. Params maps are merged with module taking precedence.
138    fn merge_server_into_module(
139        mut module_cfg: DbConnConfig,
140        server_cfg: DbConnConfig,
141    ) -> DbConnConfig {
142        // Start with server config as base, then apply module overrides
143
144        // DSN: module takes precedence
145        if module_cfg.dsn.is_none() {
146            module_cfg.dsn = server_cfg.dsn;
147        }
148
149        // Individual fields: module takes precedence
150        if module_cfg.host.is_none() {
151            module_cfg.host = server_cfg.host;
152        }
153        if module_cfg.port.is_none() {
154            module_cfg.port = server_cfg.port;
155        }
156        if module_cfg.user.is_none() {
157            module_cfg.user = server_cfg.user;
158        }
159        if module_cfg.password.is_none() {
160            module_cfg.password = server_cfg.password;
161        }
162        if module_cfg.dbname.is_none() {
163            module_cfg.dbname = server_cfg.dbname;
164        }
165
166        // Params: merge maps with module taking precedence
167        match (&mut module_cfg.params, server_cfg.params) {
168            (Some(module_params), Some(server_params)) => {
169                // Merge server params first, then module params (module overrides)
170                for (key, value) in server_params {
171                    module_params.entry(key).or_insert(value);
172                }
173            }
174            (None, Some(server_params)) => {
175                module_cfg.params = Some(server_params);
176            }
177            _ => {} // Module has params or server has none - keep module params
178        }
179
180        // Pool: module takes precedence
181        if module_cfg.pool.is_none() {
182            module_cfg.pool = server_cfg.pool;
183        }
184
185        // Note: file, path, and server fields are module-only and not merged
186
187        module_cfg
188    }
189
190    /// Finalize `SQLite` paths by resolving relative file paths to absolute paths.
191    fn finalize_sqlite_paths(
192        &self,
193        mut cfg: DbConnConfig,
194        module_home: &Path,
195    ) -> Result<DbConnConfig> {
196        // If file is specified, convert to absolute path under module home
197        if let Some(file) = &cfg.file {
198            let absolute_path = module_home.join(file);
199
200            // Check auto_provision setting
201            let auto_provision = self
202                .global
203                .as_ref()
204                .and_then(|g| g.auto_provision)
205                .unwrap_or(true);
206
207            if auto_provision {
208                // Create all necessary directories
209                if let Some(parent) = absolute_path.parent() {
210                    std::fs::create_dir_all(parent).map_err(DbError::Io)?;
211                }
212            } else if let Some(parent) = absolute_path.parent() {
213                // When auto_provision is false, check if the directory exists
214                if !parent.exists() {
215                    return Err(DbError::Io(std::io::Error::new(
216                        std::io::ErrorKind::NotFound,
217                        format!(
218                            "Directory does not exist and auto_provision is disabled: {}",
219                            parent.display()
220                        ),
221                    )));
222                }
223            }
224
225            cfg.path = Some(absolute_path);
226            cfg.file = None; // Clear file since path takes precedence and we can't have both
227        }
228
229        // If path is relative, make it absolute relative to module home
230        if let Some(path) = &cfg.path
231            && path.is_relative()
232        {
233            cfg.path = Some(module_home.join(path));
234        }
235
236        Ok(cfg)
237    }
238}