docs(examples): add saas tutorial walkthrough

Add the canonical SaaS tutorial walkthrough for the compile-tested examples/saas path, tying templates, typed domain forms, generated effects, error mapping, page/push shape, CSS/islands, tests, and provider recipes into one onboarding flow.

req: examples/001

req: canonical_authoring/001

req: canonical_authoring/002

req: canonical_authoring/003

req: auth/001

req: failure/004

req: page_swap/002

req: push/003

req: public_api/005
This commit is contained in:
slhx agent
2026-06-05 09:56:22 +02:00
parent 107111052d
commit 84b52c75ae
3 changed files with 237 additions and 7 deletions
+228
View File
@@ -0,0 +1,228 @@
# Tutorial: production-shaped SaaS app
This walkthrough explains the canonical v1 tutorial path in `examples/saas`.
It is intentionally provider-light: the app proves auth/session shape,
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
CSS, and one explicit island without moving SQL, auth, flags, deploy, or
observability providers into hemx core. req: examples/001 req: laws/002
Run it:
```sh
cargo run -p hemx-saas-example
cargo test -p hemx-saas-example
```
## What you are building
The tutorial app is a small project dashboard:
- a full page shell rendered by Rust and hemplate
- a `Dashboard` template with a project creation form
- typed domain inputs: `CsrfToken`, `ProjectName`, and `ProjectId`
- an app-owned `LocalProjectStore` persistence adapter
- an auth/session-shaped `AppContext`
- a CSRF-checked mutation handler
- generated form, summary, flash, keyed row, page-panel, and live-status effects
- an SSE/polling-shaped live status endpoint
- plain CSS and one explicit metrics island script
The important point is not the project domain; it is the boundary: templates
declare the UI surface, Rust owns domain state, handlers return generated UI
commands, and the browser runtime only applies checked effects. req: canonical_authoring/001 req: modes/001
## Files to read first
- `examples/saas/templates/dashboard.heml` — the UI contract
- `examples/saas/src/lib.rs` — domain types, app context, handlers, and tests
- `examples/saas/src/main.rs` — Axum route wiring and runtime/static assets
- `examples/saas/templates/app.css` — plain CSS
- `examples/saas/templates/metrics.js` — explicit leaf-island JavaScript
- `examples/saas/README.md` — scope and provider boundaries
## 1. Declare the surface in hemplate
The dashboard template names only facts that hemx can check and generate:
```heml
<section data-hemx-root="dashboard" data-hemx-sse="/events">
<form data-hemx-handle="create_project" data-hemx-form="new_project">
<input type="hidden" name="csrf" +value="self.csrf">
<input name="name" required="required">
<p data-hemx-error-for="name"></p>
</form>
<p data-hemx-slot="flash">{+ self.flash +}</p>
<p data-hemx-slot="summary">{+ self.summary +}</p>
<ul data-hemx-slot="project_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}
</template>
</ul>
</section>
```
There are no selectors, numeric ids, raw targets, or runtime opcodes in the
template. The `h-key` gives the keyed row target enough information for generated
append/replace/remove helpers. `{+ row +}` renders the child hemplate partial;
`{+= html =+}` is only for already-trusted HTML. req: canonical_authoring/002 req: list/001
## 2. Keep domain types ordinary
The form type is Rust domain code, not a generated DTO:
```rust
#[derive(Clone, Debug)]
#[hemx::form("new_project")]
pub struct NewProject {
csrf: CsrfToken,
name: ProjectName,
}
```
`ProjectName` trims submitted input via `FromStr`; `CsrfToken` is a typed value;
`ProjectId` implements `Display` for stable keyed row ids. The generated form
contract checks that the Rust shape matches the HTML controls. req: form/001 req: codegen/004
## 3. Put platform boundaries in app state
`AppContext` carries the authenticated session and persistence adapter:
```rust
#[derive(Clone)]
pub struct AppContext {
session: Session,
store: LocalProjectStore,
}
```
The local store is deliberately small and testable. Production providers are
recipes, not core dependencies:
- SQLx: `docs/recipes/sqlx-persistence.md`
- auth/session and CSRF middleware: `docs/recipes/auth-session-csrf.md`
- observability, feature flags, and killswitches:
`docs/recipes/observability-flags.md`
- deploy/runtime compatibility: `docs/recipes/deploy-versioning.md`
This keeps hemx focused on the UI contract while the app owns platform choices.
req: auth/001 req: laws/004
## 4. Write one boring handler
The create handler checks session/CSRF, validates input, persists a record, and
returns generated UI commands:
```rust
#[hemx::handler]
async fn create_project(
State(ctx): State<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
if form.csrf != ctx.session.csrf {
return Err(AppError::CsrfRejected);
}
if form.name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &ctx.session)?;
let total = ctx.projects().len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
dashboard::summary.set(project_summary(total)),
dashboard::new_project.clear(),
dashboard::flash.set("Project created"),
dashboard::live_status.set(format!("{total} projects persisted locally")),
))
}
```
The handler does not choose targets with CSS selectors, construct raw effects,
parse raw forms, or call the runtime. It returns intent through generated helpers
and tuple composition. req: canonical_authoring/003 req: dx/007
## 5. Map failures explicitly
Expected validation and platform failures cross one app error boundary:
```rust
impl IntoHandlerFailure for AppError {
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
match self {
AppError::Validation(message) => HandlerFailure::effects(
(
dashboard::new_project.error("name", message),
dashboard::new_project.focus("name"),
),
context,
),
other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
}
}
}
```
That keeps user mistakes visible in the generated form error target and keeps
infrastructure failures out of the normal success path. req: failure/004
## 6. Add page and push shape without a frontend app
The settings handler swaps a generated page panel and pushes history:
```rust
(
dashboard::page_panel.put(&SettingsPage { message: "..." }),
dashboard::nav.set("Settings"),
hemx::push("/settings"),
)
```
The live-status endpoint sends generated effect batches over SSE/polling-shaped
transport. Routing, auth, and connection policy stay in Axum/app code; hemx does
not become a router or transport framework. req: page_swap/002 req: push/003
## 7. Keep CSS and islands explicit
Appearance is plain CSS in `templates/app.css`. The metrics widget is an opaque
leaf island declared with `data-hemx-island="metrics"` and implemented by
`templates/metrics.js`. The island may inspect its own leaf DOM; ordinary forms,
lists, page swaps, and live status do not require handwritten JavaScript. req: canonical_authoring/007 req: dx/008
## 8. Test at the product boundary
`cargo test -p hemx-saas-example` proves the tutorial shape:
- the page contains the root, generated form, CSRF field, SSE marker, island,
CSS, and island asset
- stale CSRF does not mutate the store and maps to generated UI
- validation maps to a generated form error
- valid mutation persists locally and returns generated keyed row, summary, form,
and live-status effects
- page swap and push shape use generated targets
These tests are intentionally app-level. They prove behavior without browser
provider setup or external database side effects. req: test/001 req: examples/001
## 9. Productionize by swapping adapters, not changing hemx
To move from the local tutorial skeleton to production:
1. Replace `LocalProjectStore` with a SQLx adapter from
`docs/recipes/sqlx-persistence.md`.
2. Replace the demo `Session` with an Axum/Tower extractor and CSRF service from
`docs/recipes/auth-session-csrf.md`.
3. Wrap routes/handlers with app-owned metrics, flags, and killswitches from
`docs/recipes/observability-flags.md`.
4. Deploy server, generated output, and `/hemx.js` as one release unit following
`docs/recipes/deploy-versioning.md`.
5. Follow `docs/versioning.md` for semver and upgrade notes.
6. Use `docs/diagnostics.md` when a template/build/derive/runtime mistake fails
the app.
The handler and template model should stay recognizable throughout those swaps.
If productionizing requires raw ids, selector retargeting, manual registries, or
client app state, treat that as a design smell and either add a named advanced
escape hatch or keep the provider integration outside the beginner path. req: public_api/005 req: runtime/003