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:
@@ -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
|
||||
Reference in New Issue
Block a user