Skip to main content

toolkit_db/
manager.rs

1//! Database manager for per-gear database connections.
2//!
3//! The `DbManager` is responsible for:
4//! - Loading global database configuration from Figment
5//! - Building and caching database handles per gear
6//! - Merging global server configurations with gear-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-gear database connections.
16pub struct DbManager {
17    /// Global database configuration loaded from Figment
18    global: Option<GlobalDatabaseConfig>,
19    /// Figment instance for reading gear configurations
20    figment: Figment,
21    /// Base home directory for gears
22    home_dir: PathBuf,
23    /// Cache of secure DB entrypoints per gear
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 gear.
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, gear: &str) -> Result<Option<Db>> {
66        // Check cache first
67        if let Some(db) = self.cache.get(gear) {
68            return Ok(Some(db.clone()));
69        }
70
71        // Build new Db
72        match self.build_for_gear(gear).await? {
73            Some(db) => {
74                // Use entry API to handle race conditions properly
75                match self.cache.entry(gear.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 gear.
92    async fn build_for_gear(&self, gear: &str) -> Result<Option<Db>> {
93        // Read gear database configuration from Figment
94        let gear_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) = gear_data
100            .get("gears")
101            .and_then(|gears| gears.get(gear))
102            .and_then(|m| m.get("database"))
103        else {
104            tracing::debug!(gear =  %gear, "Gear 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                    gear =  %gear,
113                    error = %e,
114                    "Gear 'database' key is present but failed to deserialize; ignoring"
115                );
116                return Ok(None);
117            }
118        };
119
120        // If gear 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_gear(cfg, server_cfg.clone());
133        }
134
135        // Finalize SQLite paths if needed
136        let gear_home_dir = self.home_dir.join(gear);
137        cfg = self.finalize_sqlite_paths(cfg, &gear_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            gear =  %gear,
144            engine = ?handle.engine(),
145            dsn = %crate::options::redact_credentials_in_dsn(Some(handle.dsn())),
146            "Built database handle for gear"
147        );
148
149        Ok(Some(Db::new(handle)))
150    }
151
152    /// Merge global server configuration into gear configuration.
153    /// Gear fields override server fields. Params maps are merged with gear taking precedence.
154    fn merge_server_into_gear(
155        mut gear_cfg: DbConnConfig,
156        server_cfg: DbConnConfig,
157    ) -> DbConnConfig {
158        // Start with server config as base, then apply gear overrides
159
160        // Engine: gear takes precedence (important for field-based configs)
161        if gear_cfg.engine.is_none() {
162            gear_cfg.engine = server_cfg.engine;
163        }
164
165        // DSN: gear takes precedence
166        if gear_cfg.dsn.is_none() {
167            gear_cfg.dsn = server_cfg.dsn;
168        }
169
170        // Individual fields: gear takes precedence
171        if gear_cfg.host.is_none() {
172            gear_cfg.host = server_cfg.host;
173        }
174        if gear_cfg.port.is_none() {
175            gear_cfg.port = server_cfg.port;
176        }
177        if gear_cfg.user.is_none() {
178            gear_cfg.user = server_cfg.user;
179        }
180        if gear_cfg.password.is_none() {
181            gear_cfg.password = server_cfg.password;
182        }
183        if gear_cfg.dbname.is_none() {
184            gear_cfg.dbname = server_cfg.dbname;
185        }
186
187        // Params: merge maps with gear taking precedence
188        match (&mut gear_cfg.params, server_cfg.params) {
189            (Some(gear_params), Some(server_params)) => {
190                // Merge server params first, then gear params (gear overrides)
191                for (key, value) in server_params {
192                    gear_params.entry(key).or_insert(value);
193                }
194            }
195            (None, Some(server_params)) => {
196                gear_cfg.params = Some(server_params);
197            }
198            _ => {} // Gear has params or server has none - keep gear params
199        }
200
201        // Pool: gear takes precedence
202        if gear_cfg.pool.is_none() {
203            gear_cfg.pool = server_cfg.pool;
204        }
205
206        // Note: file, path, and server fields are gear-only and not merged
207
208        gear_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        gear_home: &Path,
216    ) -> Result<DbConnConfig> {
217        // If file is specified, convert to absolute path under gear home
218        if let Some(file) = &cfg.file {
219            let absolute_path = gear_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 gear home
251        if let Some(path) = &cfg.path
252            && path.is_relative()
253        {
254            cfg.path = Some(gear_home.join(path));
255        }
256
257        Ok(cfg)
258    }
259}