From 2c043d2f96248b20b0034988c27c11e103b7fec4 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 5 Jun 2026 09:27:08 +0200 Subject: [PATCH] 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 --- README.md | 6 +- docs/recipes/sqlx-persistence.md | 174 +++++++++++++++++++++++++++++++ examples/saas/README.md | 4 +- 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 docs/recipes/sqlx-persistence.md diff --git a/README.md b/README.md index a71e96d..224ae77 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ and integrate at explicit boundaries. req: laws/002 req: auth/001 - **Persistence:** use SQLx or another storage adapter in your application state/handlers. hemx should see ordinary domain values and generated UI - commands, not own the database layer. + commands, not own the database layer. See `docs/recipes/sqlx-persistence.md`. - **Auth/session:** use Axum/Tower extractors and middleware. Handlers may accept typed auth/session context and return ordinary HTTP failures or generated UI failures. req: auth/002 @@ -117,7 +117,9 @@ wire/runtime ABI; and advanced escape hatches that may remain integration-level. updates. Start here. - `examples/saas`: compile-tested v1 tutorial skeleton covering auth/session, CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain - CSS, and one explicit island without provider-heavy platform scope. + CSS, and one explicit island without provider-heavy platform scope. The SQLx + persistence recipe in `docs/recipes/sqlx-persistence.md` shows the provider + boundary without moving SQL into core. - `examples/kanban`: advanced / north-star milestone boundary sketch. It may expose manual registry or render escape hatches while exploring product limits. - `examples/techdemo`: advanced integration demo with a leaf island and broader diff --git a/docs/recipes/sqlx-persistence.md b/docs/recipes/sqlx-persistence.md new file mode 100644 index 0000000..b75aa73 --- /dev/null +++ b/docs/recipes/sqlx-persistence.md @@ -0,0 +1,174 @@ +# 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: + +```toml +# 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 + +```sql +-- 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 + +```rust +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 { + 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::("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, 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::("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` boundary. Expected validation remains a +form UI effect; unexpected persistence failure becomes an app failure effect or +HTTP response. req: failure/004 + +```rust +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. + +```rust +#[hemx::handler] +async fn create_project( + State(ctx): State, + Form(form): Form, +) -> Result { + 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: + +```rust +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 diff --git a/examples/saas/README.md b/examples/saas/README.md index 478d6ea..225de8b 100644 --- a/examples/saas/README.md +++ b/examples/saas/README.md @@ -12,9 +12,11 @@ What it proves today: - page shell with plain CSS and one explicit metrics island script - compile-time surface generation plus interaction tests +For a provider-explicit persistence boundary, see `../../docs/recipes/sqlx-persistence.md`. + What it deliberately does not claim yet: -- real SQLx migrations or a database pool +- a checked-in SQLx migration crate or database pool - production cookie/session middleware - a deploy target, flags, analytics, billing, or offline sync - browser automation for the metrics island