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
21pub mod events;
22pub mod uow;
23
24use puerto_core::RepositoryError;
25use sqlx::migrate::Migrator;
26use sqlx::{PgPool, postgres::PgPoolOptions};
27use std::time::Duration;
28
29/// The outbox, delivery and dead-letter tables (`migrations/outbox.sql`). Puerto's CLI ships a
30/// byte-for-byte copy of it as a project migration.
31pub const OUTBOX_MIGRATION: &str = include_str!("../migrations/outbox.sql");
32
33/// Why the database could not be used at startup.
34#[derive(Debug, thiserror::Error)]
35#[non_exhaustive]
36pub enum DatabaseError {
37    #[error("database.connection_error")]
38    ConnectionError,
39    #[error("database.migration_error")]
40    MigrationError,
41}
42
43pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
44    PgPoolOptions::new()
45        .max_connections(5)
46        .acquire_timeout(Duration::from_secs(30))
47        .connect(database_url)
48        .await
49        .map_err(|_| DatabaseError::ConnectionError)
50}
51
52/// Applies the project's pending migrations — `migrator` is its `sqlx::migrate!("./migrations")`.
53pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
54    migrator
55        .run(pool)
56        .await
57        .map_err(|_| DatabaseError::MigrationError)
58}
59
60/// Whether the database answers a trivial query — what `/health/ready` reports.
61///
62/// Bounded by the pool's acquire timeout, so a database that is down makes the probe slow to
63/// answer rather than hang forever.
64pub async fn ping(pool: &PgPool) -> bool {
65    sqlx::query("SELECT 1").execute(pool).await.is_ok()
66}
67
68/// The one place that knows Postgres error codes: a driver error, as the repository port speaks it.
69/// The caller logs it first, with its own context.
70pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
71    // 23505 = unique_violation: a `unique` column already taken. (No let-chain: `rust-version`.)
72    let unique_violation = err
73        .as_database_error()
74        .and_then(|db_err| db_err.code())
75        .is_some_and(|code| code == "23505");
76    if unique_violation {
77        RepositoryError::Duplicate
78    } else {
79        RepositoryError::Unavailable
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    /// A pool that never reaches a server: nothing listens on port 1.
88    fn unreachable_pool() -> PgPool {
89        PgPoolOptions::new()
90            .acquire_timeout(Duration::from_millis(200))
91            .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
92            .unwrap()
93    }
94
95    /// A driver error carrying a Postgres SQLSTATE, without a server.
96    #[derive(Debug)]
97    struct FakeDbError(&'static str);
98
99    impl std::fmt::Display for FakeDbError {
100        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101            write!(f, "SQLSTATE {}", self.0)
102        }
103    }
104
105    impl std::error::Error for FakeDbError {}
106
107    impl sqlx::error::DatabaseError for FakeDbError {
108        fn message(&self) -> &str {
109            self.0
110        }
111        fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
112            Some(self.0.into())
113        }
114        fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
115            self
116        }
117        fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
118            self
119        }
120        fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
121            self
122        }
123        fn kind(&self) -> sqlx::error::ErrorKind {
124            sqlx::error::ErrorKind::Other
125        }
126    }
127
128    fn database_error(code: &'static str) -> sqlx::Error {
129        sqlx::Error::Database(Box::new(FakeDbError(code)))
130    }
131
132    #[test]
133    fn should_map_a_unique_violation_to_duplicate() {
134        assert_eq!(
135            map_sqlx_error(&database_error("23505")),
136            RepositoryError::Duplicate
137        );
138    }
139
140    #[test]
141    fn should_map_another_database_error_to_unavailable() {
142        // 23503 = foreign_key_violation: a constraint, but not a taken unique value.
143        assert_eq!(
144            map_sqlx_error(&database_error("23503")),
145            RepositoryError::Unavailable
146        );
147    }
148
149    #[test]
150    fn should_map_a_non_database_error_to_unavailable() {
151        assert_eq!(
152            map_sqlx_error(&sqlx::Error::PoolTimedOut),
153            RepositoryError::Unavailable
154        );
155    }
156
157    #[tokio::test]
158    async fn should_report_not_ready_when_the_database_is_unreachable() {
159        assert!(!ping(&unreachable_pool()).await);
160    }
161
162    #[tokio::test]
163    async fn should_reject_a_database_url_that_is_not_one() {
164        let result = create_postgres_pool("not a database url").await;
165
166        assert!(matches!(result, Err(DatabaseError::ConnectionError)));
167    }
168
169    #[test]
170    fn should_keep_the_machine_readable_codes() {
171        assert_eq!(
172            DatabaseError::ConnectionError.to_string(),
173            "database.connection_error"
174        );
175        assert_eq!(
176            DatabaseError::MigrationError.to_string(),
177            "database.migration_error"
178        );
179    }
180
181    // ── Against a real server: `make test/db` ─────────────────────────────
182
183    async fn test_database() -> Option<PgPool> {
184        let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
185        Some(
186            create_postgres_pool(&format!("{server}/postgres"))
187                .await
188                .unwrap(),
189        )
190    }
191
192    #[tokio::test]
193    #[ignore = "needs Postgres: make test/db"]
194    async fn postgres_unique_violation_maps_to_duplicate() {
195        let Some(pool) = test_database().await else {
196            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
197            return;
198        };
199        let mut conn = pool.acquire().await.unwrap();
200        // A temporary table lives and dies with this connection: nothing to clean up.
201        sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
202            .execute(&mut *conn)
203            .await
204            .unwrap();
205        sqlx::query("INSERT INTO skus VALUES ('A-1')")
206            .execute(&mut *conn)
207            .await
208            .unwrap();
209
210        let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
211            .execute(&mut *conn)
212            .await
213            .unwrap_err();
214
215        assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
216    }
217
218    #[tokio::test]
219    #[ignore = "needs Postgres: make test/db"]
220    async fn postgres_ping_reports_ready() {
221        let Some(pool) = test_database().await else {
222            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
223            return;
224        };
225
226        assert!(ping(&pool).await);
227    }
228}