diff --git a/crates/api/src/admin_authorities.rs b/crates/api/src/admin_authorities.rs index 58fc79d..22e1e45 100644 --- a/crates/api/src/admin_authorities.rs +++ b/crates/api/src/admin_authorities.rs @@ -7,7 +7,7 @@ use axum::{ http::StatusCode, routing::get, }; -use domain::{AuthorityKind, LocalizedLabel, NewAuthority}; +use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -91,7 +91,7 @@ pub(crate) async fn list_authorities( ) )] pub(crate) async fn create_authority( - _auth: Authorized, + auth: Authorized, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { @@ -117,9 +117,10 @@ pub(crate) async fn create_authority( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let id = db::authority::create_authority(&mut tx, &new) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let id = + db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; tx.commit() .await diff --git a/crates/api/src/admin_vocab.rs b/crates/api/src/admin_vocab.rs index 18a2e71..0b714e4 100644 --- a/crates/api/src/admin_vocab.rs +++ b/crates/api/src/admin_vocab.rs @@ -7,7 +7,7 @@ use axum::{ http::StatusCode, routing::get, }; -use domain::{LocalizedLabel, NewTerm, VocabularyId}; +use domain::{AuditActor, LocalizedLabel, NewTerm, VocabularyId}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -85,11 +85,23 @@ pub(crate) async fn list_vocabularies( ) )] pub(crate) async fn create_vocabulary( - _auth: Authorized, + auth: Authorized, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { - let vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key) + let mut tx = state + .db + .pool() + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let vocab = + db::vocab::create_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &req.key) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -156,7 +168,7 @@ pub(crate) async fn list_terms( ) )] pub(crate) async fn add_term( - _auth: Authorized, + auth: Authorized, State(state): State, Path(id): Path, Json(req): Json, @@ -185,15 +197,17 @@ pub(crate) async fn add_term( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|err| { - // A well-formed id for a missing vocabulary hits the FK constraint (23503). - if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") { - StatusCode::NOT_FOUND - } else { - tracing::error!(?err, "adding term"); - StatusCode::INTERNAL_SERVER_ERROR - } - })?; + let term_id = db::vocab::add_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new) + .await + .map_err(|err| { + // A well-formed id for a missing vocabulary hits the FK constraint (23503). + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") { + StatusCode::NOT_FOUND + } else { + tracing::error!(?err, "adding term"); + StatusCode::INTERNAL_SERVER_ERROR + } + })?; tx.commit() .await diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index d9e4594..413f098 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -1,8 +1,8 @@ use api::{AppState, build_app, migrate_sessions}; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use db::users; -use domain::{AuditActor, Email, NewUser, Role}; +use db::{audit, users}; +use domain::{AuditAction, AuditActor, Email, NewUser, Role}; use http_body_util::BodyExt; use sqlx::PgPool; use tower::ServiceExt; @@ -290,3 +290,44 @@ async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +#[sqlx::test(migrations = "../db/migrations")] +async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let app = build_app(state(pool.clone())); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/vocabularies") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key":"audit-test"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let body: serde_json::Value = + serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vocab_id: uuid::Uuid = body["id"].as_str().unwrap().parse().unwrap(); + + let history = audit::history_for(&pool, "vocabulary", vocab_id) + .await + .unwrap(); + + assert_eq!(history.len(), 1); + assert_eq!(history[0].action, AuditAction::Created); + assert!( + matches!(history[0].actor, AuditActor::User(_)), + "expected actor to be a user" + ); +} diff --git a/crates/db/src/authority.rs b/crates/db/src/authority.rs index 311288f..dc2468f 100644 --- a/crates/db/src/authority.rs +++ b/crates/db/src/authority.rs @@ -1,16 +1,25 @@ //! Authority records (person / organisation / place). -use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority}; +use domain::{ + AuditAction, AuditActor, Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, + NewAuditEvent, NewAuthority, +}; use sqlx::Row; +use crate::audit; + +const AUTHORITY_ENTITY_TYPE: &str = "authority"; + /// Labels aggregated per row as JSON, to read an authority and its labels in one query. const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \ ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)"; -/// Insert an authority and its labels. Multiple statements — pass a transaction -/// connection (`&mut *tx`) for atomicity. +/// Insert an authority and its labels, then record a `created` audit entry. Multiple +/// statements — pass a transaction connection (`&mut *tx`) so everything commits +/// atomically. pub async fn create_authority( conn: &mut sqlx::PgConnection, + actor: AuditActor, new: &NewAuthority, ) -> Result { let id = AuthorityId::new(); @@ -31,6 +40,18 @@ pub async fn create_authority( .await?; } + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Created, + entity_type: AUTHORITY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + Ok(id) } diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 17267b8..0f1c2fa 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -17,10 +17,11 @@ pub struct Db { } impl Db { - /// Connect to the database at `database_url`, opening a connection pool. - pub async fn connect(database_url: &str) -> Result { + /// Connect to the database at `database_url`, opening a connection pool with at most + /// `max_connections` connections. + pub async fn connect(database_url: &str, max_connections: u32) -> Result { let pool = PgPoolOptions::new() - .max_connections(5) + .max_connections(max_connections) .connect(database_url) .await?; diff --git a/crates/db/src/seed.rs b/crates/db/src/seed.rs index 9c5febf..100e4a6 100644 --- a/crates/db/src/seed.rs +++ b/crates/db/src/seed.rs @@ -5,7 +5,9 @@ //! populated by the organization or a later import. The inventory-minimum fields //! (object number, name, location, …) live in the typed object core, not here. -use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId}; +use domain::{ + AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId, +}; use crate::{fields, vocab}; @@ -119,7 +121,11 @@ async fn ensure_vocabulary( if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? { Ok(existing.id) } else { - Ok(vocab::create_vocabulary(&mut *conn, key).await?.id) + Ok( + vocab::create_vocabulary(&mut *conn, AuditActor::System, key) + .await? + .id, + ) } } diff --git a/crates/db/src/vocab.rs b/crates/db/src/vocab.rs index 6806296..9bc558a 100644 --- a/crates/db/src/vocab.rs +++ b/crates/db/src/vocab.rs @@ -1,25 +1,47 @@ //! Controlled vocabularies and terms. -use domain::{LocalizedLabel, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId}; +use domain::{ + AuditAction, AuditActor, LocalizedLabel, NewAuditEvent, NewTerm, Term, TermId, TermRef, + Vocabulary, VocabularyId, +}; use sqlx::Row; +use crate::audit; + +const VOCABULARY_ENTITY_TYPE: &str = "vocabulary"; +const TERM_ENTITY_TYPE: &str = "term"; + /// Labels aggregated per row as JSON, to read a term and its labels in one query. const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \ ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)"; -/// Create a vocabulary with the given key. -pub async fn create_vocabulary<'e, E>(executor: E, key: &str) -> Result -where - E: sqlx::PgExecutor<'e>, -{ +/// Create a vocabulary with the given key and record a `created` audit entry, both on +/// `conn` (pass a transaction connection `&mut *tx` so they commit atomically). +pub async fn create_vocabulary( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + key: &str, +) -> Result { let id = VocabularyId::new(); sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)") .bind(id.to_uuid()) .bind(key) - .execute(executor) + .execute(&mut *conn) .await?; + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Created, + entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + Ok(Vocabulary { id, key: key.to_owned(), @@ -54,9 +76,14 @@ where row.map(map_vocabulary).transpose() } -/// Insert a term and its labels. Multiple statements — pass a transaction -/// connection (`&mut *tx`) so the term and its labels commit atomically. -pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result { +/// Insert a term and its labels, then record a `created` audit entry. Multiple +/// statements — pass a transaction connection (`&mut *tx`) so everything commits +/// atomically. +pub async fn add_term( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + new: &NewTerm, +) -> Result { let id = TermId::new(); sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)") @@ -75,6 +102,18 @@ pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result NewAuthority { @@ -24,9 +24,13 @@ async fn authority_round_trips_with_labels(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); - let id = authority::create_authority(&mut tx, &new_person("Carl Larsson", "Carl Larsson")) - .await - .unwrap(); + let id = authority::create_authority( + &mut tx, + AuditActor::System, + &new_person("Carl Larsson", "Carl Larsson"), + ) + .await + .unwrap(); tx.commit().await.unwrap(); let got = authority::authority_by_id(db.pool(), id) @@ -47,11 +51,12 @@ async fn list_by_kind_filters(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); - authority::create_authority(&mut tx, &new_person("A", "A")) + authority::create_authority(&mut tx, AuditActor::System, &new_person("A", "A")) .await .unwrap(); authority::create_authority( &mut tx, + AuditActor::System, &NewAuthority { kind: AuthorityKind::Place, external_uri: None, @@ -83,7 +88,7 @@ async fn resolve_authority_returns_kind(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); - let id = authority::create_authority(&mut tx, &new_person("X", "X")) + let id = authority::create_authority(&mut tx, AuditActor::System, &new_person("X", "X")) .await .unwrap(); tx.commit().await.unwrap(); @@ -108,6 +113,7 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let id = authority::create_authority( &mut tx, + AuditActor::System, &NewAuthority { kind: AuthorityKind::Organisation, external_uri: None, diff --git a/crates/db/tests/fields.rs b/crates/db/tests/fields.rs index 0597979..faf8ecf 100644 --- a/crates/db/tests/fields.rs +++ b/crates/db/tests/fields.rs @@ -1,5 +1,5 @@ use db::{Db, fields, vocab}; -use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition}; +use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition}; use sqlx::PgPool; fn labels() -> Vec { @@ -52,9 +52,11 @@ async fn text_field_round_trips(pool: PgPool) { #[sqlx::test] async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) { let db = Db::from_pool(pool); - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); fields::create_field_definition( diff --git a/crates/db/tests/object_fields.rs b/crates/db/tests/object_fields.rs index 8d8a24f..1092fd2 100644 --- a/crates/db/tests/object_fields.rs +++ b/crates/db/tests/object_fields.rs @@ -95,9 +95,12 @@ async fn sets_scalar_fields_and_audits(pool: PgPool) { async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) { let db = Db::from_pool(pool); let id = setup_object(&db).await; - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); + define( &db, "material", @@ -110,6 +113,7 @@ async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let wood = vocab::add_term( &mut tx, + AuditActor::System, &domain::NewTerm { vocabulary_id: material.id, external_uri: None, @@ -180,6 +184,7 @@ async fn authority_field_enforces_kind(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let person = db::authority::create_authority( &mut tx, + AuditActor::System, &domain::NewAuthority { kind: domain::AuthorityKind::Person, external_uri: None, @@ -190,6 +195,7 @@ async fn authority_field_enforces_kind(pool: PgPool) { .unwrap(); let place = db::authority::create_authority( &mut tx, + AuditActor::System, &domain::NewAuthority { kind: domain::AuthorityKind::Place, external_uri: None, @@ -219,12 +225,14 @@ async fn authority_field_enforces_kind(pool: PgPool) { async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) { let db = Db::from_pool(pool); let id = setup_object(&db).await; - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let technique = vocab::create_vocabulary(db.pool(), "technique") + let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique") .await .unwrap(); + tx.commit().await.unwrap(); define( &db, "material", @@ -238,6 +246,7 @@ async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) { let mut tx = db.pool().begin().await.unwrap(); let other = vocab::add_term( &mut tx, + AuditActor::System, &domain::NewTerm { vocabulary_id: technique.id, external_uri: None, diff --git a/crates/db/tests/vocab.rs b/crates/db/tests/vocab.rs index dfaed97..d3edabb 100644 --- a/crates/db/tests/vocab.rs +++ b/crates/db/tests/vocab.rs @@ -1,13 +1,15 @@ use db::{Db, vocab}; -use domain::{LocalizedLabel, NewTerm}; +use domain::{AuditActor, LocalizedLabel, NewTerm}; use sqlx::PgPool; #[sqlx::test] async fn vocabulary_create_and_lookup(pool: PgPool) { let db = Db::from_pool(pool); - let v = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let found = vocab::vocabulary_by_key(db.pool(), "material") .await @@ -27,13 +29,16 @@ async fn vocabulary_create_and_lookup(pool: PgPool) { #[sqlx::test] async fn term_with_multilingual_labels_round_trips(pool: PgPool) { let db = Db::from_pool(pool); - let v = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); let term_id = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: v.id, external_uri: Some("http://vocab.getty.edu/aat/300011914".into()), @@ -76,13 +81,16 @@ async fn term_with_multilingual_labels_round_trips(pool: PgPool) { #[sqlx::test] async fn term_with_no_labels_round_trips_empty(pool: PgPool) { let db = Db::from_pool(pool); - let v = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); let term_id = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: v.id, external_uri: None, @@ -103,10 +111,14 @@ async fn term_with_no_labels_round_trips_empty(pool: PgPool) { #[sqlx::test] async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) { let db = Db::from_pool(pool); - vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let err = vocab::create_vocabulary(db.pool(), "material") + tx.commit().await.unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + let err = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap_err(); assert!( @@ -118,16 +130,19 @@ async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) { #[sqlx::test] async fn resolve_term_checks_vocabulary_membership(pool: PgPool) { let db = Db::from_pool(pool); - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let technique = vocab::create_vocabulary(db.pool(), "technique") + let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique") .await .unwrap(); + tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); let term_id = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: material.id, external_uri: None, diff --git a/crates/search/tests/reindex.rs b/crates/search/tests/reindex.rs index 6f2be2f..8b0d493 100644 --- a/crates/search/tests/reindex.rs +++ b/crates/search/tests/reindex.rs @@ -23,14 +23,15 @@ async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) { let db = Db::from_pool(pool); // a material vocabulary with a "wood" term - let material = vocab::create_vocabulary(db.pool(), "material") + let mut tx = db.pool().begin().await.unwrap(); + + let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") .await .unwrap(); - let mut tx = db.pool().begin().await.unwrap(); - let wood = vocab::add_term( &mut tx, + AuditActor::System, &NewTerm { vocabulary_id: material.id, external_uri: None, diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 0182938..08e809e 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -42,4 +42,12 @@ pub struct Config { /// Meilisearch index name for catalogue objects. #[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")] pub meili_index: String, + + /// Maximum size of the PostgreSQL connection pool. + #[arg( + long = "db-max-connections", + env = "DB_MAX_CONNECTIONS", + default_value_t = 5 + )] + pub db_max_connections: u32, } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index a355cd6..53951b2 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -15,7 +15,7 @@ use tokio::net::TcpListener; /// Connect dependencies from `config` and serve until shutdown. pub async fn run(config: Config) -> anyhow::Result<()> { - let db = Db::connect(&config.database_url) + let db = Db::connect(&config.database_url, config.db_max_connections) .await .context("connecting to the database")?; @@ -64,6 +64,34 @@ pub async fn run(config: Config) -> anyhow::Result<()> { serve(listener, state).await } +/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can +/// drain in-flight requests before exiting. +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("install Ctrl-C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + tracing::info!("shutdown signal received; draining"); +} + /// Serve the API on an already-bound listener (used by `run` and tests). pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> { let app = build_app(state); @@ -72,6 +100,7 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> let app = app.merge(web_assets::routes()); axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) .await .context("running the HTTP server")?; @@ -107,7 +136,8 @@ pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow: auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))? }; - let db = Db::connect(database_url) + // CLI one-shot: a tiny pool is plenty. + let db = Db::connect(database_url, 2) .await .context("connecting to the database")?; diff --git a/crates/server/tests/serve.rs b/crates/server/tests/serve.rs index b3dd99d..edf402a 100644 --- a/crates/server/tests/serve.rs +++ b/crates/server/tests/serve.rs @@ -9,7 +9,7 @@ use tokio::net::TcpListener; async fn serves_health_live_over_tcp() { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test"); - let db = Db::connect(&database_url) + let db = Db::connect(&database_url, 2) .await .expect("connect to database"); let state = AppState {