modkit_db/config.rs
1//! Database configuration types.
2//!
3//! This module contains the canonical definitions of all database configuration
4//! structures used throughout the system. These types are deserialized directly
5//! from Figment configuration.
6//!
7//! # Configuration Precedence Rules
8//!
9//! The database configuration system follows a strict precedence hierarchy when
10//! merging global server configurations with module-specific overrides:
11//!
12//! | Priority | Source | Description | Example |
13//! |----------|--------|-------------|---------|
14//! | 1 (Highest) | Module `params` map | Key-value parameters in module config | `params: {synchronous: "FULL"}` |
15//! | 2 | Module DSN query params | Parameters in module-level DSN | `sqlite://file.db?synchronous=NORMAL` |
16//! | 3 | Module fields | Individual connection fields | `host: "localhost", port: 5432` |
17//! | 4 | Module DSN base | Core DSN without query params | `postgres://user:pass@host/db` |
18//! | 5 | Server `params` map | Key-value parameters in server config | Global server `params` |
19//! | 6 | Server DSN query params | Parameters in server-level DSN | Server DSN query string |
20//! | 7 | Server fields | Individual connection fields in server | Server `host`, `port`, etc. |
21//! | 8 (Lowest) | Server DSN base | Core server DSN without query params | Base server connection string |
22//!
23//! ## Merge Rules
24//!
25//! 1. **Field Precedence**: Module fields always override server fields
26//! 2. **DSN Precedence**: Module DSN overrides server DSN completely
27//! 3. **Params Merging**: `params` maps are merged, with module params taking precedence
28//! 4. **Pool Configuration**: Module pool config overrides server pool config entirely
29//! 5. **`SQLite` Paths**: `file`/`path` fields are module-only and never inherited from servers
30//!
31//! ## Conflict Detection
32//!
33//! The system validates configurations and returns [`DbError::ConfigConflict`] for:
34//! - `SQLite` DSN with server fields (`host`/`port`)
35//! - Non-SQLite DSN with `SQLite` fields (`file`/`path`)
36//! - Both `file` and `path` specified for `SQLite`
37//! - `SQLite` fields mixed with server connection fields
38//!
39
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use std::path::PathBuf;
43use std::time::Duration;
44
45/// Global database configuration with server-based DBs.
46#[derive(Debug, Clone, Deserialize, Serialize)]
47#[serde(deny_unknown_fields)]
48pub struct GlobalDatabaseConfig {
49 /// Server-based DBs (postgres/mysql/sqlite/etc.), keyed by server name.
50 #[serde(default)]
51 pub servers: HashMap<String, DbConnConfig>,
52 /// Optional dev-only flag to auto-provision DB/schema when missing.
53 #[serde(default)]
54 pub auto_provision: Option<bool>,
55}
56
57/// Reusable DB connection config for both global servers and modules.
58/// DSN must be a FULL, valid DSN if provided (dsn crate compliant).
59#[derive(Debug, Clone, Deserialize, Serialize, Default)]
60#[serde(deny_unknown_fields)]
61pub struct DbConnConfig {
62 /// Explicit database engine for this connection.
63 ///
64 /// This is required for configurations without `dsn`, where the engine cannot be inferred
65 /// reliably (e.g. distinguishing `MySQL` vs `PostgreSQL`, or selecting `SQLite` for file/path configs).
66 ///
67 /// If both `engine` and `dsn` are provided, they must not conflict (validated at runtime).
68 #[serde(default)]
69 pub engine: Option<DbEngineCfg>,
70
71 // DSN-style (full, valid). Optional: can be absent and rely on fields.
72 pub dsn: Option<String>,
73
74 // Field-based style; any of these override DSN parts when present:
75 pub host: Option<String>,
76 pub port: Option<u16>,
77 pub user: Option<String>,
78 pub password: Option<String>, // literal password or ${VAR} for env expansion
79 pub dbname: Option<String>, // MUST be present in final for server-based DBs
80 #[serde(default)]
81 pub params: Option<HashMap<String, String>>,
82
83 // SQLite file-based helpers (module-level only; ignored for global):
84 pub file: Option<String>, // relative name under home_dir/module
85 pub path: Option<PathBuf>, // absolute path
86
87 // Connection pool overrides:
88 #[serde(default)]
89 pub pool: Option<PoolCfg>,
90
91 // Module-level only: reference to a global server by name.
92 // If absent, this module config must be fully self-sufficient (dsn or fields).
93 pub server: Option<String>,
94}
95
96/// Serializable engine selector for configuration.
97///
98/// Keep this separate from `modkit_db::DbEngine` (runtime type) to avoid coupling it to serde.
99#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
100#[serde(rename_all = "lowercase")]
101pub enum DbEngineCfg {
102 Postgres,
103 Mysql,
104 Sqlite,
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
108#[serde(deny_unknown_fields)]
109pub struct PoolCfg {
110 pub max_conns: Option<u32>,
111 pub min_conns: Option<u32>,
112 #[serde(with = "modkit_utils::humantime_serde::option", default)]
113 pub acquire_timeout: Option<Duration>,
114 #[serde(with = "modkit_utils::humantime_serde::option", default)]
115 pub idle_timeout: Option<Duration>,
116 #[serde(with = "modkit_utils::humantime_serde::option", default)]
117 pub max_lifetime: Option<Duration>,
118 pub test_before_acquire: Option<bool>,
119}
120
121impl PoolCfg {
122 /// Apply pool configuration to `PostgreSQL` pool options.
123 #[cfg(feature = "pg")]
124 #[must_use]
125 pub fn apply_pg(
126 &self,
127 mut opts: sqlx::postgres::PgPoolOptions,
128 ) -> sqlx::postgres::PgPoolOptions {
129 if let Some(max_conns) = self.max_conns {
130 opts = opts.max_connections(max_conns);
131 }
132 if let Some(min_conns) = self.min_conns {
133 opts = opts.min_connections(min_conns);
134 }
135 if let Some(acquire_timeout) = self.acquire_timeout {
136 opts = opts.acquire_timeout(acquire_timeout);
137 }
138 if let Some(idle_timeout) = self.idle_timeout {
139 opts = opts.idle_timeout(Some(idle_timeout));
140 }
141 if let Some(max_lifetime) = self.max_lifetime {
142 opts = opts.max_lifetime(Some(max_lifetime));
143 }
144 if let Some(test_before_acquire) = self.test_before_acquire {
145 opts = opts.test_before_acquire(test_before_acquire);
146 }
147 opts
148 }
149
150 /// Apply pool configuration to `MySQL` pool options.
151 #[cfg(feature = "mysql")]
152 #[must_use]
153 pub fn apply_mysql(
154 &self,
155 mut opts: sqlx::mysql::MySqlPoolOptions,
156 ) -> sqlx::mysql::MySqlPoolOptions {
157 if let Some(max_conns) = self.max_conns {
158 opts = opts.max_connections(max_conns);
159 }
160 if let Some(min_conns) = self.min_conns {
161 opts = opts.min_connections(min_conns);
162 }
163 if let Some(acquire_timeout) = self.acquire_timeout {
164 opts = opts.acquire_timeout(acquire_timeout);
165 }
166 if let Some(idle_timeout) = self.idle_timeout {
167 opts = opts.idle_timeout(Some(idle_timeout));
168 }
169 if let Some(max_lifetime) = self.max_lifetime {
170 opts = opts.max_lifetime(Some(max_lifetime));
171 }
172 if let Some(test_before_acquire) = self.test_before_acquire {
173 opts = opts.test_before_acquire(test_before_acquire);
174 }
175 opts
176 }
177
178 /// Apply pool configuration to `SQLite` pool options.
179 #[cfg(feature = "sqlite")]
180 #[must_use]
181 pub fn apply_sqlite(
182 &self,
183 mut opts: sqlx::sqlite::SqlitePoolOptions,
184 ) -> sqlx::sqlite::SqlitePoolOptions {
185 if let Some(max_conns) = self.max_conns {
186 opts = opts.max_connections(max_conns);
187 }
188 if let Some(min_conns) = self.min_conns {
189 opts = opts.min_connections(min_conns);
190 }
191 if let Some(acquire_timeout) = self.acquire_timeout {
192 opts = opts.acquire_timeout(acquire_timeout);
193 }
194 if let Some(idle_timeout) = self.idle_timeout {
195 opts = opts.idle_timeout(Some(idle_timeout));
196 }
197 if let Some(max_lifetime) = self.max_lifetime {
198 opts = opts.max_lifetime(Some(max_lifetime));
199 }
200 if let Some(test_before_acquire) = self.test_before_acquire {
201 opts = opts.test_before_acquire(test_before_acquire);
202 }
203 opts
204 }
205}