diff --git a/README.md b/README.md index e8c7662..e7e3194 100644 --- a/README.md +++ b/README.md @@ -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, 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 -Kanban milestone sketch, and a full techdemo. The polished v1 production SaaS -tutorial app is still a goal, not a completed artifact. +Status: the repository currently has a strong v0 example/API path, a compile-tested +SaaS tutorial skeleton, an advanced Kanban milestone sketch, and a full techdemo. +The polished v1 product story is still being filled out through docs, recipes, +diagnostics, and stability guidance. ## The normal path @@ -120,9 +121,10 @@ See `docs/versioning.md`. 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. The SQLx - persistence recipe in `docs/recipes/sqlx-persistence.md` shows the provider - boundary without moving SQL into core. + CSS, and one explicit island without provider-heavy platform scope. Read the + walkthrough in `docs/tutorial-saas.md`; 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/tutorial-saas.md b/docs/tutorial-saas.md new file mode 100644 index 0000000..0a87dde --- /dev/null +++ b/docs/tutorial-saas.md @@ -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 +
+
+ + +

+
+ +

{+ self.flash +}

+

{+ self.summary +}

+ + +
+``` + +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, + Form(form): Form, +) -> Result { + 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 diff --git a/examples/saas/README.md b/examples/saas/README.md index f48dfc4..fff6200 100644 --- a/examples/saas/README.md +++ b/examples/saas/README.md @@ -1,6 +1,6 @@ # 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: