Skip to main content

puerto_sqlx/
lib.rs

1//! The Postgres adapter every Puerto project with a database shares (ADR-0017): the connection
2//! pool, migrations at startup, the readiness ping and the one mapping from driver errors to
3//! [`RepositoryError`].
4//!
5//! The migrations are the project's own: `sqlx::migrate!` embeds a directory at compile time, so
6//! the project declares its [`Migrator`] and hands it over. A generated `infrastructure/src/db.rs`
7//! is
8//!
9//! ```ignore
10//! pub use puerto_sqlx::*;
11//!
12//! pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
13//! ```
14//!
15//! and its repositories call `crate::db::map_sqlx_error`.
16//!
17//! A project with domain events (ADR-0018) also uses [`uow::PgTransaction`] in its generated units
18//! of work, and its worker the outbox, the `db` transport and the dead-letter sink in [`events`].
19//! Their tables are [`OUTBOX_MIGRATION`], which `puerto new --events` writes into the project.
20//!
21//! A worker that sends mail (ADR-0020) keeps what it sent in [`mail::PgSentMails`], over
22//! [`SENT_MAILS_MIGRATION`].
23
24pub mod events;
25pub mod mail;
26pub mod uow;
27
28use puerto_core::RepositoryError;
29use sqlx::migrate::Migrator;
30use sqlx::postgres::{PgConnectOptions, PgConnection, PgPoolOptions};
31use sqlx::{Connection, PgPool};
32use std::time::Duration;
33
34/// The outbox, delivery and dead-letter tables (`migrations/outbox.sql`). Puerto's CLI ships a
35/// byte-for-byte copy of it as a project migration.
36pub const OUTBOX_MIGRATION: &str = include_str!("../migrations/outbox.sql");
37
38/// The sent-mail log (`migrations/sent_mails.sql`). Puerto's CLI ships a byte-for-byte copy of it
39/// as a project migration.
40pub const SENT_MAILS_MIGRATION: &str = include_str!("../migrations/sent_mails.sql");
41
42/// Why the database could not be used at startup — read by the person starting the process, so
43/// it says where and why, never the password.
44#[derive(Debug, thiserror::Error)]
45#[non_exhaustive]
46pub enum DatabaseError {
47    /// `target` is the URL's `host[:port]/database`: no user, no password.
48    #[error("cannot connect to the database at {target}: {cause}")]
49    ConnectionError { target: String, cause: String },
50    #[error("cannot apply the database migrations: {cause}")]
51    MigrationError { cause: String },
52}
53
54pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
55    let failed = |e: sqlx::Error| DatabaseError::ConnectionError {
56        target: describe_url(database_url),
57        cause: e.to_string(),
58    };
59    let options: PgConnectOptions = database_url.parse().map_err(failed)?;
60    // One connection first, on its own: the pool retries a refused connection until its acquire
61    // timeout and then reports only that it timed out, not why.
62    let probe = PgConnection::connect_with(&options).await.map_err(failed)?;
63    let _ = probe.close().await;
64    Ok(PgPoolOptions::new()
65        .max_connections(5)
66        .acquire_timeout(Duration::from_secs(30))
67        .connect_lazy_with(options))
68}
69
70/// A connection URL as it can be shown: `host[:port]/database`, without the scheme, the
71/// credentials and the query. A string that is not a URL comes back as it is.
72fn describe_url(url: &str) -> String {
73    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
74    let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest);
75    rest.split('?').next().unwrap_or(rest).to_string()
76}
77
78/// Applies the project's pending migrations — `migrator` is its `sqlx::migrate!("./migrations")`.
79pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
80    migrator
81        .run(pool)
82        .await
83        .map_err(|e| DatabaseError::MigrationError {
84            cause: e.to_string(),
85        })
86}
87
88/// Whether the database answers a trivial query — what `/health/ready` reports.
89///
90/// Bounded by the pool's acquire timeout, so a database that is down makes the probe slow to
91/// answer rather than hang forever.
92pub async fn ping(pool: &PgPool) -> bool {
93    sqlx::query("SELECT 1").execute(pool).await.is_ok()
94}
95
96/// The one place that knows Postgres error codes: a driver error, as the repository port speaks it.
97/// The caller logs it first, with its own context.
98pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
99    // 23505 = unique_violation: a `unique` column already taken. (No let-chain: `rust-version`.)
100    let unique_violation = err
101        .as_database_error()
102        .and_then(|db_err| db_err.code())
103        .is_some_and(|code| code == "23505");
104    if unique_violation {
105        RepositoryError::Duplicate
106    } else {
107        RepositoryError::Unavailable
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// A pool that never reaches a server: nothing listens on port 1.
116    fn unreachable_pool() -> PgPool {
117        PgPoolOptions::new()
118            .acquire_timeout(Duration::from_millis(200))
119            .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
120            .unwrap()
121    }
122
123    /// A driver error carrying a Postgres SQLSTATE, without a server.
124    #[derive(Debug)]
125    struct FakeDbError(&'static str);
126
127    impl std::fmt::Display for FakeDbError {
128        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129            write!(f, "SQLSTATE {}", self.0)
130        }
131    }
132
133    impl std::error::Error for FakeDbError {}
134
135    impl sqlx::error::DatabaseError for FakeDbError {
136        fn message(&self) -> &str {
137            self.0
138        }
139        fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
140            Some(self.0.into())
141        }
142        fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
143            self
144        }
145        fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
146            self
147        }
148        fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
149            self
150        }
151        fn kind(&self) -> sqlx::error::ErrorKind {
152            sqlx::error::ErrorKind::Other
153        }
154    }
155
156    fn database_error(code: &'static str) -> sqlx::Error {
157        sqlx::Error::Database(Box::new(FakeDbError(code)))
158    }
159
160    #[test]
161    fn should_map_a_unique_violation_to_duplicate() {
162        assert_eq!(
163            map_sqlx_error(&database_error("23505")),
164            RepositoryError::Duplicate
165        );
166    }
167
168    #[test]
169    fn should_map_another_database_error_to_unavailable() {
170        // 23503 = foreign_key_violation: a constraint, but not a taken unique value.
171        assert_eq!(
172            map_sqlx_error(&database_error("23503")),
173            RepositoryError::Unavailable
174        );
175    }
176
177    #[test]
178    fn should_map_a_non_database_error_to_unavailable() {
179        assert_eq!(
180            map_sqlx_error(&sqlx::Error::PoolTimedOut),
181            RepositoryError::Unavailable
182        );
183    }
184
185    #[tokio::test]
186    async fn should_report_not_ready_when_the_database_is_unreachable() {
187        assert!(!ping(&unreachable_pool()).await);
188    }
189
190    #[tokio::test]
191    async fn should_reject_a_database_url_that_is_not_one() {
192        let result = create_postgres_pool("not a database url").await;
193
194        assert!(matches!(result, Err(DatabaseError::ConnectionError { .. })));
195    }
196
197    #[tokio::test]
198    async fn should_say_where_and_why_the_connection_failed_without_the_password() {
199        // Arrange: nothing listens on port 1.
200        let url = "postgres://devuser:s3cret@127.0.0.1:1/shop";
201
202        // Act
203        let started = std::time::Instant::now();
204        let message = create_postgres_pool(url).await.unwrap_err().to_string();
205
206        // Assert
207        assert!(message.contains("127.0.0.1:1/shop"), "{message}");
208        assert!(message.to_lowercase().contains("refused"), "{message}");
209        assert!(!message.contains("s3cret"), "{message}");
210        assert!(!message.contains("devuser"), "{message}");
211        // A database that is down is reported at once, not after the pool's acquire timeout.
212        assert!(
213            started.elapsed() < Duration::from_secs(5),
214            "{:?}",
215            started.elapsed()
216        );
217    }
218
219    #[test]
220    fn should_describe_a_database_url_by_its_host_and_database_only() {
221        assert_eq!(
222            describe_url("postgres://u:p@db.internal:5432/shop?sslmode=require"),
223            "db.internal:5432/shop"
224        );
225        assert_eq!(describe_url("postgres://localhost/shop"), "localhost/shop");
226        assert_eq!(describe_url("not a database url"), "not a database url");
227    }
228
229    // ── Against a real server: `make test/db` ─────────────────────────────
230
231    async fn test_database() -> Option<PgPool> {
232        let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
233        Some(
234            create_postgres_pool(&format!("{server}/postgres"))
235                .await
236                .unwrap(),
237        )
238    }
239
240    #[tokio::test]
241    #[ignore = "needs Postgres: make test/db"]
242    async fn postgres_unique_violation_maps_to_duplicate() {
243        let Some(pool) = test_database().await else {
244            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
245            return;
246        };
247        let mut conn = pool.acquire().await.unwrap();
248        // A temporary table lives and dies with this connection: nothing to clean up.
249        sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
250            .execute(&mut *conn)
251            .await
252            .unwrap();
253        sqlx::query("INSERT INTO skus VALUES ('A-1')")
254            .execute(&mut *conn)
255            .await
256            .unwrap();
257
258        let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
259            .execute(&mut *conn)
260            .await
261            .unwrap_err();
262
263        assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
264    }
265
266    #[tokio::test]
267    #[ignore = "needs Postgres: make test/db"]
268    async fn postgres_ping_reports_ready() {
269        let Some(pool) = test_database().await else {
270            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
271            return;
272        };
273
274        assert!(ping(&pool).await);
275    }
276}