Files
hemx/docs/recipes/sqlx-persistence.md
slhx agent 2c043d2f96 docs(recipes): add sqlx persistence boundary
Document a provider-explicit SQLx persistence adapter for the SaaS tutorial without adding SQLx to hemx core or the workspace. The recipe keeps auth/session, CSRF, generated helpers, and Result handler mapping as the app boundary.

req: laws/002

req: laws/004

req: auth/001

req: auth/002

req: auth/004

req: examples/001

req: canonical_authoring/002

req: failure/004
2026-06-05 09:27:08 +02:00

5.3 KiB

Recipe: SQLx persistence for the SaaS tutorial

This recipe replaces the tutorial app's in-memory LocalProjectStore with an application-owned SQLx adapter. SQLx is deliberately a recipe dependency, not a hemx core dependency: hemx still sees ordinary Rust domain values, typed forms, and generated UI commands. req: laws/002 req: laws/004 req: auth/001

Use this when the examples/saas flow is ready to persist projects outside the process. Keep auth/session and CSRF checks in middleware/extractors or app state, then call the store from the handler only after those checks pass. req: auth/002 req: auth/004

Cargo feature in the app, not hemx

Add SQLx to the application crate that owns persistence:

# examples/saas/Cargo.toml or your app crate
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros", "migrate"] }

Do not add SQLx to hemx, hemx-core, hemx-build, hemx-derive, or hemx-axum. Persistence is app/domain policy, not a UI runtime primitive.

Schema

-- migrations/0001_projects.sql
CREATE TABLE projects (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  owner_email TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Adapter

The adapter has the same shape as LocalProjectStore: insert a domain command, return a domain record, and let the handler convert that record into the hemplate view type used by generated helpers. req: examples/001 req: canonical_authoring/002

use sqlx::{Row, SqlitePool};

#[derive(Clone)]
pub struct SqlxProjectStore {
    pool: SqlitePool,
}

impl SqlxProjectStore {
    pub fn new(pool: SqlitePool) -> Self {
        Self { pool }
    }

    pub async fn insert(
        &self,
        name: ProjectName,
        session: &Session,
    ) -> Result<ProjectRecord, AppError> {
        let row = sqlx::query(
            r#"
            INSERT INTO projects (name, owner_email)
            VALUES (?, ?)
            RETURNING id, name, owner_email
            "#,
        )
        .bind(name.as_str())
        .bind(&session.email)
        .fetch_one(&self.pool)
        .await
        .map_err(AppError::from_sqlx)?;

        Ok(ProjectRecord {
            id: ProjectId(row.try_get::<i64, _>("id").map_err(AppError::from_sqlx)? as u64),
            name: row.try_get("name").map_err(AppError::from_sqlx)?,
            owner: row.try_get("owner_email").map_err(AppError::from_sqlx)?,
        })
    }

    pub async fn list(&self) -> Result<Vec<ProjectRecord>, AppError> {
        let rows = sqlx::query(
            r#"
            SELECT id, name, owner_email
            FROM projects
            ORDER BY id
            "#,
        )
        .fetch_all(&self.pool)
        .await
        .map_err(AppError::from_sqlx)?;

        rows.into_iter()
            .map(|row| {
                Ok(ProjectRecord {
                    id: ProjectId(row.try_get::<i64, _>("id").map_err(AppError::from_sqlx)? as u64),
                    name: row.try_get("name").map_err(AppError::from_sqlx)?,
                    owner: row.try_get("owner_email").map_err(AppError::from_sqlx)?,
                })
            })
            .collect()
    }
}

Keep SQLx errors in the app error type and map them through the existing Result<impl IntoEffect, AppError> boundary. Expected validation remains a form UI effect; unexpected persistence failure becomes an app failure effect or HTTP response. req: failure/004

impl AppError {
    fn from_sqlx(error: sqlx::Error) -> Self {
        eprintln!("project store failed: {error}");
        AppError::StoreUnavailable
    }
}

Handler boundary

The handler shape does not change. Only the store implementation changes.

#[hemx::handler]
async fn create_project(
    State(ctx): State<AppContext>,
    Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
    ctx.require_session()?;
    ctx.verify_csrf(&form.csrf)?;

    if form.name.as_str().is_empty() {
        return Err(AppError::Validation("Project name required"));
    }

    let project = ctx.store.insert(form.name, &ctx.session).await?;
    let total = ctx.store.list().await?.len();

    Ok((
        dashboard::project_row.append(ProjectRow::from(project)),
        dashboard::summary.set(project_summary(total)),
        dashboard::new_project.clear(),
        dashboard::flash.set("Project created"),
    ))
}

The important invariant is that SQLx never appears in templates, generated helpers, the JavaScript runtime, or hemx core. It is an application adapter behind ordinary Rust state. req: invariant/005

Test shape

Prefer an app-level integration test with an in-memory SQLite pool and migrations:

let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let ctx = AppContext::with_store(Session::demo(), SqlxProjectStore::new(pool));

let response = InteractionRequest::from(form(
    dashboard::create_project,
    &[("csrf", "demo-csrf"), ("name", "Launch checklist")],
))
.dispatch_async(registry(ctx.clone()))
.await?;

let effects = inspect_batch(response.batch);
assert!(effects.inserts_html_containing(dashboard::project_row, "1", "Launch checklist"));

This proves the same generated form/slot/keyed-row behavior as the local adapter while exercising a real provider at the application boundary. req: examples/001 req: test/001