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
This commit is contained in:
@@ -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
|
- **Persistence:** use SQLx or another storage adapter in your application
|
||||||
state/handlers. hemx should see ordinary domain values and generated UI
|
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
|
- **Auth/session:** use Axum/Tower extractors and middleware. Handlers may accept
|
||||||
typed auth/session context and return ordinary HTTP failures or generated UI
|
typed auth/session context and return ordinary HTTP failures or generated UI
|
||||||
failures. req: auth/002
|
failures. req: auth/002
|
||||||
@@ -117,7 +117,9 @@ wire/runtime ABI; and advanced escape hatches that may remain integration-level.
|
|||||||
updates. Start here.
|
updates. Start here.
|
||||||
- `examples/saas`: compile-tested v1 tutorial skeleton covering auth/session,
|
- `examples/saas`: compile-tested v1 tutorial skeleton covering auth/session,
|
||||||
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
|
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
|
- `examples/kanban`: advanced / north-star milestone boundary sketch. It may
|
||||||
expose manual registry or render escape hatches while exploring product limits.
|
expose manual registry or render escape hatches while exploring product limits.
|
||||||
- `examples/techdemo`: advanced integration demo with a leaf island and broader
|
- `examples/techdemo`: advanced integration demo with a leaf island and broader
|
||||||
|
|||||||
@@ -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<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
|
||||||
|
|
||||||
|
```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<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:
|
||||||
|
|
||||||
|
```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
|
||||||
@@ -12,9 +12,11 @@ What it proves today:
|
|||||||
- page shell with plain CSS and one explicit metrics island script
|
- page shell with plain CSS and one explicit metrics island script
|
||||||
- compile-time surface generation plus interaction tests
|
- 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:
|
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
|
- production cookie/session middleware
|
||||||
- a deploy target, flags, analytics, billing, or offline sync
|
- a deploy target, flags, analytics, billing, or offline sync
|
||||||
- browser automation for the metrics island
|
- browser automation for the metrics island
|
||||||
|
|||||||
Reference in New Issue
Block a user