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:
@@ -5,9 +5,10 @@ handlers, and return generated UI commands. The browser receives checked UI
|
|||||||
commands; ordinary server-first apps do not need a frontend framework,
|
commands; ordinary server-first apps do not need a frontend framework,
|
||||||
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001
|
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001
|
||||||
|
|
||||||
Status: the repository currently has a strong v0 example/API path, an advanced
|
Status: the repository currently has a strong v0 example/API path, a compile-tested
|
||||||
Kanban milestone sketch, and a full techdemo. The polished v1 production SaaS
|
SaaS tutorial skeleton, an advanced Kanban milestone sketch, and a full techdemo.
|
||||||
tutorial app is still a goal, not a completed artifact.
|
The polished v1 product story is still being filled out through docs, recipes,
|
||||||
|
diagnostics, and stability guidance.
|
||||||
|
|
||||||
## The normal path
|
## The normal path
|
||||||
|
|
||||||
@@ -120,9 +121,10 @@ See `docs/versioning.md`.
|
|||||||
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. The SQLx
|
CSS, and one explicit island without provider-heavy platform scope. Read the
|
||||||
persistence recipe in `docs/recipes/sqlx-persistence.md` shows the provider
|
walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in
|
||||||
boundary without moving SQL into core.
|
`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,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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# hemx SaaS tutorial skeleton
|
# hemx SaaS tutorial skeleton
|
||||||
|
|
||||||
This is the compile-tested skeleton for the v1 production-shaped tutorial app. It is intentionally provider-light: auth/session, CSRF, persistence, deploy, metrics, and islands are explicit app boundaries, not hemx core services. req: examples/001 req: auth/001
|
This is the compile-tested skeleton for the v1 production-shaped tutorial app. It is intentionally provider-light: auth/session, CSRF, persistence, deploy, metrics, and islands are explicit app boundaries, not hemx core services. Read the walkthrough in `../../docs/tutorial-saas.md`. req: examples/001 req: auth/001
|
||||||
|
|
||||||
What it proves today:
|
What it proves today:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user