Skip to main content

toolkit_db/
config.rs

1//! Database configuration types.
2//!
3//! This gear 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 gear-specific overrides:
11//!
12//! | Priority | Source | Description | Example |
13//! |----------|--------|-------------|---------|
14//! | 1 (Highest) | Gear `params` map | Key-value parameters in gear config | `params: {synchronous: "FULL"}` |
15//! | 2 | Gear DSN query params | Parameters in gear-level DSN | `sqlite://file.db?synchronous=NORMAL` |
16//! | 3 | Gear fields | Individual connection fields | `host: "localhost", port: 5432` |
17//! | 4 | Gear 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**: Gear fields always override server fields
26//! 2. **DSN Precedence**: Gear DSN overrides server DSN completely
27//! 3. **Params Merging**: `params` maps are merged, with gear params taking precedence
28//! 4. **Pool Configuration**: Gear pool config overrides server pool config entirely
29//! 5. **`SQLite` Paths**: `file`/`path` fields are gear-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;
44use toolkit_utils::SecretString;
45
46/// Global database configuration with server-based DBs.
47#[derive(Debug, Clone, Deserialize, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct GlobalDatabaseConfig {
50    /// Server-based DBs (postgres/mysql/sqlite/etc.), keyed by server name.
51    #[serde(default)]
52    pub servers: HashMap<String, DbConnConfig>,
53    /// Optional dev-only flag to auto-provision DB/schema when missing.
54    #[serde(default)]
55    pub auto_provision: Option<bool>,
56}
57
58/// Reusable DB connection config for both global servers and gears.
59/// DSN must be a FULL, valid DSN if provided (dsn crate compliant).
60///
61/// Not `#[non_exhaustive]`: callers (including first-party tests) build it with struct-literal +
62/// `..Default::default()`, so that attribute would break them. Deserialization stays
63/// backward-compatible via `#[serde(default)]` on new fields, but adding a public field is a
64/// source-breaking change for exhaustive struct-literal callers — see the crate semver note.
65#[derive(Debug, Clone, Deserialize, Serialize, Default)]
66#[serde(deny_unknown_fields)]
67pub struct DbConnConfig {
68    /// Explicit database engine for this connection.
69    ///
70    /// This is required for configurations without `dsn`, where the engine cannot be inferred
71    /// reliably (e.g. distinguishing `MySQL` vs `PostgreSQL`, or selecting `SQLite` for file/path configs).
72    ///
73    /// If both `engine` and `dsn` are provided, they must not conflict (validated at runtime).
74    #[serde(default)]
75    pub engine: Option<DbEngineCfg>,
76
77    // DSN-style (full, valid). Optional: can be absent and rely on fields.
78    #[serde(
79        default,
80        serialize_with = "toolkit_utils::secret_string::serialize_option_exposed"
81    )]
82    pub dsn: Option<SecretString>,
83
84    // Field-based style; any of these override DSN parts when present:
85    pub host: Option<String>,
86    pub port: Option<u16>,
87    pub user: Option<String>,
88    #[serde(
89        default,
90        serialize_with = "toolkit_utils::secret_string::serialize_option_exposed"
91    )]
92    pub password: Option<SecretString>, // literal password or ${VAR} for env expansion
93    pub dbname: Option<String>, // MUST be present in final for server-based DBs
94    /// Backend-specific connection parameters.
95    ///
96    /// Recognized keys are mapped to typed `sqlx` options (`sslmode`,
97    /// `application_name`, `statement_cache_capacity`, ...). **Anything else
98    /// is passed to `PostgreSQL` as a server runtime parameter**, i.e. applied
99    /// as if by `SET` on every connection in the pool.
100    ///
101    /// That catch-all is how session GUCs are configured, and it is easy to
102    /// miss because nothing names them:
103    ///
104    /// ```yaml
105    /// params:
106    ///   statement_timeout: "5s"   # abort a single statement that runs longer
107    ///   lock_timeout: "2s"        # give up waiting for a row lock
108    /// ```
109    ///
110    /// Neither has a default here, so a deployment that sets nothing has
111    /// neither bound. Note that `statement_timeout` bounds one statement, not
112    /// a transaction. For the transaction as a whole there is
113    /// `transaction_timeout`, added in `PostgreSQL` 17; before that version
114    /// there is no equivalent, and `idle_in_transaction_session_timeout`
115    /// bounds only the idle part. All three are runtime parameters, so all
116    /// three are set the same way.
117    ///
118    /// `MySQL` has no runtime-parameter equivalent; unrecognized keys there are
119    /// rejected rather than forwarded.
120    #[serde(default)]
121    pub params: Option<HashMap<String, String>>,
122
123    // SQLite file-based helpers (gear-level only; ignored for global):
124    pub file: Option<String>,  // relative name under home_dir/gear
125    pub path: Option<PathBuf>, // absolute path
126
127    // Connection pool overrides:
128    #[serde(default)]
129    pub pool: Option<PoolCfg>,
130
131    /// Keepalive ping interval for the dedicated advisory-lock session (PG/MySQL only).
132    ///
133    /// Defaults to [`crate::advisory_locks::DEFAULT_LOCK_KEEPALIVE`] when unset. Tune below any
134    /// proxy/database idle-connection cutoff so the single lock session is not reaped.
135    #[serde(with = "toolkit_utils::humantime_serde::option", default)]
136    pub lock_keepalive: Option<Duration>,
137
138    // Gear-level only: reference to a global server by name.
139    // If absent, this gear config must be fully self-sufficient (dsn or fields).
140    pub server: Option<String>,
141}
142
143/// Serializable engine selector for configuration.
144///
145/// Keep this separate from `toolkit_db::DbEngine` (runtime type) to avoid coupling it to serde.
146#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
147#[serde(rename_all = "lowercase")]
148pub enum DbEngineCfg {
149    Postgres,
150    Mysql,
151    Sqlite,
152}
153
154#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
155#[serde(deny_unknown_fields)]
156pub struct PoolCfg {
157    pub max_conns: Option<u32>,
158    pub min_conns: Option<u32>,
159    #[serde(with = "toolkit_utils::humantime_serde::option", default)]
160    pub acquire_timeout: Option<Duration>,
161    #[serde(with = "toolkit_utils::humantime_serde::option", default)]
162    pub idle_timeout: Option<Duration>,
163    #[serde(with = "toolkit_utils::humantime_serde::option", default)]
164    pub max_lifetime: Option<Duration>,
165    pub test_before_acquire: Option<bool>,
166}
167
168impl PoolCfg {
169    /// Apply pool configuration to `PostgreSQL` pool options.
170    #[cfg(feature = "pg")]
171    #[must_use]
172    pub fn apply_pg(
173        &self,
174        mut opts: sqlx::postgres::PgPoolOptions,
175    ) -> sqlx::postgres::PgPoolOptions {
176        if let Some(max_conns) = self.max_conns {
177            opts = opts.max_connections(max_conns);
178        }
179        if let Some(min_conns) = self.min_conns {
180            opts = opts.min_connections(min_conns);
181        }
182        if let Some(acquire_timeout) = self.acquire_timeout {
183            opts = opts.acquire_timeout(acquire_timeout);
184        }
185        if let Some(idle_timeout) = self.idle_timeout {
186            opts = opts.idle_timeout(Some(idle_timeout));
187        }
188        if let Some(max_lifetime) = self.max_lifetime {
189            opts = opts.max_lifetime(Some(max_lifetime));
190        }
191        if let Some(test_before_acquire) = self.test_before_acquire {
192            opts = opts.test_before_acquire(test_before_acquire);
193        }
194        opts
195    }
196
197    /// Apply pool configuration to `MySQL` pool options.
198    #[cfg(feature = "mysql")]
199    #[must_use]
200    pub fn apply_mysql(
201        &self,
202        mut opts: sqlx::mysql::MySqlPoolOptions,
203    ) -> sqlx::mysql::MySqlPoolOptions {
204        if let Some(max_conns) = self.max_conns {
205            opts = opts.max_connections(max_conns);
206        }
207        if let Some(min_conns) = self.min_conns {
208            opts = opts.min_connections(min_conns);
209        }
210        if let Some(acquire_timeout) = self.acquire_timeout {
211            opts = opts.acquire_timeout(acquire_timeout);
212        }
213        if let Some(idle_timeout) = self.idle_timeout {
214            opts = opts.idle_timeout(Some(idle_timeout));
215        }
216        if let Some(max_lifetime) = self.max_lifetime {
217            opts = opts.max_lifetime(Some(max_lifetime));
218        }
219        if let Some(test_before_acquire) = self.test_before_acquire {
220            opts = opts.test_before_acquire(test_before_acquire);
221        }
222        opts
223    }
224
225    /// Apply pool configuration to `SQLite` pool options.
226    #[cfg(feature = "sqlite")]
227    #[must_use]
228    pub fn apply_sqlite(
229        &self,
230        mut opts: sqlx::sqlite::SqlitePoolOptions,
231    ) -> sqlx::sqlite::SqlitePoolOptions {
232        if let Some(max_conns) = self.max_conns {
233            opts = opts.max_connections(max_conns);
234        }
235        if let Some(min_conns) = self.min_conns {
236            opts = opts.min_connections(min_conns);
237        }
238        if let Some(acquire_timeout) = self.acquire_timeout {
239            opts = opts.acquire_timeout(acquire_timeout);
240        }
241        if let Some(idle_timeout) = self.idle_timeout {
242            opts = opts.idle_timeout(Some(idle_timeout));
243        }
244        if let Some(max_lifetime) = self.max_lifetime {
245            opts = opts.max_lifetime(Some(max_lifetime));
246        }
247        if let Some(test_before_acquire) = self.test_before_acquire {
248            opts = opts.test_before_acquire(test_before_acquire);
249        }
250        opts
251    }
252}