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