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