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        // Engine: module takes precedence (important for field-based configs)
145        if module_cfg.engine.is_none() {
146            module_cfg.engine = server_cfg.engine;
147        }
148
149        // DSN: module takes precedence
150        if module_cfg.dsn.is_none() {
151            module_cfg.dsn = server_cfg.dsn;
152        }
153
154        // Individual fields: module takes precedence
155        if module_cfg.host.is_none() {
156            module_cfg.host = server_cfg.host;
157        }
158        if module_cfg.port.is_none() {
159            module_cfg.port = server_cfg.port;
160        }
161        if module_cfg.user.is_none() {
162            module_cfg.user = server_cfg.user;
163        }
164        if module_cfg.password.is_none() {
165            module_cfg.password = server_cfg.password;
166        }
167        if module_cfg.dbname.is_none() {
168            module_cfg.dbname = server_cfg.dbname;
169        }
170
171        // Params: merge maps with module taking precedence
172        match (&mut module_cfg.params, server_cfg.params) {
173            (Some(module_params), Some(server_params)) => {
174                // Merge server params first, then module params (module overrides)
175                for (key, value) in server_params {
176                    module_params.entry(key).or_insert(value);
177                }
178            }
179            (None, Some(server_params)) => {
180                module_cfg.params = Some(server_params);
181            }
182            _ => {} // Module has params or server has none - keep module params
183        }
184
185        // Pool: module takes precedence
186        if module_cfg.pool.is_none() {
187            module_cfg.pool = server_cfg.pool;
188        }
189
190        // Note: file, path, and server fields are module-only and not merged
191
192        module_cfg
193    }
194
195    /// Finalize `SQLite` paths by resolving relative file paths to absolute paths.
196    fn finalize_sqlite_paths(
197        &self,
198        mut cfg: DbConnConfig,
199        module_home: &Path,
200    ) -> Result<DbConnConfig> {
201        // If file is specified, convert to absolute path under module home
202        if let Some(file) = &cfg.file {
203            let absolute_path = module_home.join(file);
204
205            // Check auto_provision setting
206            let auto_provision = self
207                .global
208                .as_ref()
209                .and_then(|g| g.auto_provision)
210                .unwrap_or(true);
211
212            if auto_provision {
213                // Create all necessary directories
214                if let Some(parent) = absolute_path.parent() {
215                    std::fs::create_dir_all(parent).map_err(DbError::Io)?;
216                }
217            } else if let Some(parent) = absolute_path.parent() {
218                // When auto_provision is false, check if the directory exists
219                if !parent.exists() {
220                    return Err(DbError::Io(std::io::Error::new(
221                        std::io::ErrorKind::NotFound,
222                        format!(
223                            "Directory does not exist and auto_provision is disabled: {}",
224                            parent.display()
225                        ),
226                    )));
227                }
228            }
229
230            cfg.path = Some(absolute_path);
231            cfg.file = None; // Clear file since path takes precedence and we can't have both
232        }
233
234        // If path is relative, make it absolute relative to module home
235        if let Some(path) = &cfg.path
236            && path.is_relative()
237        {
238            cfg.path = Some(module_home.join(path));
239        }
240
241        Ok(cfg)
242    }
243}