1pub 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
39pub const OUTBOX_MIGRATION: &str = include_str!("../migrations/outbox.sql");
42
43pub const SENT_MAILS_MIGRATION: &str = include_str!("../migrations/sent_mails.sql");
46
47pub const USERS_MIGRATION: &str = include_str!("../migrations/users.sql");
51
52pub const AUTH_MIGRATION: &str = include_str!("../migrations/auth.sql");
55
56#[derive(Debug, thiserror::Error)]
59#[non_exhaustive]
60pub enum DatabaseError {
61 #[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 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
84fn 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
92pub 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
102pub async fn ping(pool: &PgPool) -> bool {
107 sqlx::query("SELECT 1").execute(pool).await.is_ok()
108}
109
110pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
113 let unique_violation = err
115 .as_database_error()
116 .and_then(|db_err| db_err.code())
117 .is_some_and(|code| code == "23505");
118 if unique_violation {
119 RepositoryError::Duplicate
120 } else {
121 RepositoryError::Unavailable
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn unreachable_pool() -> PgPool {
131 PgPoolOptions::new()
132 .acquire_timeout(Duration::from_millis(200))
133 .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
134 .unwrap()
135 }
136
137 #[derive(Debug)]
139 struct FakeDbError(&'static str);
140
141 impl std::fmt::Display for FakeDbError {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 write!(f, "SQLSTATE {}", self.0)
144 }
145 }
146
147 impl std::error::Error for FakeDbError {}
148
149 impl sqlx::error::DatabaseError for FakeDbError {
150 fn message(&self) -> &str {
151 self.0
152 }
153 fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
154 Some(self.0.into())
155 }
156 fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
157 self
158 }
159 fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
160 self
161 }
162 fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
163 self
164 }
165 fn kind(&self) -> sqlx::error::ErrorKind {
166 sqlx::error::ErrorKind::Other
167 }
168 }
169
170 fn database_error(code: &'static str) -> sqlx::Error {
171 sqlx::Error::Database(Box::new(FakeDbError(code)))
172 }
173
174 #[test]
175 fn should_map_a_unique_violation_to_duplicate() {
176 assert_eq!(
177 map_sqlx_error(&database_error("23505")),
178 RepositoryError::Duplicate
179 );
180 }
181
182 #[test]
183 fn should_map_another_database_error_to_unavailable() {
184 assert_eq!(
186 map_sqlx_error(&database_error("23503")),
187 RepositoryError::Unavailable
188 );
189 }
190
191 #[test]
192 fn should_map_a_non_database_error_to_unavailable() {
193 assert_eq!(
194 map_sqlx_error(&sqlx::Error::PoolTimedOut),
195 RepositoryError::Unavailable
196 );
197 }
198
199 #[tokio::test]
200 async fn should_report_not_ready_when_the_database_is_unreachable() {
201 assert!(!ping(&unreachable_pool()).await);
202 }
203
204 #[tokio::test]
205 async fn should_reject_a_database_url_that_is_not_one() {
206 let result = create_postgres_pool("not a database url").await;
207
208 assert!(matches!(result, Err(DatabaseError::ConnectionError { .. })));
209 }
210
211 #[tokio::test]
212 async fn should_say_where_and_why_the_connection_failed_without_the_password() {
213 let url = "postgres://devuser:s3cret@127.0.0.1:1/shop";
215
216 let started = std::time::Instant::now();
218 let message = create_postgres_pool(url).await.unwrap_err().to_string();
219
220 assert!(message.contains("127.0.0.1:1/shop"), "{message}");
222 assert!(message.to_lowercase().contains("refused"), "{message}");
223 assert!(!message.contains("s3cret"), "{message}");
224 assert!(!message.contains("devuser"), "{message}");
225 assert!(
227 started.elapsed() < Duration::from_secs(5),
228 "{:?}",
229 started.elapsed()
230 );
231 }
232
233 #[test]
234 fn should_describe_a_database_url_by_its_host_and_database_only() {
235 assert_eq!(
236 describe_url("postgres://u:p@db.internal:5432/shop?sslmode=require"),
237 "db.internal:5432/shop"
238 );
239 assert_eq!(describe_url("postgres://localhost/shop"), "localhost/shop");
240 assert_eq!(describe_url("not a database url"), "not a database url");
241 }
242
243 async fn test_database() -> Option<PgPool> {
246 let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
247 Some(
248 create_postgres_pool(&format!("{server}/postgres"))
249 .await
250 .unwrap(),
251 )
252 }
253
254 #[tokio::test]
255 #[ignore = "needs Postgres: make test/db"]
256 async fn postgres_unique_violation_maps_to_duplicate() {
257 let Some(pool) = test_database().await else {
258 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
259 return;
260 };
261 let mut conn = pool.acquire().await.unwrap();
262 sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
264 .execute(&mut *conn)
265 .await
266 .unwrap();
267 sqlx::query("INSERT INTO skus VALUES ('A-1')")
268 .execute(&mut *conn)
269 .await
270 .unwrap();
271
272 let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
273 .execute(&mut *conn)
274 .await
275 .unwrap_err();
276
277 assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
278 }
279
280 #[tokio::test]
281 #[ignore = "needs Postgres: make test/db"]
282 async fn postgres_ping_reports_ready() {
283 let Some(pool) = test_database().await else {
284 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
285 return;
286 };
287
288 assert!(ping(&pool).await);
289 }
290}