diff options
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/bin/server.rs | 26 | ||||
| -rw-r--r-- | makima/src/db/mod.rs | 15 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 101 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 128 | ||||
| -rw-r--r-- | makima/src/lib.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/files.rs | 230 | ||||
| -rw-r--r-- | makima/src/server/handlers/listen.rs | 82 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 9 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 22 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 14 |
11 files changed, 617 insertions, 12 deletions
diff --git a/makima/src/bin/server.rs b/makima/src/bin/server.rs index 3ea3a67..bbc56fd 100644 --- a/makima/src/bin/server.rs +++ b/makima/src/bin/server.rs @@ -1,6 +1,6 @@ //! Makima Audio API Server binary. //! -//! This server provides WebSocket-based speech-to-text streaming. +//! This server provides WebSocket-based speech-to-text streaming with optional persistence. use std::sync::Arc; @@ -43,13 +43,29 @@ async fn main() -> anyhow::Result<()> { ); // Load ML models - let state = Arc::new( - AppState::new(¶keet_dir, ¶keet_eou_dir, &sortformer_path) - .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?, - ); + let mut app_state = AppState::new(¶keet_dir, ¶keet_eou_dir, &sortformer_path) + .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?; tracing::info!("Models loaded successfully"); + // Initialize database (optional - server works without it) + if let Ok(database_url) = std::env::var("POSTGRES_CONNECTION_URI") { + tracing::info!("Connecting to database..."); + match makima::db::create_pool(&database_url).await { + Ok(pool) => { + tracing::info!("Database connected successfully"); + app_state = app_state.with_db_pool(pool); + } + Err(e) => { + tracing::warn!("Failed to connect to database: {}. Running without persistence.", e); + } + } + } else { + tracing::info!("POSTGRES_CONNECTION_URI not set. Running without persistence."); + } + + let state = Arc::new(app_state); + // Run the server let addr = format!("0.0.0.0:{}", port); run_server(state, &addr).await diff --git a/makima/src/db/mod.rs b/makima/src/db/mod.rs new file mode 100644 index 0000000..dbfeeab --- /dev/null +++ b/makima/src/db/mod.rs @@ -0,0 +1,15 @@ +//! Database module for PostgreSQL connectivity and models. + +pub mod models; +pub mod repository; + +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; + +/// Create a database connection pool. +pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> { + PgPoolOptions::new() + .max_connections(5) + .connect(database_url) + .await +} diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs new file mode 100644 index 0000000..45b0e53 --- /dev/null +++ b/makima/src/db/models.rs @@ -0,0 +1,101 @@ +//! Database models for the files table. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; +use uuid::Uuid; + +/// TranscriptEntry stored in JSONB - matches frontend TranscriptEntry +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptEntry { + pub id: String, + pub speaker: String, + pub start: f32, + pub end: f32, + pub text: String, + pub is_final: bool, +} + +/// File record from the database. +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct File { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option<String>, + #[sqlx(json)] + pub transcript: Vec<TranscriptEntry>, + pub location: Option<String>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request payload for creating a new file. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateFileRequest { + /// Name of the file (auto-generated if not provided) + pub name: Option<String>, + /// Optional description + pub description: Option<String>, + /// Transcript entries + pub transcript: Vec<TranscriptEntry>, + /// Storage location (e.g., s3://bucket/path) - not used yet + pub location: Option<String>, +} + +/// Request payload for updating an existing file. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateFileRequest { + /// New name (optional) + pub name: Option<String>, + /// New description (optional) + pub description: Option<String>, + /// New transcript (optional) + pub transcript: Option<Vec<TranscriptEntry>>, +} + +/// Response for file list endpoint. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileListResponse { + pub files: Vec<FileSummary>, + pub total: i64, +} + +/// Summary of a file for list views (excludes full transcript). +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileSummary { + pub id: Uuid, + pub name: String, + pub description: Option<String>, + pub transcript_count: usize, + /// Duration derived from last transcript end time + pub duration: Option<f32>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +impl From<File> for FileSummary { + fn from(file: File) -> Self { + let duration = file + .transcript + .iter() + .map(|t| t.end) + .fold(0.0_f32, f32::max); + Self { + id: file.id, + name: file.name, + description: file.description, + transcript_count: file.transcript.len(), + duration: if duration > 0.0 { Some(duration) } else { None }, + created_at: file.created_at, + updated_at: file.updated_at, + } + } +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs new file mode 100644 index 0000000..90cb1b9 --- /dev/null +++ b/makima/src/db/repository.rs @@ -0,0 +1,128 @@ +//! Repository pattern for file database operations. + +use chrono::Utc; +use sqlx::PgPool; +use uuid::Uuid; + +use super::models::{CreateFileRequest, File, UpdateFileRequest}; + +/// Default owner ID for anonymous users. +pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002); + +/// Generate a default name based on current timestamp. +fn generate_default_name() -> String { + let now = Utc::now(); + now.format("Recording - %b %d %Y %H:%M:%S").to_string() +} + +/// Create a new file record. +pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, sqlx::Error> { + let name = req.name.unwrap_or_else(generate_default_name); + let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default(); + + sqlx::query_as::<_, File>( + r#" + INSERT INTO files (owner_id, name, description, transcript, location) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at + "#, + ) + .bind(ANONYMOUS_OWNER_ID) + .bind(&name) + .bind(&req.description) + .bind(&transcript_json) + .bind(&req.location) + .fetch_one(pool) + .await +} + +/// Get a file by ID. +pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> { + sqlx::query_as::<_, File>( + r#" + SELECT id, owner_id, name, description, transcript, location, created_at, updated_at + FROM files + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(ANONYMOUS_OWNER_ID) + .fetch_optional(pool) + .await +} + +/// List all files for the owner, ordered by created_at DESC. +pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> { + sqlx::query_as::<_, File>( + r#" + SELECT id, owner_id, name, description, transcript, location, created_at, updated_at + FROM files + WHERE owner_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(ANONYMOUS_OWNER_ID) + .fetch_all(pool) + .await +} + +/// Update a file by ID. +pub async fn update_file( + pool: &PgPool, + id: Uuid, + req: UpdateFileRequest, +) -> Result<Option<File>, sqlx::Error> { + // Get the existing file first + let existing = get_file(pool, id).await?; + let Some(existing) = existing else { + return Ok(None); + }; + + // Apply updates + let name = req.name.unwrap_or(existing.name); + let description = req.description.or(existing.description); + let transcript = req.transcript.unwrap_or(existing.transcript); + let transcript_json = serde_json::to_value(&transcript).unwrap_or_default(); + + sqlx::query_as::<_, File>( + r#" + UPDATE files + SET name = $3, description = $4, transcript = $5 + WHERE id = $1 AND owner_id = $2 + RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at + "#, + ) + .bind(id) + .bind(ANONYMOUS_OWNER_ID) + .bind(&name) + .bind(&description) + .bind(&transcript_json) + .fetch_optional(pool) + .await +} + +/// Delete a file by ID. +pub async fn delete_file(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM files + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(ANONYMOUS_OWNER_ID) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Count total files for owner. +pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> { + let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM files WHERE owner_id = $1") + .bind(ANONYMOUS_OWNER_ID) + .fetch_one(pool) + .await?; + + Ok(result.0) +} diff --git a/makima/src/lib.rs b/makima/src/lib.rs index 1e95d95..35d376c 100644 --- a/makima/src/lib.rs +++ b/makima/src/lib.rs @@ -1,4 +1,5 @@ pub mod audio; +pub mod db; pub mod listen; pub mod server; pub mod tts; diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs new file mode 100644 index 0000000..746d66b --- /dev/null +++ b/makima/src/server/handlers/files.rs @@ -0,0 +1,230 @@ +//! HTTP handlers for file CRUD operations. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{CreateFileRequest, FileListResponse, FileSummary, UpdateFileRequest}; +use crate::db::repository; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// List all files for the current owner. +#[utoipa::path( + get, + path = "/api/v1/files", + responses( + (status = 200, description = "List of files", body = FileListResponse), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Files" +)] +pub async fn list_files(State(state): State<SharedState>) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_files(pool).await { + Ok(files) => { + let summaries: Vec<FileSummary> = files.into_iter().map(FileSummary::from).collect(); + let total = summaries.len() as i64; + Json(FileListResponse { + files: summaries, + total, + }) + .into_response() + } + Err(e) => { + tracing::error!("Failed to list files: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a single file by ID. +#[utoipa::path( + get, + path = "/api/v1/files/{id}", + params( + ("id" = Uuid, Path, description = "File ID") + ), + responses( + (status = 200, description = "File details", body = crate::db::models::File), + (status = 404, description = "File not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Files" +)] +pub async fn get_file( + State(state): State<SharedState>, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_file(pool, id).await { + Ok(Some(file)) => Json(file).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get file {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new file. +#[utoipa::path( + post, + path = "/api/v1/files", + request_body = CreateFileRequest, + responses( + (status = 201, description = "File created", body = crate::db::models::File), + (status = 400, description = "Invalid request", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Files" +)] +pub async fn create_file( + State(state): State<SharedState>, + Json(req): Json<CreateFileRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::create_file(pool, req).await { + Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), + Err(e) => { + tracing::error!("Failed to create file: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update an existing file. +#[utoipa::path( + put, + path = "/api/v1/files/{id}", + params( + ("id" = Uuid, Path, description = "File ID") + ), + request_body = UpdateFileRequest, + responses( + (status = 200, description = "File updated", body = crate::db::models::File), + (status = 404, description = "File not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Files" +)] +pub async fn update_file( + State(state): State<SharedState>, + Path(id): Path<Uuid>, + Json(req): Json<UpdateFileRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::update_file(pool, id, req).await { + Ok(Some(file)) => Json(file).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update file {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a file. +#[utoipa::path( + delete, + path = "/api/v1/files/{id}", + params( + ("id" = Uuid, Path, description = "File ID") + ), + responses( + (status = 204, description = "File deleted"), + (status = 404, description = "File not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Files" +)] +pub async fn delete_file( + State(state): State<SharedState>, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_file(pool, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete file {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs index bf6746c..93062f3 100644 --- a/makima/src/server/handlers/listen.rs +++ b/makima/src/server/handlers/listen.rs @@ -9,6 +9,8 @@ use tokio::sync::mpsc; use uuid::Uuid; use crate::audio::{resample_and_mixdown, TARGET_CHANNELS, TARGET_SAMPLE_RATE}; +use crate::db::models::{CreateFileRequest, TranscriptEntry, UpdateFileRequest}; +use crate::db::repository; use crate::listen::{align_speakers, samples_per_chunk, DialogueSegment, TimestampMode}; use crate::server::messages::{ AudioEncoding, ClientMessage, ServerMessage, StartMessage, TranscriptMessage, @@ -99,6 +101,11 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { let mut audio_offset: f32 = 0.0; // Time offset from trimmed audio let mut finalized_segments: Vec<DialogueSegment> = Vec::new(); + // File persistence state + let mut file_id: Option<Uuid> = None; + let mut transcript_entries: Vec<TranscriptEntry> = Vec::new(); + let mut transcript_counter: u32 = 0; + // Reset Sortformer state for new session { let mut sortformer = state.sortformer.lock().await; @@ -329,12 +336,52 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { // Send segments with adjusted timestamps for seg in &segments { + let adjusted_start = seg.start + audio_offset; let adjusted_end = seg.end + audio_offset; if adjusted_end > last_sent_end_time { + // Create file on first transcript if database is available + if file_id.is_none() { + if let Some(ref pool) = state.db_pool { + match repository::create_file(pool, CreateFileRequest { + name: None, // Auto-generated + description: None, + transcript: vec![], + location: None, + }).await { + Ok(file) => { + file_id = Some(file.id); + tracing::info!( + session_id = %session_id, + file_id = %file.id, + "Created file for session" + ); + } + Err(e) => { + tracing::warn!( + session_id = %session_id, + error = %e, + "Failed to create file for session" + ); + } + } + } + } + + // Track transcript entry + transcript_counter += 1; + transcript_entries.push(TranscriptEntry { + id: format!("{}-{}", session_id, transcript_counter), + speaker: seg.speaker.clone(), + start: adjusted_start, + end: adjusted_end, + text: seg.text.clone(), + is_final: false, + }); + let _ = response_tx .send(ServerMessage::Transcript(TranscriptMessage { speaker: seg.speaker.clone(), - start: seg.start + audio_offset, + start: adjusted_start, end: adjusted_end, text: seg.text.clone(), is_final: false, @@ -399,6 +446,39 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { } } + // Save final transcript to file if we have one + if let Some(fid) = file_id { + if let Some(ref pool) = state.db_pool { + // Mark all entries as final + for entry in &mut transcript_entries { + entry.is_final = true; + } + + match repository::update_file(pool, fid, UpdateFileRequest { + name: None, + description: None, + transcript: Some(transcript_entries.clone()), + }).await { + Ok(_) => { + tracing::info!( + session_id = %session_id, + file_id = %fid, + transcript_count = transcript_entries.len(), + "Saved final transcript to file" + ); + } + Err(e) => { + tracing::error!( + session_id = %session_id, + file_id = %fid, + error = %e, + "Failed to save final transcript to file" + ); + } + } + } + } + // Cleanup drop(response_tx); let _ = sender_task.await; diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 94b0384..f249234 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,3 +1,4 @@ //! HTTP and WebSocket request handlers. +pub mod files; pub mod listen; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index c509afa..bc3e679 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -17,7 +17,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::listen; +use crate::server::handlers::{files, listen}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -43,6 +43,13 @@ pub fn make_router(state: SharedState) -> Router { // API v1 routes let api_v1 = Router::new() .route("/listen", get(listen::websocket_handler)) + .route("/files", get(files::list_files).post(files::create_file)) + .route( + "/files/{id}", + get(files::get_file) + .put(files::update_file) + .delete(files::delete_file), + ) .with_state(state); let swagger = SwaggerUi::new("/swagger-ui") diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 3e8c06c..b946ff3 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -2,19 +2,27 @@ use utoipa::OpenApi; -use crate::server::handlers::listen; +use crate::db::models::{ + CreateFileRequest, File, FileListResponse, FileSummary, TranscriptEntry, UpdateFileRequest, +}; +use crate::server::handlers::{files, listen}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] #[openapi( info( - title = "Makima Listen API", + title = "Makima API", version = "1.0.0", - description = "Streaming audio APIs for speech-to-text.", + description = "Streaming audio APIs for speech-to-text with persistence.", license(name = "MIT"), ), paths( listen::websocket_handler, + files::list_files, + files::get_file, + files::create_file, + files::update_file, + files::delete_file, ), components( schemas( @@ -23,10 +31,18 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage StartMessage, StopMessage, TranscriptMessage, + // File schemas + File, + FileSummary, + FileListResponse, + CreateFileRequest, + UpdateFileRequest, + TranscriptEntry, ) ), tags( (name = "Listen", description = "Speech-to-text streaming endpoints"), + (name = "Files", description = "Transcript file management"), ) )] pub struct ApiDoc; diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 31e1518..8cdc26c 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -1,11 +1,12 @@ -//! Application state holding shared ML models. +//! Application state holding shared ML models and database pool. use std::sync::Arc; +use sqlx::PgPool; use tokio::sync::Mutex; use crate::listen::{DiarizationConfig, ParakeetEOU, ParakeetTDT, Sortformer}; -/// Shared application state containing ML models. +/// Shared application state containing ML models and database pool. /// /// Models are wrapped in `Mutex` for thread-safe mutable access during inference. pub struct AppState { @@ -15,6 +16,8 @@ pub struct AppState { pub parakeet_eou: Mutex<ParakeetEOU>, /// Speaker diarization model (Sortformer) pub sortformer: Mutex<Sortformer>, + /// Optional database connection pool + pub db_pool: Option<PgPool>, } impl AppState { @@ -41,8 +44,15 @@ impl AppState { parakeet: Mutex::new(parakeet), parakeet_eou: Mutex::new(parakeet_eou), sortformer: Mutex::new(sortformer), + db_pool: None, }) } + + /// Set the database pool. + pub fn with_db_pool(mut self, pool: PgPool) -> Self { + self.db_pool = Some(pool); + self + } } /// Type alias for the shared application state. |
