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//!
24//! A project with local accounts (ADR-0021 § 2) keeps their passwords and sessions in
25//! [`auth::PgCredentials`] and [`auth::PgSessions`], over [`USERS_MIGRATION`] and
26//! [`AUTH_MIGRATION`].
27
28pub mod auth;
29pub mod events;
30pub mod mail;
31pub mod uow;
32
33use puerto_core::RepositoryError;
34use sqlx::migrate::Migrator;
35use sqlx::postgres::{PgConnectOptions, PgConnection, PgPoolOptions};
36use sqlx::{Connection, PgPool};
37use std::time::Duration;
38
39/// The outbox, delivery and dead-letter tables (`migrations/outbox.sql`). Puerto's CLI ships a
40/// byte-for-byte copy of it as a project migration.
41pub const OUTBOX_MIGRATION: &str = include_str!("../migrations/outbox.sql");
42
43/// The sent-mail log (`migrations/sent_mails.sql`). Puerto's CLI ships a byte-for-byte copy of it
44/// as a project migration.
45pub const SENT_MAILS_MIGRATION: &str = include_str!("../migrations/sent_mails.sql");
46
47/// The users every auth project has (`migrations/users.sql`): [`auth::PgSessions`] reads it, so its
48/// schema is part of this crate's contract. Puerto's CLI ships a byte-for-byte copy of it as a
49/// project migration.
50pub const USERS_MIGRATION: &str = include_str!("../migrations/users.sql");
51
52/// The credentials and sessions of local accounts (`migrations/auth.sql`), after
53/// [`USERS_MIGRATION`]. Puerto's CLI ships a byte-for-byte copy of it as a project migration.
54pub const AUTH_MIGRATION: &str = include_str!("../migrations/auth.sql");
55
56/// Why the database could not be used at startup — read by the person starting the process, so
57/// it says where and why, never the password.
58#[derive(Debug, thiserror::Error)]
59#[non_exhaustive]
60pub enum DatabaseError {
61    /// `target` is the URL's `host[:port]/database`: no user, no password.
62    #[error("cannot connect to the database at {target}: {cause}")]
63    ConnectionError { target: String, cause: String },
64    #[error("cannot apply the database migrations: {cause}")]
65    MigrationError { cause: String },
66}
67
68pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
69    let failed = |e: sqlx::Error| DatabaseError::ConnectionError {
70        target: describe_url(database_url),
71        cause: e.to_string(),
72    };
73    let options: PgConnectOptions = database_url.parse().map_err(failed)?;
74    // One connection first, on its own: the pool retries a refused connection until its acquire
75    // timeout and then reports only that it timed out, not why.
76    let probe = PgConnection::connect_with(&options).await.map_err(failed)?;
77    let _ = probe.close().await;
78    Ok(PgPoolOptions::new()
79        .max_connections(5)
80        .acquire_timeout(Duration::from_secs(30))
81        .connect_lazy_with(options))
82}
83
84/// A connection URL as it can be shown: `host[:port]/database`, without the scheme, the
85/// credentials and the query. A string that is not a URL comes back as it is.
86fn describe_url(url: &str) -> String {
87    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
88    let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest);
89    rest.split('?').next().unwrap_or(rest).to_string()
90}
91
92/// Applies the project's pending migrations — `migrator` is its `sqlx::migrate!("./migrations")`.
93pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
94    migrator
95        .run(pool)
96        .await
97        .map_err(|e| DatabaseError::MigrationError {
98            cause: e.to_string(),
99        })
100}
101
102/// Whether the database answers a trivial query — what `/health/ready` reports.
103///
104/// Bounded by the pool's acquire timeout, so a database that is down makes the probe slow to
105/// answer rather than hang forever.
106pub async fn ping(pool: &PgPool) -> bool {
107    sqlx::query("SELECT 1").execute(pool).await.is_ok()
108}
109
110/// The one place that knows Postgres error codes: a driver error, as the repository port speaks it.
111/// The caller logs it first, with its own context. An entity with references converts with
112/// [`map_sqlx_error_with`] instead.
113pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
114    map_sqlx_error_with(err, &[])
115}
116
117/// A foreign key a repository writes through: the constraint's name in the migration and the field
118/// it checks (ADR-0022).
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct ForeignKey {
121    pub constraint: &'static str,
122    pub field: &'static str,
123}
124
125/// [`map_sqlx_error`], knowing the entity's foreign keys: a violation of one of them is
126/// [`RepositoryError::MissingReference`] with its field. One the list does not name is
127/// `Unavailable` — nothing can tell the caller which input was wrong.
128pub fn map_sqlx_error_with(err: &sqlx::Error, references: &[ForeignKey]) -> RepositoryError {
129    let Some(db_err) = err.as_database_error() else {
130        return RepositoryError::Unavailable;
131    };
132    match db_err.code().as_deref() {
133        // unique_violation: a `unique` column already taken.
134        Some("23505") => RepositoryError::Duplicate,
135        // foreign_key_violation: a reference to a row that does not exist.
136        Some("23503") => db_err
137            .constraint()
138            .and_then(|name| references.iter().find(|fk| fk.constraint == name))
139            .map_or(RepositoryError::Unavailable, |fk| {
140                RepositoryError::MissingReference(fk.field)
141            }),
142        _ => RepositoryError::Unavailable,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    /// A pool that never reaches a server: nothing listens on port 1.
151    fn unreachable_pool() -> PgPool {
152        PgPoolOptions::new()
153            .acquire_timeout(Duration::from_millis(200))
154            .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
155            .unwrap()
156    }
157
158    /// A driver error carrying a Postgres SQLSTATE (and the constraint it broke), without a server.
159    #[derive(Debug)]
160    struct FakeDbError {
161        code: &'static str,
162        constraint: Option<&'static str>,
163    }
164
165    impl std::fmt::Display for FakeDbError {
166        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167            write!(f, "SQLSTATE {}", self.code)
168        }
169    }
170
171    impl std::error::Error for FakeDbError {}
172
173    impl sqlx::error::DatabaseError for FakeDbError {
174        fn message(&self) -> &str {
175            self.code
176        }
177        fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
178            Some(self.code.into())
179        }
180        fn constraint(&self) -> Option<&str> {
181            self.constraint
182        }
183        fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
184            self
185        }
186        fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
187            self
188        }
189        fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
190            self
191        }
192        fn kind(&self) -> sqlx::error::ErrorKind {
193            sqlx::error::ErrorKind::Other
194        }
195    }
196
197    fn database_error(code: &'static str) -> sqlx::Error {
198        sqlx::Error::Database(Box::new(FakeDbError {
199            code,
200            constraint: None,
201        }))
202    }
203
204    fn constraint_error(code: &'static str, constraint: &'static str) -> sqlx::Error {
205        sqlx::Error::Database(Box::new(FakeDbError {
206            code,
207            constraint: Some(constraint),
208        }))
209    }
210
211    const ORDER_REFERENCES: &[ForeignKey] = &[
212        ForeignKey {
213            constraint: "orders_customer_id_fkey",
214            field: "customer_id",
215        },
216        ForeignKey {
217            constraint: "orders_reviewer_id_fkey",
218            field: "reviewer_id",
219        },
220    ];
221
222    #[test]
223    fn should_map_a_foreign_key_violation_to_the_missing_field() {
224        assert_eq!(
225            map_sqlx_error_with(
226                &constraint_error("23503", "orders_reviewer_id_fkey"),
227                ORDER_REFERENCES
228            ),
229            RepositoryError::MissingReference("reviewer_id")
230        );
231    }
232
233    #[test]
234    fn should_map_a_foreign_key_violation_on_an_unlisted_constraint_to_unavailable() {
235        assert_eq!(
236            map_sqlx_error_with(
237                &constraint_error("23503", "orders_warehouse_id_fkey"),
238                ORDER_REFERENCES
239            ),
240            RepositoryError::Unavailable
241        );
242    }
243
244    #[test]
245    fn should_map_a_unique_violation_to_duplicate_whatever_the_references() {
246        assert_eq!(
247            map_sqlx_error_with(
248                &constraint_error("23505", "orders_customer_id_fkey"),
249                ORDER_REFERENCES
250            ),
251            RepositoryError::Duplicate
252        );
253    }
254
255    #[test]
256    fn should_map_a_non_database_error_to_unavailable_whatever_the_references() {
257        assert_eq!(
258            map_sqlx_error_with(&sqlx::Error::PoolTimedOut, ORDER_REFERENCES),
259            RepositoryError::Unavailable
260        );
261    }
262
263    #[test]
264    fn should_map_a_unique_violation_to_duplicate() {
265        assert_eq!(
266            map_sqlx_error(&database_error("23505")),
267            RepositoryError::Duplicate
268        );
269    }
270
271    #[test]
272    fn should_map_another_database_error_to_unavailable() {
273        // 23503 = foreign_key_violation: a constraint, but not a taken unique value.
274        assert_eq!(
275            map_sqlx_error(&database_error("23503")),
276            RepositoryError::Unavailable
277        );
278    }
279
280    #[test]
281    fn should_map_a_non_database_error_to_unavailable() {
282        assert_eq!(
283            map_sqlx_error(&sqlx::Error::PoolTimedOut),
284            RepositoryError::Unavailable
285        );
286    }
287
288    #[tokio::test]
289    async fn should_report_not_ready_when_the_database_is_unreachable() {
290        assert!(!ping(&unreachable_pool()).await);
291    }
292
293    #[tokio::test]
294    async fn should_reject_a_database_url_that_is_not_one() {
295        let result = create_postgres_pool("not a database url").await;
296
297        assert!(matches!(result, Err(DatabaseError::ConnectionError { .. })));
298    }
299
300    #[tokio::test]
301    async fn should_say_where_and_why_the_connection_failed_without_the_password() {
302        // Arrange: nothing listens on port 1.
303        let url = "postgres://devuser:s3cret@127.0.0.1:1/shop";
304
305        // Act
306        let started = std::time::Instant::now();
307        let message = create_postgres_pool(url).await.unwrap_err().to_string();
308
309        // Assert
310        assert!(message.contains("127.0.0.1:1/shop"), "{message}");
311        assert!(message.to_lowercase().contains("refused"), "{message}");
312        assert!(!message.contains("s3cret"), "{message}");
313        assert!(!message.contains("devuser"), "{message}");
314        // A database that is down is reported at once, not after the pool's acquire timeout.
315        assert!(
316            started.elapsed() < Duration::from_secs(5),
317            "{:?}",
318            started.elapsed()
319        );
320    }
321
322    #[test]
323    fn should_describe_a_database_url_by_its_host_and_database_only() {
324        assert_eq!(
325            describe_url("postgres://u:p@db.internal:5432/shop?sslmode=require"),
326            "db.internal:5432/shop"
327        );
328        assert_eq!(describe_url("postgres://localhost/shop"), "localhost/shop");
329        assert_eq!(describe_url("not a database url"), "not a database url");
330    }
331
332    // ── Against a real server: `make test/db` ─────────────────────────────
333
334    async fn test_database() -> Option<PgPool> {
335        let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
336        Some(
337            create_postgres_pool(&format!("{server}/postgres"))
338                .await
339                .unwrap(),
340        )
341    }
342
343    #[tokio::test]
344    #[ignore = "needs Postgres: make test/db"]
345    async fn postgres_unique_violation_maps_to_duplicate() {
346        let Some(pool) = test_database().await else {
347            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
348            return;
349        };
350        let mut conn = pool.acquire().await.unwrap();
351        // A temporary table lives and dies with this connection: nothing to clean up.
352        sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
353            .execute(&mut *conn)
354            .await
355            .unwrap();
356        sqlx::query("INSERT INTO skus VALUES ('A-1')")
357            .execute(&mut *conn)
358            .await
359            .unwrap();
360
361        let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
362            .execute(&mut *conn)
363            .await
364            .unwrap_err();
365
366        assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
367    }
368
369    #[tokio::test]
370    #[ignore = "needs Postgres: make test/db"]
371    async fn postgres_foreign_key_violation_maps_to_the_missing_field() {
372        let Some(pool) = test_database().await else {
373            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
374            return;
375        };
376        let mut conn = pool.acquire().await.unwrap();
377        sqlx::query("CREATE TEMPORARY TABLE customers (id UUID PRIMARY KEY)")
378            .execute(&mut *conn)
379            .await
380            .unwrap();
381        sqlx::query(
382            "CREATE TEMPORARY TABLE orders (customer_id UUID NOT NULL \
383             CONSTRAINT orders_customer_id_fkey REFERENCES customers (id))",
384        )
385        .execute(&mut *conn)
386        .await
387        .unwrap();
388
389        let err = sqlx::query("INSERT INTO orders VALUES (gen_random_uuid())")
390            .execute(&mut *conn)
391            .await
392            .unwrap_err();
393
394        assert_eq!(
395            map_sqlx_error_with(&err, ORDER_REFERENCES),
396            RepositoryError::MissingReference("customer_id")
397        );
398    }
399
400    #[tokio::test]
401    #[ignore = "needs Postgres: make test/db"]
402    async fn postgres_ping_reports_ready() {
403        let Some(pool) = test_database().await else {
404            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
405            return;
406        };
407
408        assert!(ping(&pool).await);
409    }
410}