1use std::fmt;
4use std::sync::atomic::AtomicBool;
5use std::sync::{Arc, OnceLock};
6use std::time::Duration;
7
8use cdk_common::database::Error;
9use cdk_sql_common::database::{DatabaseConnector, DatabaseExecutor, GenericTransactionHandler};
10use cdk_sql_common::mint::SQLMintAuthDatabase;
11use cdk_sql_common::pool::{DatabaseConfig, DatabasePool};
12use cdk_sql_common::stmt::{Column, Statement};
13use cdk_sql_common::{SQLMintDatabase, SQLWalletDatabase};
14use db::{pg_batch, pg_execute, pg_fetch_all, pg_fetch_one, pg_pluck};
15use tokio::sync::{Mutex, Notify};
16use tokio::time::timeout;
17use tokio_postgres::{Client, Error as PgError, NoTls};
18
19mod db;
20mod tls;
21mod value;
22
23#[derive(Debug)]
24pub struct PgConnectionPool;
26
27#[derive(Clone)]
28pub enum SslMode {
30 NoTls(NoTls),
32 NativeTls(postgres_native_tls::MakeTlsConnector),
34}
35impl Default for SslMode {
36 fn default() -> Self {
37 SslMode::NoTls(NoTls {})
38 }
39}
40
41impl fmt::Debug for SslMode {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 let debug_text = match self {
44 Self::NoTls(_) => "NoTls",
45 Self::NativeTls(_) => "NativeTls",
46 };
47
48 write!(f, "SslMode::{debug_text}")
49 }
50}
51
52#[derive(Clone)]
54pub struct PgConfig {
55 url: String,
56 schema: Option<String>,
57 tls_mode: Option<String>,
58 max_connections: usize,
59 connection_timeout: Duration,
60}
61
62impl fmt::Debug for PgConfig {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.debug_struct("PgConfig")
65 .field("url", &"[redacted]")
66 .field("schema", &self.schema)
67 .field("tls_mode", &self.tls_mode.as_ref().map(|_| "[configured]"))
68 .field("max_connections", &self.max_connections)
69 .field("connection_timeout", &self.connection_timeout)
70 .finish()
71 }
72}
73
74impl DatabaseConfig for PgConfig {
75 fn default_timeout(&self) -> Duration {
76 self.connection_timeout
77 }
78
79 fn max_size(&self) -> usize {
80 self.max_connections
81 }
82}
83
84const DEFAULT_MAX_CONNECTIONS: usize = 20;
86
87const DEFAULT_CONNECTION_TIMEOUT_SECS: u64 = 10;
89
90impl PgConfig {
91 pub fn new(
100 conn_str: &str,
101 tls_mode: Option<&str>,
102 max_connections: Option<usize>,
103 connection_timeout_secs: Option<u64>,
104 ) -> Self {
105 let (schema, conn_str) = Self::strip_schema(conn_str);
106 Self {
107 url: conn_str,
108 schema,
109 tls_mode: tls_mode.map(str::to_owned),
110 max_connections: max_connections.unwrap_or(DEFAULT_MAX_CONNECTIONS),
111 connection_timeout: Duration::from_secs(
112 connection_timeout_secs.unwrap_or(DEFAULT_CONNECTION_TIMEOUT_SECS),
113 ),
114 }
115 }
116
117 pub fn validate(&self) -> Result<(), Error> {
122 tls::configure(&self.url, self.tls_mode.as_deref()).map(|_| ())
123 }
124
125 pub fn has_same_tls_policy(&self, other: &Self) -> Result<bool, Error> {
130 let (_, policy) = tls::resolve(&self.url, self.tls_mode.as_deref())?;
131 let (_, other_policy) = tls::resolve(&other.url, other.tls_mode.as_deref())?;
132 Ok(policy == other_policy)
133 }
134
135 fn strip_schema(input: &str) -> (Option<String>, String) {
137 let mut schema: Option<String> = None;
138
139 let mut parts = Vec::new();
141 for token in input.split_whitespace() {
142 if let Some(rest) = token.strip_prefix("schema=") {
143 schema = Some(rest.to_string());
144 } else {
145 parts.push(token);
146 }
147 }
148
149 let cleaned = parts.join(" ");
150 (schema, cleaned)
151 }
152}
153
154impl From<&str> for PgConfig {
155 fn from(conn_str: &str) -> Self {
156 Self::new(conn_str, None, None, None)
157 }
158}
159
160impl DatabasePool for PgConnectionPool {
161 type Config = PgConfig;
162
163 type Connection = PostgresConnection;
164
165 type Error = PgError;
166
167 fn new_resource(
168 config: &Self::Config,
169 stale: Arc<AtomicBool>,
170 timeout: Duration,
171 ) -> Result<Self::Connection, cdk_sql_common::pool::Error<Self::Error>> {
172 Ok(PostgresConnection::new(config.to_owned(), timeout, stale))
173 }
174}
175
176#[derive(Debug)]
178pub struct PostgresConnection {
179 timeout: Duration,
180 error: Arc<Mutex<Option<cdk_common::database::Error>>>,
181 result: Arc<OnceLock<Client>>,
182 notify: Arc<Notify>,
183}
184
185impl PostgresConnection {
186 pub fn new(config: PgConfig, timeout: Duration, stale: Arc<AtomicBool>) -> Self {
188 let failed = Arc::new(Mutex::new(None));
189 let result = Arc::new(OnceLock::new());
190 let notify = Arc::new(Notify::new());
191 let error_clone = failed.clone();
192 let result_clone = result.clone();
193 let notify_clone = notify.clone();
194
195 async fn select_schema(conn: &Client, schema: &str) -> Result<(), Error> {
196 conn.batch_execute(&format!(
197 r#"
198 CREATE SCHEMA IF NOT EXISTS "{schema}";
199 SET search_path TO "{schema}"
200 "#
201 ))
202 .await
203 .map_err(|e| Error::Database(Box::new(e)))
204 }
205
206 tokio::spawn(async move {
207 let (connection_config, tls) =
208 match tls::configure(&config.url, config.tls_mode.as_deref()) {
209 Ok(config) => config,
210 Err(err) => {
211 *error_clone.lock().await = Some(err);
212 stale.store(true, std::sync::atomic::Ordering::Release);
213 notify_clone.notify_waiters();
214 return;
215 }
216 };
217 match tls {
218 SslMode::NoTls(tls) => {
219 let (client, connection) = match connection_config.connect(tls).await {
220 Ok((client, connection)) => (client, connection),
221 Err(err) => {
222 *error_clone.lock().await =
223 Some(cdk_common::database::Error::Database(Box::new(err)));
224 stale.store(true, std::sync::atomic::Ordering::Release);
225 notify_clone.notify_waiters();
226 return;
227 }
228 };
229
230 let stale_for_spawn = stale.clone();
231 tokio::spawn(async move {
232 let _ = connection.await;
233 stale_for_spawn.store(true, std::sync::atomic::Ordering::Release);
234 });
235
236 if let Some(schema) = config.schema.as_ref() {
237 if let Err(err) = select_schema(&client, schema).await {
238 *error_clone.lock().await = Some(err);
239 stale.store(true, std::sync::atomic::Ordering::Release);
240 notify_clone.notify_waiters();
241 return;
242 }
243 }
244
245 let _ = result_clone.set(client);
246 notify_clone.notify_waiters();
247 }
248 SslMode::NativeTls(tls) => {
249 let (client, connection) = match connection_config.connect(tls).await {
250 Ok((client, connection)) => (client, connection),
251 Err(err) => {
252 *error_clone.lock().await =
253 Some(cdk_common::database::Error::Database(Box::new(err)));
254 stale.store(true, std::sync::atomic::Ordering::Release);
255 notify_clone.notify_waiters();
256 return;
257 }
258 };
259
260 let stale_for_spawn = stale.clone();
261 tokio::spawn(async move {
262 let _ = connection.await;
263 stale_for_spawn.store(true, std::sync::atomic::Ordering::Release);
264 });
265
266 if let Some(schema) = config.schema.as_ref() {
267 if let Err(err) = select_schema(&client, schema).await {
268 *error_clone.lock().await = Some(err);
269 stale.store(true, std::sync::atomic::Ordering::Release);
270 notify_clone.notify_waiters();
271 return;
272 }
273 }
274
275 let _ = result_clone.set(client);
276 notify_clone.notify_waiters();
277 }
278 }
279 });
280
281 Self {
282 error: failed,
283 timeout,
284 result,
285 notify,
286 }
287 }
288
289 async fn inner(&self) -> Result<&Client, cdk_common::database::Error> {
292 if let Some(client) = self.result.get() {
293 return Ok(client);
294 }
295
296 if let Some(error) = self.error.lock().await.take() {
297 return Err(error);
298 }
299
300 if timeout(self.timeout, self.notify.notified()).await.is_err() {
301 return Err(cdk_common::database::Error::Internal("Timeout".to_owned()));
302 }
303
304 if let Some(client) = self.result.get() {
306 Ok(client)
307 } else if let Some(error) = self.error.lock().await.take() {
308 Err(error)
309 } else {
310 Err(cdk_common::database::Error::Internal(
311 "Failed connection".to_owned(),
312 ))
313 }
314 }
315}
316
317#[async_trait::async_trait]
318impl DatabaseConnector for PostgresConnection {
319 type Transaction = GenericTransactionHandler<Self>;
320}
321
322#[async_trait::async_trait]
323impl DatabaseExecutor for PostgresConnection {
324 fn name() -> &'static str {
325 "postgres"
326 }
327
328 async fn execute(&self, statement: Statement) -> Result<usize, Error> {
329 pg_execute(self.inner().await?, statement).await
330 }
331
332 async fn fetch_one(&self, statement: Statement) -> Result<Option<Vec<Column>>, Error> {
333 pg_fetch_one(self.inner().await?, statement).await
334 }
335
336 async fn fetch_all(&self, statement: Statement) -> Result<Vec<Vec<Column>>, Error> {
337 pg_fetch_all(self.inner().await?, statement).await
338 }
339
340 async fn pluck(&self, statement: Statement) -> Result<Option<Column>, Error> {
341 pg_pluck(self.inner().await?, statement).await
342 }
343
344 async fn batch(&self, statement: Statement) -> Result<(), Error> {
345 pg_batch(self.inner().await?, statement).await
346 }
347}
348
349pub type MintPgDatabase = SQLMintDatabase<PgConnectionPool>;
351
352pub type MintPgAuthDatabase = SQLMintAuthDatabase<PgConnectionPool>;
354
355pub type WalletPgDatabase = SQLWalletDatabase<PgConnectionPool>;
357
358pub async fn new_wallet_pg_database(conn_str: &str) -> Result<WalletPgDatabase, Error> {
361 <SQLWalletDatabase<PgConnectionPool>>::new(conn_str).await
362}
363
364#[cfg(test)]
365mod test {
366 use cdk_common::{mint_db_test, wallet_db_test, QuoteId};
367
368 use super::*;
369
370 async fn provide_mint_db(test_id: String) -> MintPgDatabase {
371 let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
372 .or_else(|_| std::env::var("PG_DB_URL")) .unwrap_or(
374 "host=localhost user=cdk_user password=cdk_password dbname=cdk_mint port=5432"
375 .to_owned(),
376 );
377
378 let db_url = format!("{db_url} schema={test_id}");
379
380 MintPgDatabase::new(db_url.as_str())
381 .await
382 .expect("database")
383 }
384
385 mint_db_test!(provide_mint_db);
386
387 #[tokio::test]
388 async fn mint_pool_accepts_single_connection_configuration() {
389 use cdk_common::database::MintDatabase;
390
391 let test_id = format!("test_single_connection_pool_{}", uuid::Uuid::new_v4());
392 let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
393 .or_else(|_| std::env::var("PG_DB_URL"))
394 .unwrap_or(
395 "host=localhost user=cdk_user password=cdk_password dbname=cdk_mint port=5432"
396 .to_owned(),
397 );
398 let config = PgConfig::new(
399 &format!("{db_url} schema={test_id}"),
400 None,
401 Some(1),
402 Some(10),
403 );
404
405 let db = MintPgDatabase::new(config)
406 .await
407 .expect("single-connection mint pool should remain supported");
408 let regular = MintDatabase::begin_transaction(&db)
409 .await
410 .expect("regular transaction");
411 regular.rollback().await.expect("regular rollback");
412 }
413
414 #[tokio::test]
415 async fn quote_lock_batch_excludes_concurrent_transaction() {
416 use std::sync::Arc;
417 use std::time::Duration;
418
419 use cdk_common::database::MintDatabase;
420
421 let test_id = format!("test_quote_lock_batch_{}", uuid::Uuid::new_v4());
422 let db = Arc::new(provide_mint_db(test_id).await);
423 let first = QuoteId::new();
424 let second = QuoteId::new();
425
426 let mut holder = MintDatabase::begin_transaction(&*db).await.expect("tx");
427 holder
428 .lock_quotes(&[first.clone(), second.clone()])
429 .await
430 .expect("lock");
431
432 let waiter = tokio::spawn({
433 let db = db.clone();
434 async move {
435 let mut tx = MintDatabase::begin_transaction(&*db).await.expect("tx");
436 tx.lock_quotes(&[second, first]).await.expect("lock");
437 tx.commit().await.expect("commit");
438 }
439 });
440
441 tokio::time::sleep(Duration::from_millis(300)).await;
442 assert!(
443 !waiter.is_finished(),
444 "reversed quote batch did not wait for the holder"
445 );
446
447 holder.commit().await.expect("commit");
448 tokio::time::timeout(Duration::from_secs(5), waiter)
449 .await
450 .expect("reversed quote batch remained blocked")
451 .expect("waiter task");
452 }
453
454 #[tokio::test]
455 async fn kvstore_compare_and_swap() {
456 let test_id = format!("test_kvstore_compare_and_swap_{}", uuid::Uuid::new_v4());
457 cdk_common::database::mint::test::kvstore_compare_and_swap(provide_mint_db(test_id).await)
458 .await;
459 }
460
461 #[tokio::test]
462 async fn concurrent_mint_quote_batches_use_consistent_lock_order() {
463 let test_id = format!(
464 "test_concurrent_mint_quote_batches_{}",
465 uuid::Uuid::new_v4()
466 );
467 cdk_common::database::mint::test::concurrent_mint_quote_batches_use_consistent_lock_order(
468 Arc::new(provide_mint_db(test_id).await),
469 )
470 .await;
471 }
472
473 #[tokio::test]
474 async fn concurrent_multi_keyset_spends_use_consistent_lock_order() {
475 let test_id = format!(
476 "test_concurrent_multi_keyset_spends_{}",
477 uuid::Uuid::new_v4()
478 );
479 cdk_common::database::mint::test::concurrent_multi_keyset_spends_use_consistent_lock_order(
480 Arc::new(provide_mint_db(test_id).await),
481 )
482 .await;
483 }
484
485 async fn provide_wallet_db(test_id: String) -> WalletPgDatabase {
486 let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
487 .or_else(|_| std::env::var("PG_DB_URL")) .unwrap_or(
489 "host=localhost user=cdk_user password=cdk_password dbname=cdk_mint port=5432"
490 .to_owned(),
491 );
492
493 let db_url = format!("{db_url} schema={test_id}");
494
495 WalletPgDatabase::new(db_url.as_str())
496 .await
497 .expect("database")
498 }
499
500 wallet_db_test!(provide_wallet_db);
501
502 #[tokio::test]
503 async fn failed_initial_connect_marks_connection_stale() {
504 let stale = Arc::new(AtomicBool::new(false));
505 let config = PgConfig::from("host=127.0.0.1 port=1 user=cdk dbname=cdk connect_timeout=1");
506 let conn = PostgresConnection::new(config, Duration::from_secs(5), stale.clone());
507
508 assert!(
509 conn.inner().await.is_err(),
510 "connect to refused port should fail"
511 );
512 tokio::task::yield_now().await;
513
514 assert!(
515 stale.load(std::sync::atomic::Ordering::SeqCst),
516 "failed initial connect should mark the pooled connection stale"
517 );
518 }
519
520 #[test]
521 fn pgconfig_debug_does_not_leak_password() {
522 let config = PgConfig::from("host=localhost user=u password=hunter2secret dbname=d");
523 let rendered = format!("{config:?}");
524
525 assert!(
526 !rendered.contains("hunter2secret"),
527 "PgConfig Debug leaked the DB password: {rendered}"
528 );
529 }
530}