feat(db): add SQLx migrations and AppState with connection pool

- Add sqlx 0.8 (AnyPool, runtime-tokio, sqlite, postgres, migrate)
- Create 6 migration files for both SQLite and PostgreSQL backends
- Add server/db.rs: create_pool and run_migrations helpers
- Add server/state.rs: AppState with LeptosOptions + AnyPool
- Run migrations at server startup before accepting requests
- Fix Port model: remove host_id (ports are now a global catalog)
- Add HostPort join struct for the host_ports many-to-many table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 21:46:16 +02:00
parent 18804e740c
commit f13097591c
19 changed files with 1192 additions and 23 deletions

View File

@@ -21,7 +21,12 @@ async fn main() {
use leptos_axum::{generate_route_list, LeptosRoutes};
use rust_ipam::{
app::{App, Shell},
server::{config::AppConfig, routes::not_found_handler},
server::{
config::AppConfig,
db::{create_pool, run_migrations},
routes::not_found_handler,
state::AppState,
},
};
use tower_http::services::ServeDir;
@@ -44,6 +49,18 @@ async fn main() {
tracing::info!("Database: {} ({})", app_config.backend, app_config.database_url);
// Connect to the database and apply any pending migrations.
// The server cannot serve data without a working database, so we abort on error.
let pool = create_pool(&app_config)
.await
.expect("Failed to connect to database");
run_migrations(&pool, &app_config.backend)
.await
.expect("Database migration failed");
tracing::info!("Database ready.");
// `Some("Cargo.toml")` tells Leptos to read the [package.metadata.leptos]
// section from Cargo.toml (file paths, output names, server address...).
let conf = get_configuration(Some("Cargo.toml"))
@@ -51,27 +68,37 @@ async fn main() {
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
// Combine Leptos options and the database pool into a single shared state.
// `AppState` implements `FromRef<AppState> for LeptosOptions` so Leptos
// can still extract just what it needs from the full state.
let state = AppState { leptos_options: leptos_options.clone(), db: pool };
// Walk all `<Route>` components inside `App` to build the list of URLs
// that Leptos SSR must handle.
let routes = generate_route_list(App);
// Build the Axum router using the builder pattern (method chaining).
let app = Router::new()
//
// `Router::<AppState>::new()` explicitly tells Rust the state type is `AppState`.
// Without this annotation, type inference would default to `LeptosOptions`
// (inferred from `leptos_routes`) and then reject `.with_state(state: AppState)`.
let app = Router::<AppState>::new()
// Serve static files compiled by trunk (WASM, JS...).
// Trunk places them in target/site/pkg/ as configured in [package.metadata.leptos].
.nest_service("/pkg", ServeDir::new("target/site/pkg"))
// Mount all Leptos routes into Axum.
// For each URL, Axum renders Shell() to HTML and sends it to the browser.
// Shell() contains App(), which provides the page content.
.leptos_routes(&leptos_options, routes, {
// Clone options so the closure can capture them.
// `leptos_routes` receives `&state` (the full AppState).
// It extracts `LeptosOptions` via `FromRef<AppState>` implemented in state.rs.
.leptos_routes(&state, routes, {
// Clone options before moving into the closure.
// `move` transfers ownership of `leptos_options` into the closure.
let leptos_options = leptos_options.clone();
let leptos_options = state.leptos_options.clone();
move || view! { <Shell options=leptos_options.clone()/> }
})
.fallback(not_found_handler)
// Share Leptos options with all handlers via Axum's state system.
.with_state(leptos_options);
// Share AppState (Leptos options + DB pool) with all handlers.
.with_state(state);
let listener = tokio::net::TcpListener::bind(&addr)
.await

View File

@@ -57,24 +57,32 @@ pub struct Host {
// ─── Port ─────────────────────────────────────────────────────────────────────
/// A network port open on a host, with its likely protocol description.
/// A network port entry in the global port catalog.
///
/// Ports are defined once here; host_ports and application_ports link them
/// to hosts and applications through separate join tables.
/// Well-known ports (01023) have standardized protocol assignments.
/// A port can be associated with multiple applications (non-strict relation).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Port {
/// TCP/UDP port number.
/// `u16` is an unsigned 16-bit integer → range 0 to 65535,
/// which exactly matches the valid range for network ports.
/// TCP/UDP port number (065535).
/// `u16` is an unsigned 16-bit integer — the exact range for port numbers.
pub number: u16,
/// Description of the likely protocol on this port.
/// `Option<String>`: may be absent (None) when the protocol is unknown.
/// Description of the protocol typically running on this port.
/// `Option<String>`: absent (None) when the protocol is unknown.
/// Examples: Some("SSH"), Some("HTTPS"), None
pub description: Option<String>,
}
/// The host on which this port is open.
// ─── HostPort ─────────────────────────────────────────────────────────────────
/// Join record representing a port open on a specific host.
///
/// Maps to the `host_ports` table (many-to-many between hosts and ports).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostPort {
pub host_id: i64,
pub port_number: u16,
}
impl Port {

68
src/server/db.rs Normal file
View File

@@ -0,0 +1,68 @@
// server/db.rs — Database connection pool and migrations
//
// This module provides two functions called once at server startup:
// - `create_pool` : opens a connection pool to the database
// - `run_migrations` : applies all pending SQL migrations
//
// `AnyPool` lets the same Rust code target both SQLite (dev) and
// PostgreSQL (production) — only DATABASE_URL changes.
use sqlx::AnyPool;
use thiserror::Error;
use crate::server::config::{AppConfig, DatabaseBackend};
// ─── Errors ───────────────────────────────────────────────────────────────────
#[derive(Debug, Error)]
pub enum DbError {
#[error("Database connection failed: {0}")]
Connection(#[from] sqlx::Error),
#[error("Migration failed: {0}")]
Migration(#[from] sqlx::migrate::MigrateError),
}
// ─── Pool creation ────────────────────────────────────────────────────────────
/// Opens a connection pool to the database specified in `config.database_url`.
///
/// A pool maintains multiple open connections so concurrent requests
/// do not block each other waiting for a single connection.
///
/// `install_default_drivers()` must be called before `AnyPool::connect`
/// to register both the SQLite and PostgreSQL drivers in the `Any` registry.
pub async fn create_pool(config: &AppConfig) -> Result<AnyPool, DbError> {
// Register SQLite and PostgreSQL drivers so `AnyPool` can dispatch
// to the correct one based on the URL scheme (sqlite:// vs postgres://).
sqlx::any::install_default_drivers();
let pool = AnyPool::connect(&config.database_url).await?;
Ok(pool)
}
// ─── Migrations ───────────────────────────────────────────────────────────────
/// Applies all pending migrations from the directory matching the active backend.
///
/// Two separate directories handle SQL syntax differences:
/// - `migrations/sqlite/` : uses `INTEGER PRIMARY KEY AUTOINCREMENT`
/// - `migrations/postgres/` : uses `BIGSERIAL PRIMARY KEY`
///
/// SQLx tracks applied migrations in a `_sqlx_migrations` table, so running
/// this function on an already-migrated database is always safe (idempotent).
///
/// `sqlx::migrate!("path")` is a compile-time macro: it embeds all `.sql`
/// files from the given path directly into the binary. The path is relative
/// to the project root (where Cargo.toml lives).
pub async fn run_migrations(pool: &AnyPool, backend: &DatabaseBackend) -> Result<(), DbError> {
match backend {
DatabaseBackend::Sqlite => {
sqlx::migrate!("migrations/sqlite").run(pool).await?;
}
DatabaseBackend::Postgres => {
sqlx::migrate!("migrations/postgres").run(pool).await?;
}
}
Ok(())
}

View File

@@ -11,5 +11,11 @@ pub mod routes;
#[cfg(feature = "ssr")]
pub mod config;
#[cfg(feature = "ssr")]
pub mod db;
#[cfg(feature = "ssr")]
pub mod state;
#[cfg(feature = "ssr")]
pub mod validation;

42
src/server/state.rs Normal file
View File

@@ -0,0 +1,42 @@
// server/state.rs — Shared Axum application state
//
// Axum uses a typed state system: any handler can extract a specific type
// from the shared state using the `State<T>` extractor.
//
// We store two pieces of state:
// - `leptos_options` : required by Leptos SSR routes
// - `db` : database pool shared across all requests
use axum::extract::FromRef;
use leptos::config::LeptosOptions;
use sqlx::AnyPool;
// ─── AppState ─────────────────────────────────────────────────────────────────
/// Shared state available to every Axum handler and Leptos server function.
///
/// `#[derive(Clone)]` is required by Axum: each request receives its own clone.
/// Both fields are cheap to clone — they hold reference-counted pointers
/// under the hood, so cloning just increments an atomic counter.
#[derive(Clone)]
pub struct AppState {
/// Leptos configuration (output paths, site address, reload port…).
pub leptos_options: LeptosOptions,
/// Shared connection pool to the database.
/// Using a pool (not a single connection) allows concurrent requests
/// to run their queries in parallel without contention.
pub db: AnyPool,
}
// ─── FromRef implementations ──────────────────────────────────────────────────
/// Tells Axum how to extract just `LeptosOptions` from the full `AppState`.
///
/// `.leptos_routes()` in main.rs requires this impl because it stores
/// only `LeptosOptions` internally, not the full application state.
impl FromRef<AppState> for LeptosOptions {
fn from_ref(state: &AppState) -> Self {
state.leptos_options.clone()
}
}