From 677f87d9d0a5163bd75b311840cd314328f173fd Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 17 Aug 2026 00:03:04 -0500 Subject: [PATCH 1/5] docs: rewrite README from the e2e-ui home story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lead with the playground teaching path — the bar, backstory, and the unidirectional loop from command to replica — instead of a crate-tour quick start. Keep the API reference from Feature Flags onward. Implements [[tasks/service-authoring-1]] --- README.md | 807 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 424 insertions(+), 383 deletions(-) diff --git a/README.md b/README.md index ba657320..d5d89436 100644 --- a/README.md +++ b/README.md @@ -1,489 +1,532 @@ # Distributed -**Event-sourced Rust backends. Deny-by-default GraphQL with first-class OIDC. -A causal TypeScript replica for real apps.** One platform from aggregate tests -to SvelteKit SSR. +**Distributed** is a state-of-the-art framework for building distributed +systems and realtime applications. -Plain domain structs on the write side. Relational read models on the query -side. The GraphQL edge validates Bearer tokens (JWKS) and maps claims into -session roles/RLS — not a bolted-on middleware afterthought. The browser talks -**one protocol** — GraphQL queries, live subscriptions, and typed command -mutations — through `@hops-ops/distributed`. +Not a partial toolkit. An end-to-end cloud native stack — domain, service, +query edge, live client, and even GitOps — so engineers who care about quality +code can stay on the model and still ship polished, fast, maintainable +products. ---- +Rust · TypeScript · CQRS / ES · SvelteKit -## See it run +The living playground is [`tests/e2e-ui`](tests/e2e-ui): real apps (chat, +todos, blob, admin) with a **How it is built** panel on every screen. This +README is the same story, in the repo. -### e2e-ui — read the code (`tests/e2e-ui`) +- [The bar](#the-bar) — what “state of the art” means here +- [Backstory](#backstory) — why this is one system, not a kit of parts +- [How it delivers](#how-it-delivers) — the unidirectional loop, with real code +- [See it run](#see-it-run) — playground, GraphiQL, live OIDC +- [Use as a dependency](#use-as-a-dependency) — workspace layout and features +- [Reference](#feature-flags) — macros, repos, bus, GraphQL, CLI -A copyable multi-crate product: pure domains, GraphQL-only edge, Zitadel OIDC, -SvelteKit SSR, generated clients, live WS. Full runbook + deeper map: -**[`tests/e2e-ui/README.md`](tests/e2e-ui/README.md)**. +--- -```bash -cd tests/e2e-ui && make up && set -a && source e2e-ui.env && set +a && make run -# UI :5180 · API :8791/graphql · login alice / Password1! -``` +## The bar -What makes it feel like a real app is not a second framework — it is a short -stack of deliberate files. Start here: +You never get perfect consistency, always-available writes, and partition +tolerance at once (**CAP**). Products that stay up accept **eventual +consistency** on reads — with clear rules about what the user can trust now. +The bar is not a kit of excellent parts. It is one path from domain event to +optimistic row. -| File | Why it is nice | -|---|---| -| [`ui/src/routes/todos/+page.graphql`](tests/e2e-ui/ui/src/routes/todos/+page.graphql) | Co-located read. `@load` → SSR seed; no hand-written load function for the list. | -| [`ui/src/routes/todos/+page.svelte`](tests/e2e-ui/ui/src/routes/todos/+page.svelte) | `Todos.use()` + `useCommands()` — page never invents a cache or optimistic recipe. | -| [`ui/src/routes/chat/+page.graphql`](tests/e2e-ui/ui/src/routes/chat/+page.graphql) | Same document does SSR **and** live: `@load @live`. No second subscription file. | -| [`ui/src/routes/blob/[[gameId]]/+page.svelte`](tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte) | Arrow keys → `commands.blob.move`; board from `BlobGames.use()` — projected payload hits the replica before the call resolves. | -| [`ui/src/routes/+layout.server.ts`](tests/e2e-ui/ui/src/routes/+layout.server.ts) | One root loader, generated route registry, session → engine role. No loading flash for declared ops. | -| [`ui/src/routes/+layout.svelte`](tests/e2e-ui/ui/src/routes/+layout.svelte) | `provideDistributed` + SSR hydration into the causal replica. | -| [`ui/src/routes/admin/+layout.server.ts`](tests/e2e-ui/ui/src/routes/admin/+layout.server.ts) | Elevated surface is a **second** generated client + role gate — not smuggled into the user bundle. | -| [`crates/service/src/service.rs`](tests/e2e-ui/crates/service/src/service.rs) | Inventory, RLS (`owner_id = claim(x-user-id)`), dual client surfaces (`e2e-ui` / `e2e-ui-admin`), OIDC claim map. | -| [`crates/service/src/handlers/commands/blob_move.rs`](tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs) | `PreparedCommand>` — map/score written with the event, not dual-written later. | -| [`crates/todo-domain/src/models/todo.rs`](tests/e2e-ui/crates/todo-domain/src/models/todo.rs) | Plain aggregate: `create` / `ensure_owner` / `@sourced` events — no GraphQL in the domain. | -| [`crates/readmodels/src/models/blob_game_view.rs`](tests/e2e-ui/crates/readmodels/src/models/blob_game_view.rs) | `#[table]` + `belongs_to` owner join — GraphQL shape from the read model. | -| [`ui/src/auth.ts`](tests/e2e-ui/ui/src/auth.ts) | Real Auth.js + Zitadel scopes/groups → engine roles. | +**Event-driven backend.** Command in, domain event out, projections update +reads. The UI does not patch tables. **CQRS** keeps aggregates for rules and +read models for screens. **Event sourcing** records what happened as history +you can unit-test. Identity is OIDC and RBAC on the same claims — not a +one-off check per endpoint. -```text - SvelteKit ──GraphQL HTTP/WS──► Rust edge (GraphqlEngine + microsvc) - │ │ - │ @hops-ops/distributed ├── mutations → aggregates - │ causal replica + commands ├── Atomic rows (blob) with events - │ distributed-generated ops └── projector rows (todos, chat) -``` +**Compiler-owned frontend.** SSR, the same query rehydrated, then a live +feed. GraphQL selects fields; writes stay **commands**. A **replica** holds +the authorized slice. Optimism uses the same mutation program as the +projector — not a `setState` recipe per page. -JS package deep-dive: [`js/README.md`](js/README.md). +**Why Rust.** Memory safety without GC pauses, concurrency the type system +can check, and macros so the domain API stays short. The same definitions +compile into the client. Substrate, not fashion. -### GraphiQL playground (engine only) +**Same blocks, few or many processes.** Domain, modules, and projections are +packages — not a deploy shape. A **service crate** lists the modules this +process runs. Today the playground is one host. Later you write another +`Service` from the same modules. Eventual projectors can move; Atomic seals +stay with commands. -```bash -cargo run --example graphiql --features "graphql,sqlite" -# → http://127.0.0.1:4000/graphql -``` +**Distributed** is that path — one system so generation can keep the DX +simple. -### First-class OIDC (Zitadel, Keycloak, Authentik) +--- -GraphQL identity is **built into the engine** (`OidcBearer`: JWKS, iss/aud/exp, -claim → role/session). Live against three local IdPs — not mocks only: +## Backstory -| Provider | Compose + bootstrap | Live test | Gate | -|---|---|---|---| -| **[Zitadel](tests/graphql_oidc_zitadel/)** (reference) | `./scripts/oidc-zitadel-up.sh` | `cargo test --test graphql_oidc_zitadel --features graphql,sqlite` | `ZITADEL_E2E=1` | -| **[Keycloak](tests/graphql_oidc_keycloak/)** | `./scripts/oidc-keycloak-up.sh` | `cargo test --test graphql_oidc_keycloak --features graphql,sqlite` | `KEYCLOAK_E2E=1` | -| **[Authentik](tests/graphql_oidc_authentik/)** | `./scripts/oidc-authentik-up.sh` | `cargo test --test graphql_oidc_authentik --features graphql,sqlite` | `AUTHENTIK_E2E=1` | +Built by someone who has lived the glue. Patrick Lee Scott is a multi-time +CTO and long-time consultant on microservices and DevOps. He has maintained +**sourced** and **servicebus** in the Node ecosystem for nearly a decade, +and has been a student of domain-driven design since before CQRS/ES was the +usual name for the write path. -Shared **E1–E8** in [`tests/graphql_oidc_common/`](tests/graphql_oidc_common/). -Gated binaries skip cleanly when unset. Offline: `cargo test --test graphql_identity --features graphql,sqlite`. +Early on the pieces were wired together. **Matt Walters** authored Node +`sourced` and `servicebus`, which inspired parts of what this library does. +Later, Knative Eventing replaced much of hand-rolled service-bus plumbing. +For reads, Hasura-style SQL (joins, RBAC, generated query APIs) worked. It +was still a kit of parts. -e2e-ui boots **Zitadel** for the browser path; the three stacks prove the same -`OidcBearer` edge is not vendor-locked. +Distributed started in late 2024 as AI became usable for real systems work — +and as models got good enough that building the dream framework (everything +in one coherent place) stopped being a multi-year solo fantasy. The +playground is that system: domain through live UI, with the DX we always +wanted. --- -## At a Glance +## How it delivers -| Capability | What it gives you | -|---|---| -| **Full-stack path** | Rust domains → GraphQL edge → `@hops-ops/distributed` → SvelteKit/React | -| **First-class OIDC** | Built-in `OidcBearer` (JWKS, claims → roles); live e2e for **Zitadel, Keycloak, Authentik** | -| Plain Rust aggregates | Domain state in ordinary structs with explicit command methods | -| Model-first TDD | Exhaustive unit tests before handlers or infrastructure | -| Event-sourced persistence | Append-only records, replay, optimistic commit, pluggable async repos | -| Outbox + multi-transport bus | Durable publish; swap in-memory / SQL / NATS / RabbitMQ / Kafka / Knative | -| Read models | Relational projections — atomic with the command **or** eventual from projectors | -| GraphQL query service | Filters, order, pagination, relationships, RBAC, live subs, causal mutations | -| npm JS client | Artifacts, HTTP/WS transport, **causal replica**, diagnostics, SvelteKit/React | -| microsvc | One handler inventory on HTTP, gRPC, bus, GraphQL, or direct dispatch | -| `distributed` | Scaffold, SQL/Atlas/SDL, **client-manifest / client** codegen | - -## Use as a Dependency - -The recommended shape is one shared crate per bounded context, plus one or more -service crates that use those types. Put aggregate models, event payload types, -command input DTOs, read models, and manifest registration helpers in the shared -crate. Then import that crate from the command/aggregate service, projection -service, API service, tests, or any other crate that needs the same domain types. +Write the domain once, compose it into one `Service` or several, then +generate the client. Each stage below uses real code from +[`tests/e2e-ui`](tests/e2e-ui). ```text -crates/ - ordering/ # shared bounded-context types - ordering-api/ # command/aggregate service - ordering-projections/ # projection/read-model service + query · command · live +Client ──────────────────────────────────► GraphQL gateway + │ + ┌── commands → aggregate → domain event → projection → read model ──┐ + │ │ + └──────────────────────────── one way ──────────────────────────────┘ ``` -The aggregate service imports the aggregate types and command DTOs; projection -services import the event/read-model DTOs and `ReadModel` types; API or test -crates can use the same shared types without redefining them. - -The shared bounded-context crate usually depends on `distributed` with the empty -default feature set. It needs macros and traits, not HTTP servers, SQL adapters, -or broker clients: - -```toml -# crates/ordering/Cargo.toml -[dependencies] -distributed = "0.1" -serde = { version = "1", features = ["derive"] } -``` +### 01 · Unidirectional -Executable service crates depend on the bounded-context crate and enable the -runtime features they need: +Changes go one way. There is order. -```toml -# crates/ordering-api/Cargo.toml -[dependencies] -ordering = { path = "../ordering" } -distributed = { version = "0.1", features = ["postgres", "http", "nats"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -``` +Front-end developers know this from Redux: dispatch in, state updates on a +defined path, UI reads the result. Distributed is that idea for the **whole +system**. -For local development against a checkout of this repository, use a path -dependency instead: +Client → **command** → **aggregate** state change → **domain event** → +**projection** → **read model** → client. No dual-write from the UI. CAP +and eventual consistency sit on the read side; optimistic UI is how the +front end meets that honestly. -```toml -[dependencies] -distributed = { path = "../distributed" } -``` +### 02 · CQRS -In a multi-crate workspace, put the dependency in the workspace root and inherit -it from member crates. Keep the root dependency feature-light, then enable -service-specific features only in the service crates: +Decisions and views are different models. -```toml -# workspace Cargo.toml -[workspace.dependencies] -distributed = "0.1" -ordering = { path = "crates/ordering" } +In the business, “complete this todo” is a decision with rules. “Show my +open todos” is a question about a list. Commands load aggregates; queries +hit a SQL-shaped read model. You avoid forcing both into “update a row,” +so domain code stays about rules and screens stay about presentation. -# crates/ordering/Cargo.toml -[dependencies] -distributed.workspace = true +```rust,ignore +// Commands → aggregates (accept / reject business rules) +commands.todo.create({ title }) +commands.todo.archive({ todo_id }) -# crates/ordering-api/Cargo.toml -[dependencies] -ordering.workspace = true -distributed = { workspace = true, features = ["postgres", "http", "nats"] } +// Queries → SQL-shaped read models (never write tables) +// query Todos @load { todos { todo_id title status } } ``` -Enable persistence, transports, and servers with crate features: +### 03 · Event-sourced aggregates -```toml -[dependencies] -# HTTP service endpoints -distributed = { version = "0.1", features = ["http"] } +Business rules as plain types — with history. -# Durable SQL repository + SQL-backed bus -distributed = { version = "0.1", features = ["postgres"] } +Express the business as ordinary Rust structs and methods: who may do what, +what state is allowed next. Under the hood that’s event sourcing — +repository, append-only events, optional upcasters — so you get a timeline +and easy unit tests without putting rules in SQL or HTTP. -# Service using Postgres plus NATS JetStream transport -distributed = { version = "0.1", features = ["postgres", "nats"] } -``` +[`tests/e2e-ui/crates/todo-domain/src/models/todo.rs`](tests/e2e-ui/crates/todo-domain/src/models/todo.rs) -Most application crates should depend on `distributed` only. The proc macros -(`#[sourced]`, `#[digest]`, `#[derive(ReadModel)]`, `#[derive(Snapshot)]`) are -re-exported from `distributed`; do not add `distributed_macros` directly unless -you are working on the macro crate itself. The `distributed_cli` crate installs -the `distributed` tooling and is not needed as a runtime dependency unless you are -embedding the CLI in another command such as `hops service`. +```rust,ignore +#[sourced( + entity, + events = "TodoEvent", + aggregate_type = "todo", + domain_state = TodoState, +)] +impl Todo { + pub fn create( + &mut self, + todo_id: impl Into, + owner_id: impl Into, + title: impl Into, + ) -> Result<(), TodoError> { + // …validate… + self.record_created(todo_id, owner_id, title)?; + Ok(()) + } -## Quick Start (library) + #[event("todo.created", version = 1, domain)] + fn record_created(&mut self, todo_id: String, owner_id: String, title: String) { + self.entity.set_id(&todo_id); + self.todo_id = todo_id; + self.owner_id = owner_id; + self.title = title; + self.status = TodoStatus::Open; + } +} +``` -Want the product demo first? Use [See it run](#see-it-run) above. This section is -the minimal **in-crate** path: specify the model API in tests, implement the model, add a thin -command handler, serve it, then swap in production persistence and transports -without changing the proven domain behavior. +### 04 · SQL read models + RBAC -### 1. Specify the model behavior in tests +What the user is allowed to see. -Start with the API you want the domain model to expose. These are ordinary, -synchronous Rust unit tests: instantiate the plain model and call its command -methods directly. There is no Tokio runtime, repository, handler `Context`, bus, -database, or mock to set up. +Screens need tables: lists, filters, joins. Read models are that query +shape, with row/column permissions next to the model — “owner sees only +their todos,” “admin sees all.” Queries and commands share the same idea +of who the actor is. -Write the test before the model behavior exists, see it fail, and then implement -only enough behavior to make it pass. Assert the complete observable contract: -the result, resulting state, and the typed events recorded by the command. +[`tests/e2e-ui/crates/readmodels/src/models/todos.rs`](tests/e2e-ui/crates/readmodels/src/models/todos.rs) ```rust,ignore -#[cfg(test)] -mod tests { - use super::*; - - fn initialized_todo() -> Todo { - let mut todo = Todo::default(); - todo.initialize( - "todo-1".into(), - "user-1".into(), - "Buy milk".into(), - ) - .unwrap(); - todo - } +#[derive(Clone, Debug, ReadModel)] +#[readmodel(primary_key = ["todo_id"])] +pub struct Todos { + #[readmodel(id)] + pub todo_id: String, + pub owner_id: String, + pub title: String, + pub status: String, +} - #[test] - fn completing_a_todo_changes_state_and_records_the_fact() { - let mut todo = initialized_todo(); +impl Todos { + pub fn permissions() -> ModelPermissions { + ModelPermissions::new() + .grant( + "user", + read() + .all_columns() + .rows(col("owner_id").eq(claim("x-user-id"))), + ) + .grant("admin", read().all_columns()) + } +} +``` - todo.complete().unwrap(); +### 05 · Inferred query API - assert!(todo.snapshot().completed); - assert_eq!(todo.entity.version(), 2); - assert_eq!( - TodoEvent::try_from(&todo.entity.events()[1]).unwrap(), - TodoEvent::Completed, - ); - } +Page needs → typed client. - #[test] - fn completing_an_already_completed_todo_is_a_no_op() { - let mut todo = initialized_todo(); - todo.complete().unwrap(); - let before = todo.snapshot(); - let version = todo.entity.version(); - let event_count = todo.entity.events().len(); +Once the read model is defined, GraphQL is how the UI selects fields — +transport for queries, not the heart of the product. You declare what a +page needs; you don’t maintain a REST endpoint per screen. Commands stay +domain verbs on the write side. - todo.complete().unwrap(); +[`tests/e2e-ui/ui/src/routes/todos/+page.graphql`](tests/e2e-ui/ui/src/routes/todos/+page.graphql) - assert_eq!(todo.snapshot(), before); - assert_eq!(todo.entity.version(), version); - assert_eq!(todo.entity.events().len(), event_count); - } +```graphql +# Page declares the shape it needs — no hand-written query API +query Todos @load { + todos(order_by: [{ status: asc }, { todo_id: asc }]) { + todo_id + owner_id + title + status + } } ``` -Repeat this red-green-refactor loop for every valid transition, invariant, -guard/no-op, validation failure, repeated command, and boundary case. The small, -infrastructure-free surface makes 100% model coverage a practical target before -service or handler work begins. Coverage proves that code ran, however; the -meaningful state, result, and event assertions are what prove the domain contract. -Run `cargo llvm-cov --lib --summary-only` in the bounded-context crate to measure -that model-only feedback loop. +### 06 · Projections -The `when = ...` guard used below deliberately returns `Ok(())` without changing -state or recording an event. If the desired API should reject the command instead, -write that contract first (`Err`, unchanged state, and no new event), validate in -the public command method, and only then call a private recorded event applier. +When the domain changes, views catch up. -### 2. Implement the model +After a command succeeds, events describe what happened. Projections turn +those facts into read-model updates — and the same mapping drives +optimistic UI in the browser. You declare “on these events, update the +view like this” once; you don’t dual-write tables from the page. -A domain model is a plain Rust struct with an embedded `Entity`. `#[sourced]` turns -its command methods into recorded, replayable events; `#[derive(Snapshot)]` adds a -hydration cache for long streams. +The mutation file looks like GraphQL but is **internal IR** for that +update program, not a public client mutation API. Field names are +snake_case table names (`upsert_todos`). Pages still send domain commands. -```rust,ignore -use serde::{Deserialize, Serialize}; -use distributed::{sourced, DomainState, Entity, Snapshot}; +[`tests/e2e-ui/crates/projections/src/todos.rs`](tests/e2e-ui/crates/projections/src/todos.rs) -#[derive(Clone, Serialize, DomainState)] -#[domain_state(version = 1)] -struct TodoState { - id: String, - user_id: String, - task: String, - completed: bool, +```rust,ignore +// Event → mutation mapping (server projector + client optimism) +projection! { + pub const TODOS: ProjectionDescriptor = { + name: "project_todos", + version: 1, + model: Todos, + on { + events: [ + TodoCreatedDomainEvent, + TodoCompletedDomainEvent, + TodoArchivedDomainEvent, + ], + mutation: SaveTodo, + input: { todo: body }, + }, + on { + events: [TodoPurgedDomainEvent], + mutation: DeleteTodo, + input: { todo_id: aggregate_id }, + }, + }; } +``` -#[derive(Default, Snapshot)] -struct Todo { - entity: Entity, - user_id: String, - task: String, - completed: bool, +```graphql +# Syntax-only IR → MutationProgram (not a public GraphQL field). +# Same program applies to the SQL read model and the browser replica. +mutation SaveTodo { + upsert_todos(object: $input.todo) } +``` -impl From<&Todo> for TodoState { - fn from(todo: &Todo) -> Self { - Self { - id: todo.entity.id().to_string(), - user_id: todo.user_id.clone(), - task: todo.task.clone(), - completed: todo.completed, - } - } +Handlers stay thin: load the aggregate, call a proven domain method, +commit events. + +```rust,ignore +pub async fn handle( + ctx: &CausalCommandContext<'_, Todo>, + input: TodoArchiveInput, +) -> Result>, HandlerError> { + let owner = ctx.user_id()?.to_string(); + let mut todo = ctx.repo() + .get(&input.todo_id).await? + .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; + todo.archive(&owner).map_err(rejected)?; + + let state = TodoState::from(&*todo); + ctx.repo().publish_events().commit(todo)?.eventual(TodoArchivePayload { + todo_id: state.todo_id, + status: state.status, + }) } +``` -#[sourced(entity, aggregate_type = "todo", domain_state = TodoState)] -impl Todo { - #[event("todo.initialized", version = 1, domain)] - fn initialize(&mut self, id: String, user_id: String, task: String) { - self.entity.set_id(&id); - self.user_id = user_id; - self.task = task; - } +### 07 · Service crates - #[event("todo.completed", version = 1, when = !self.completed, domain)] - fn complete(&mut self) { - self.completed = true; - } -} +Compose the process. Keep the domain still. -// The command input your handler decodes -#[derive(Deserialize)] -struct CreateTodo { - id: String, - user_id: String, - task: String, -} +A module mounts one bounded context — commands, guards, projectors. A +**service crate** lists those modules. That list *is* the process: the +playground is one `Service`, one host, one runner that only reads env and +calls `run`. You do not set a runtime role flag. + +The same packages can back a different `Service` later: all modules in one +binary, or commands here and Eventual projectors there. **Atomic** work +(blob’s board seal) stays with the command process. **Eventual** work can +split. Topology is explicit composition — not a hidden matrix. -// #[sourced] generates: TodoEvent enum, TryFrom<&EventRecord>, impl Aggregate -// #[derive(Snapshot)] generates: TodoSnapshot, fn snapshot(), impl Snapshottable +[`tests/e2e-ui/crates/service/src/modules/compose.rs`](tests/e2e-ui/crates/service/src/modules/compose.rs) + +```rust,ignore +// Same domain + module crates. This Service lists what this process runs. +pub const MODULE_IDS: &[&str] = &[ + todo::MODULE_ID, chat::MODULE_ID, blob::MODULE_ID, "identity", +]; + +Service::new() + .named("e2e-ui") + .routes(todo::routes(/* commands + Eventual projector */)) + .routes(chat::routes(/* … */)) + .routes(blob::routes(/* Atomic — stays with commands */)) + +// Another crate can list the same modules, or only Eventual projectors. +// You write that Service. You do not flip a Runtime::role flag. ``` -### 3. Write a command handler +HTTP `POST /{command}` is **off by default**. Browser writes use the +GraphQL command proxy. Call `.with_http_command_routes()` only for an +intentional non-GraphQL ingress. -Each handler is a module exporting a `COMMAND` name, a `guard`, and an **async** -`handle`. It loads/creates the aggregate, runs a command, and commits the resulting -events — optionally alongside a durable outbox message in the same transaction. +### 08 · Browser replica -```rust,ignore -// handlers/todo_create.rs -use serde_json::{json, Value}; -use distributed::microsvc::{Context, HandlerError}; +The cycle closes at the client. -use super::Repo; // an AggregateRepository<_, Todo> alias +Generated TypeScript carries your inventory into a client replica and typed +commands. The page reads `query.use()` and calls `commands.todo…` — same +business verbs as the server. Optimism applies the projection mapping on +the way around the loop, so the UI stays aligned with the model instead of +a one-off cache recipe per screen. -pub const COMMAND: &str = "todo.initialize"; +[`tests/e2e-ui/ui/src/routes/todos/+page.svelte`](tests/e2e-ui/ui/src/routes/todos/+page.svelte) -pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "user_id", "task"]) -} +```ts +// Generated operation + typed commands — no cache recipes in the page +import { Todos, useCommands } from '$distributed'; -pub async fn handle(ctx: &Context<'_, Repo>) -> Result { - let input = ctx.input::()?; +const query = Todos.use(); +const commands = useCommands(); +const todos = $derived($query.complete ? $query.data.todos : []); - let mut todo = Todo::default(); - todo.initialize(input.id.clone(), input.user_id, input.task)?; +await commands.todo.create({ title: text }); +await commands.todo.complete({ todo_id }); +``` - // Publish the canonical TodoState occurrence captured by the domain-marked - // transition. History + occurrence + outbox commit atomically. - ctx.repo().publish_events().commit(&mut todo).await?; +JS package deep-dive: [`js/README.md`](js/README.md). - Ok(json!({ "id": input.id })) +### 09 · SvelteKit + +SSR first, then live — one query. + +`@load` and `@live` use the same GraphQL operation for server render, +rehydrate, and a push change feed. Users get a fast first paint and rooms +that stay current without a second subscription document or polling. + +[`tests/e2e-ui/ui/src/routes/chat/+page.graphql`](tests/e2e-ui/ui/src/routes/chat/+page.graphql) + +```graphql +# Same query powers SSR (@load) and live change feed (@live) +query ChatMessages($limit: Int!, $offset: Int!) @load @live { + chat_messages( + where: { room_id: { _eq: "lobby" } } + limit: $limit + offset: $offset + order_by: [{ created_at: desc }] + ) { + message_id + body + author { display_name } + } } ``` -### 4. Serve it +### 10 · OIDC -Build typed route bundles with `Routes::new()`, register handler modules with -`routes!`, then collect those bundles into a deployment-level `Service`. Expose -the exact same service over direct dispatch, HTTP, gRPC, or the bus. Handlers -are written once and are transport-agnostic. +Who the user is — in the model and the UI. -```rust,ignore -use std::sync::Arc; -use distributed::microsvc::{self, Routes, Service, Session}; -use distributed::bus::{InMemoryBus, RunOptions}; -use distributed::{AggregateBuilder, InMemoryRepository, Queueable}; -use serde_json::json; +Real products need real identity. OIDC is first-class (Zitadel in the +playground; Keycloak and Authentik in tests). Sessions and JWTs become +claims the domain already uses for ownership and roles — the same claims +that scope the client replica. -#[tokio::main] -async fn main() -> Result<(), Box> { - let routes = distributed::routes!( - Routes::new().with_repo( - InMemoryRepository::new().queued().aggregate::() - ), - command handlers::todo_create, - command handlers::todo_complete, - ); - let service = Service::new().routes(routes); +- **Claims → RBAC.** Row filters and command handlers share claims like + `x-user-id` and roles. +- **Surfaces.** User, admin, and public clients stay separate so elevated + power does not leak. - // Attach a bus and run. `with_bus` closes the loop from step 3: that - // `outbox(..).commit(..)` now publishes on commit, and `run` consumes the - // registered commands (and events). Same handlers, one line of wiring. - service - .with_bus(InMemoryBus::new()) - .run(RunOptions::idempotent()) - .await?; +--- - // Alternatives that share the same handlers: - // service.dispatch("todo.initialize", json!({ "id": "todo-1", .. }), Session::new()).await?; // in-process - // microsvc::serve(Arc::new(service), "0.0.0.0:3000").await?; // HTTP (feature = "http") - // microsvc::serve_grpc(Arc::new(service), "[::1]:50051").await?; // gRPC (feature = "grpc") +## See it run - Ok(()) -} -``` +### e2e-ui playground (`tests/e2e-ui`) -### 5. Swap persistence and transports +A copyable multi-crate product: pure domains, GraphQL-only edge, Zitadel +OIDC, SvelteKit SSR, generated clients, live WS. Full runbook: +**[`tests/e2e-ui/README.md`](tests/e2e-ui/README.md)**. -Everything above is in-memory. Moving to production is a **constructor change**, not -a handler change — every infrastructure concern is an async trait with an in-memory -default you replace with a durable adapter. +```bash +cd tests/e2e-ui +make up # Postgres + Zitadel → e2e-ui.env +source e2e-ui.env && make run +# UI http://localhost:5180 +# API http://127.0.0.1:8791 +``` -```rust,ignore -// Persistence: InMemoryRepository → durable SQL (features "postgres" / "sqlite") -let repo = distributed::PostgresRepository::connect_and_migrate(database_url).await?; -let routes = distributed::routes!( - Routes::new().with_repo(repo.queued().aggregate::()), - command handlers::todo_create, - command handlers::todo_complete, -); -let service = Service::new().named("todo-api").routes(routes); +Demo logins after `make up`: `alice` / `bob` / `admin` · `Password1!`. -// Transport: InMemoryBus → a real broker. The handlers and the -// `with_bus(..).run(..)` wiring are unchanged; only this constructor line differs. -let namespace = "todos-prod"; // broker namespace/prefix for this app/environment -// let bus = NatsBus::connect("nats://localhost:4222").namespace(namespace).await?; -// let bus = PostgresBus::new(pool); -// let bus = SqliteBus::new(pool); -// let bus = RabbitBus::connect("amqp://localhost:5672/%2f").namespace(namespace).await?; -// let bus = KafkaBus::connect("localhost:9092").namespace(namespace).await?; -service.with_bus(bus).run(RunOptions::idempotent()).await?; -``` - -`group` and `namespace` are broker topology names, not the command/event names -your service handles. `routes!` gives each route bundle its command/event names; -`Service::routes(..)` aggregates them, and `with_bus(bus).run(..)` reads those -names through `subscription_plan()` and passes them to the transport. - -- `Service::named("todo-api")` supplies the default durable consumer `group`. - Use the same service name for every replica of one deployment. For direct - `bus.listen(..)` / `bus.subscribe(..)` consumers that are not a `Service`, set - the group with `bus.group("todo-projections")`. -- `namespace` scopes streams, subjects, topics, queues, or exchanges on a shared - broker. `PostgresBus` and `SqliteBus` do not take `namespace` because the - database/schema/file behind `pool` already scopes their bus tables. -- Topology names are validated before broker use. Keep groups/service names to - portable deployment IDs (`A-Z`, `a-z`, `0-9`, `_`, `-`); namespaces may also - use `.`. Blank names, whitespace, control characters, path separators, broker - wildcards, and names longer than 128 bytes are rejected. - -| Concern | In-memory default | Swap in for production | +Small apps, full patterns. Each screen has **How it is built**: query, +then command, then handler, then domain, then events, then service and +host. + +| Demo | Tag | What it shows | |---|---|---| -| Storage | `InMemoryRepository` | `PostgresRepository`, `SqliteRepository` | -| Messaging | `InMemoryBus` | `NatsBus`, `PostgresBus`, `SqliteBus`, `RabbitBus`, `KafkaBus`, `KnativeBus` | -| Locking | `InMemoryLockManager` | `PostgresLockManager`, `SqliteLockManager` (durable leases), any `LockManager` (Redis, …) | +| [`/chat`](tests/e2e-ui/ui/src/routes/chat) | Live + anonymous | Shared room with SSR, live updates, guest reads | +| [`/todos`](tests/e2e-ui/ui/src/routes/todos) | Eventual | Ownership rules, optimistic commands, projector fill | +| [`/blob`](tests/e2e-ui/ui/src/routes/blob) | Atomic | Game moves with an atomic board in the response | +| [`/admin`](tests/e2e-ui/ui/src/routes/admin) | Surface | Elevated surface — separate client, more power | +| [`/session`](tests/e2e-ui/ui/src/routes/session) | OIDC | Who you are: tokens, groups, roles | + +Start here in the code: + +| File | Why it is nice | +|---|---| +| [`ui/src/routes/todos/+page.graphql`](tests/e2e-ui/ui/src/routes/todos/+page.graphql) | Co-located read. `@load` → SSR seed; no hand-written load function for the list. | +| [`ui/src/routes/todos/+page.svelte`](tests/e2e-ui/ui/src/routes/todos/+page.svelte) | `Todos.use()` + `useCommands()` — page never invents a cache or optimistic recipe. | +| [`ui/src/routes/chat/+page.graphql`](tests/e2e-ui/ui/src/routes/chat/+page.graphql) | Same document does SSR **and** live: `@load @live`. | +| [`ui/src/routes/blob/[[gameId]]/+page.svelte`](tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte) | Arrow keys → `commands.blob.move`; board from `BlobGames.use()`. | +| [`crates/service/src/modules/compose.rs`](tests/e2e-ui/crates/service/src/modules/compose.rs) | One `Service` lists modules. No `Runtime::role`. | +| [`crates/service/src/handlers/commands/blob_move.rs`](tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs) | `PreparedCommand>` — map/score written with the event. | +| [`crates/todo-domain/src/models/todo.rs`](tests/e2e-ui/crates/todo-domain/src/models/todo.rs) | Plain aggregate — no GraphQL in the domain. | +| [`ui/src/auth.ts`](tests/e2e-ui/ui/src/auth.ts) | Auth.js + Zitadel scopes/groups → engine roles. | + +### GraphiQL playground (engine only) + +```bash +cargo run --example graphiql --features "graphql,sqlite" +# → http://127.0.0.1:4000/graphql +``` + +### First-class OIDC (Zitadel, Keycloak, Authentik) + +GraphQL identity is built into the engine (`OidcBearer`: JWKS, iss/aud/exp, +claim → role/session). Live against three local IdPs — not mocks only: + +| Provider | Compose + bootstrap | Live test | Gate | +|---|---|---|---| +| **[Zitadel](tests/graphql_oidc_zitadel/)** (reference) | `./scripts/oidc-zitadel-up.sh` | `cargo test --test graphql_oidc_zitadel --features graphql,sqlite` | `ZITADEL_E2E=1` | +| **[Keycloak](tests/graphql_oidc_keycloak/)** | `./scripts/oidc-keycloak-up.sh` | `cargo test --test graphql_oidc_keycloak --features graphql,sqlite` | `KEYCLOAK_E2E=1` | +| **[Authentik](tests/graphql_oidc_authentik/)** | `./scripts/oidc-authentik-up.sh` | `cargo test --test graphql_oidc_authentik --features graphql,sqlite` | `AUTHENTIK_E2E=1` | + +Shared **E1–E8** in [`tests/graphql_oidc_common/`](tests/graphql_oidc_common/). +Gated binaries skip cleanly when unset. Offline: +`cargo test --test graphql_identity --features graphql,sqlite`. + +e2e-ui boots **Zitadel** for the browser path; the three stacks prove the +same `OidcBearer` edge is not vendor-locked. + +--- + +## Use as a dependency -The rest of this README is the reference guide for each of these pieces. +Copy the **e2e-ui** layout: domain crates stay feature-light; a **service +crate** lists which modules this process runs. -## Example Conventions +```text +crates/ + todo-domain/ # personal todos (owner-scoped) + chat-domain/ # lobby chat (shared room) + readmodels/ # projections + read_model_catalog + service/ # thin command handlers + event projectors + GraphQL + runner/ # store + bus + bind +``` + +The shared bounded-context crate depends on `distributed` with the empty +default feature set. It needs macros and traits, not HTTP servers, SQL +adapters, or broker clients: + +```toml +# crates/todo-domain/Cargo.toml +[dependencies] +distributed = "0.1" +serde = { version = "1", features = ["derive"] } +``` + +Executable service crates depend on the domain crates and enable the +runtime features they need: -Examples use production-style error propagation. Event methods generated by `#[sourced]` and `#[digest]`, repository calls, and outbox constructors are fallible, so snippets that call them assume a surrounding `async` function that returns a `Result` and use `?` / `.await?`. +```toml +# crates/service/Cargo.toml +[dependencies] +todo-domain = { path = "../todo-domain" } +distributed = { version = "0.1", features = ["postgres", "graphql", "sqlite"] } +``` -Complete runnable examples live under [`tests/`](tests/). Short snippets focus on the API surface and may omit surrounding imports or application-specific types when those are not the point of the example. +For local development against a checkout of this repository, use a path +dependency instead: -## Project Inspiration +```toml +[dependencies] +distributed = { path = "../distributed" } +``` -Distributed is inspired by the original [sourced](https://github.com/mateodelnorte/sourced) Node.js project by Matt Walters and his accompanying [servicebus](https://github.com/mateodelnorte/servicebus) library for distributed messaging. Patrick Lee Scott, a contributor and maintainer of the original JavaScript/TypeScript versions, brought these concepts to Rust and refactored them for the Rust ecosystem. The bus facade (`send`/`listen` + `publish`/`subscribe`, with per-transport `*Bus` types) mirrors the `servicebus` / `rabbitbus` / `kafkabus` / `knativebus` family. +In a multi-crate workspace, put the dependency in the workspace root and +inherit it from member crates. Keep the root dependency feature-light, then +enable service-specific features only in the service crates. -## Design Goals +Most application crates should depend on `distributed` only. The proc +macros (`#[sourced]`, `#[digest]`, `#[derive(ReadModel)]`, +`#[derive(Snapshot)]`) are re-exported from `distributed`; do not add +`distributed_macros` directly unless you are working on the macro crate +itself. The `distributed_cli` crate installs the `distributed` tooling +and is not needed as a runtime dependency unless you are embedding the +CLI in another command such as `hops service`. -- Keep domain objects simple and explicit (Plain Old Rust Structs). -- Make aggregate event records the source of truth for model state. -- Make replay predictable and safe. -- Keep storage and messaging pluggable and testable behind async traits. -- Make the transport a wiring choice, not a handler change. -- Add optional queue-based locking for serialized workflows. -- Expose a deny-by-default GraphQL edge over relational read models (not ad-hoc - handler SQL). -- Ship **first-class OIDC** on that edge (`OidcBearer` + claim mapping), with - optional trusted-proxy modes — not “bring your own JWT middleware.” -- Keep browser apps on one protocol: typed GraphQL queries, live subscriptions, - and causal command mutations with a normalized client replica. -- Prefer generated client artifacts and explicit projection contracts over - hand-written fetch/cache glue. +The rest of this README is the API reference for each piece. +--- ## Feature Flags The in-memory repository and the service bus facade are part of the core crate and @@ -2129,8 +2172,6 @@ Blob game, live chat, GraphiQL. ## License -## License - The Rust workspace metadata declares its crates as MIT licensed, but this repository does not currently contain a top-level license file. The npm package therefore remains `UNLICENSED` until maintainers explicitly choose and add its From 5f30db4d07987e5fc0ec04b37573e6a3e20797ab Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 17 Aug 2026 00:15:13 -0500 Subject: [PATCH 2/5] chore: Improve formatting of Todos query in README Format the Todos query for better readability. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d5d89436..656720f7 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,13 @@ commands.todo.create({ title }) commands.todo.archive({ todo_id }) // Queries → SQL-shaped read models (never write tables) -// query Todos @load { todos { todo_id title status } } +query Todos @load { + todos { + todo_id + title + status + } +} ``` ### 03 · Event-sourced aggregates From 5d4bd85914690bda94768227fa44b311af17a0f9 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 17 Aug 2026 00:28:13 -0500 Subject: [PATCH 3/5] docs: say generated GraphQL, replica cache, and WASM pures out loud Home page and README now state that Rust definitions generate the GraphQL schema and typed client; auto-optimism applies the projection mutation to a client replica cache (same program as the SQL projector); and advanced cases ship the domain pure as WASM for the generated host. Implements [[tasks/service-authoring-1]] --- README.md | 83 ++++++++++++++-------- tests/e2e-ui/ui/src/routes/+page.svelte | 93 +++++++++++++++++-------- 2 files changed, 119 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 656720f7..87197a4d 100644 --- a/README.md +++ b/README.md @@ -37,20 +37,22 @@ read models for screens. **Event sourcing** records what happened as history you can unit-test. Identity is OIDC and RBAC on the same claims — not a one-off check per endpoint. -**Compiler-owned frontend.** SSR, the same query rehydrated, then a live -feed. GraphQL selects fields; writes stay **commands**. A **replica** holds -the authorized slice. Optimism uses the same mutation program as the -projector — not a `setState` recipe per page. +**Compiler-owned frontend.** You write Rust models, commands, and +projections. The **GraphQL schema**, filters, typed operations, and command +stubs are **generated** from those definitions — no resolvers, no +hand-written query API. Pages only select fields. Writes stay **commands**. -**Why Rust.** Memory safety without GC pauses, concurrency the type system -can check, and macros so the domain API stays short. The same definitions -compile into the client. Substrate, not fashion. +**Replica cache + one effect.** A client **replica** is a cache of the +authorized slice. Auto-optimism applies the **projection mutation** to that +cache — the same program the server projector runs against SQL. When the +next row needs a known-record calculation, ship the domain **pure as WASM**; +the generated client hosts it. **Same blocks, few or many processes.** Domain, modules, and projections are packages — not a deploy shape. A **service crate** lists the modules this process runs. Today the playground is one host. Later you write another `Service` from the same modules. Eventual projectors can move; Atomic seals -stay with commands. +stay with commands. The same Rust pures can compile to WASM for the replica. **Distributed** is that path — one system so generation can keep the DX simple. @@ -210,12 +212,16 @@ impl Todos { ### 05 · Inferred query API -Page needs → typed client. +Rust models generate GraphQL. -Once the read model is defined, GraphQL is how the UI selects fields — -transport for queries, not the heart of the product. You declare what a -page needs; you don’t maintain a REST endpoint per screen. Commands stay -domain verbs on the write side. +The read model, permissions, and command contracts in Rust are the source. +Distributed **generates** the GraphQL schema — filters, order, pagination, +joins, RBAC, and command mutations. You do not write resolvers or a REST +endpoint per screen. + +The page file only selects fields against that generated schema. Commands +stay domain verbs on the write side. The typed TypeScript client is +generated from the same inventory. [`tests/e2e-ui/ui/src/routes/todos/+page.graphql`](tests/e2e-ui/ui/src/routes/todos/+page.graphql) @@ -233,16 +239,18 @@ query Todos @load { ### 06 · Projections -When the domain changes, views catch up. +One mutation. Two runtimes. -After a command succeeds, events describe what happened. Projections turn -those facts into read-model updates — and the same mapping drives -optimistic UI in the browser. You declare “on these events, update the -view like this” once; you don’t dual-write tables from the page. +After a command succeeds, events describe what happened. A projection +names the **effect**: on these events, run this mutation program +(`upsert_todos`, `delete_todos_by_pk`). That program is the update — not +a second cache language on the page. -The mutation file looks like GraphQL but is **internal IR** for that -update program, not a public client mutation API. Field names are -snake_case table names (`upsert_todos`). Pages still send domain commands. +The same mutation runs in two places: the **server projector** writes the +SQL read model; the **client replica** applies it to the cache for +auto-optimism. The mutation file looks like GraphQL but is **internal IR**, +not a public client field. Field names are snake_case table names +(`upsert_todos`). Pages still send domain commands. [`tests/e2e-ui/crates/projections/src/todos.rs`](tests/e2e-ui/crates/projections/src/todos.rs) @@ -339,15 +347,21 @@ intentional non-GraphQL ingress. ### 08 · Browser replica -The cycle closes at the client. +Auto-optimism is a cache update. + +The generated client is a **replica cache** of the authorized read-model +slice, plus typed commands. The page reads `query.use()` and calls +`commands.todo…`. It does not patch arrays or write `setState` recipes. -Generated TypeScript carries your inventory into a client replica and typed -commands. The page reads `query.use()` and calls `commands.todo…` — same -business verbs as the server. Optimism applies the projection mapping on -the way around the loop, so the UI stays aligned with the model instead of -a one-off cache recipe per screen. +When a command fires, the replica applies the **same projection mutation** +to the cache immediately. The server later writes SQL with that program; +live/causal confirmation reconciles. Most rows are input + defaults + +claims. When the next row needs the known record (blob’s next board), +ship the domain **pure function as WASM**. Gen-client hosts it. Do not +write a TypeScript twin. [`tests/e2e-ui/ui/src/routes/todos/+page.svelte`](tests/e2e-ui/ui/src/routes/todos/+page.svelte) +· [`tests/e2e-ui/crates/service/src/modules/blob.rs`](tests/e2e-ui/crates/service/src/modules/blob.rs) ```ts // Generated operation + typed commands — no cache recipes in the page @@ -359,6 +373,19 @@ const todos = $derived($query.complete ? $query.data.todos : []); await commands.todo.create({ title: text }); await commands.todo.complete({ todo_id }); +// Replica applies SaveTodo (upsert_todos) to the cache. Page does not. +``` + +```rust,ignore +// Advanced optimism: same domain pure, shipped as WASM +.preview_reduce_known_record(CommandProjectionPureReduce::wasm( + "blob.simulate_move", + "blob/pkg/blob_wasm", // wasm-pack under $lib + "blobSimulateMove", // (recordJson, argsJson) → assignJson + "BlobGames", +)) + +// Generated client hosts the module. No TypeScript board rules. ``` JS package deep-dive: [`js/README.md`](js/README.md). @@ -431,7 +458,7 @@ host. |---|---|---| | [`/chat`](tests/e2e-ui/ui/src/routes/chat) | Live + anonymous | Shared room with SSR, live updates, guest reads | | [`/todos`](tests/e2e-ui/ui/src/routes/todos) | Eventual | Ownership rules, optimistic commands, projector fill | -| [`/blob`](tests/e2e-ui/ui/src/routes/blob) | Atomic | Game moves with an atomic board in the response | +| [`/blob`](tests/e2e-ui/ui/src/routes/blob) | Atomic + WASM | Atomic board in the response. Same domain pure runs as WASM in the replica | | [`/admin`](tests/e2e-ui/ui/src/routes/admin) | Surface | Elevated surface — separate client, more power | | [`/session`](tests/e2e-ui/ui/src/routes/session) | OIDC | Who you are: tokens, groups, roles | diff --git a/tests/e2e-ui/ui/src/routes/+page.svelte b/tests/e2e-ui/ui/src/routes/+page.svelte index 34fb66b7..15696157 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -32,7 +32,7 @@ const demos = [ { href: '/chat', title: 'Lobby chat', tag: 'Live + anonymous', blurb: 'A shared room with SSR, live updates, and guest reads.' }, { href: '/todos', title: 'Todos', tag: 'Eventual', blurb: 'Ownership rules, optimistic commands, projector fill.' }, - { href: '/blob', tag: 'Atomic', title: 'Blob game', blurb: 'Game moves with an atomic board in the response.' }, + { href: '/blob', tag: 'Atomic + WASM', title: 'Blob game', blurb: 'Atomic board in the response. Same domain pure runs as WASM in the replica.' }, { href: '/admin', title: 'Admin', tag: 'Surface', blurb: 'Elevated surface — separate client, more power.' }, { href: '/session', title: 'Session', tag: 'OIDC', blurb: 'Who you are to the app: tokens, groups, roles.' } ]; @@ -156,7 +156,18 @@ const commands = useCommands(); const todos = $derived($query.complete ? $query.data.todos : []); await commands.todo.create({ title: text }); -await commands.todo.complete({ todo_id });`; +await commands.todo.complete({ todo_id }); +// Replica applies SaveTodo (upsert_todos) to the cache. Page does not.`; + + const codeWasm = `// Advanced optimism: same domain pure, shipped as WASM +.preview_reduce_known_record(CommandProjectionPureReduce::wasm( + "blob.simulate_move", + "blob/pkg/blob_wasm", // wasm-pack under $lib + "blobSimulateMove", // (recordJson, argsJson) → assignJson + "BlobGames", +)) + +// Generated client hosts the module. No TypeScript board rules.`; const codeLive = `# Same query powers SSR (@load) and live change feed (@live) query ChatMessages($limit: Int!, $offset: Int!) @load @live { @@ -292,18 +303,19 @@ Service::new()

Compiler-owned frontend

- SSR, the same query rehydrated, then a live feed. GraphQL selects fields; writes stay - commands. A replica holds the authorized slice. Optimism - uses the same mutation program as the projector — not a setState recipe per - page. + You write Rust models, commands, and projections. The GraphQL schema, + filters, typed operations, and command stubs are generated from those + definitions — no resolvers, no hand-written query API. Pages only select fields. + Writes stay commands.

-

Why Rust

+

Replica cache + one effect

- Memory safety without GC pauses, concurrency the type system can check, and macros so - the domain API stays short. The same definitions compile into the client. Substrate, not - fashion. + A client replica is a cache of the authorized slice. Auto-optimism + applies the projection mutation to that cache — the same program the + server projector runs against SQL. When the next row needs a known-record calculation, + ship the domain pure as WASM; the generated client hosts it.

@@ -312,7 +324,8 @@ Service::new() Domain, modules, and projections are packages — not a deploy shape. A service crate lists the modules this process runs. Today this playground is one host. Later you write another Service from the same modules. Eventual projectors - can move; Atomic seals stay with commands. + can move; Atomic seals stay with commands. The same Rust pures can compile to WASM for + the replica.

@@ -576,11 +589,16 @@ Service::new()
05 · Inferred query API -

Page needs → typed client

+

Rust models generate GraphQL

+

+ The read model, permissions, and command contracts in Rust are the source. Distributed + generates the GraphQL schema — filters, order, pagination, joins, + RBAC, and command mutations. You do not write resolvers or a REST endpoint per screen. +

- Once the read model is defined, GraphQL is how the UI selects fields — transport for - queries, not the heart of the product. You declare what a page needs; you don’t maintain - a REST endpoint per screen. Commands stay domain verbs on the write side. + The page file only selects fields against that generated schema. Commands stay domain + verbs on the write side. The typed TypeScript client is generated from the same + inventory.

tests/e2e-ui/ui/src/routes/todos/+page.graphql
@@ -602,16 +620,18 @@ Service::new()
06 · Projections -

When the domain changes, views catch up

+

One mutation. Two runtimes.

- Next step in the unidirectional cycle: after a command succeeds, events describe what - happened. Projections turn those facts into read-model updates — and the same mapping - drives optimistic UI in the browser. You declare “on these events, update the view like - this” once; you don’t dual-write tables from the page or invent a second cache language. + After a command succeeds, events describe what happened. A projection names the + effect: on these events, run this mutation program + (upsert_todos, delete_todos_by_pk). That program is the + update — not a second cache language on the page.

- The mutation file looks like GraphQL but is internal IR for that update program, not a - public client mutation API. Pages still send domain commands. + The same mutation runs in two places: the server projector writes the + SQL read model; the client replica applies it to the cache for + auto-optimism. The mutation file looks like GraphQL but is internal IR, not a public + client field. Pages still send domain commands.

tests/e2e-ui/crates/projections/src/todos.rs · mutations/save_todo.mutation.graphql
08 · Browser replica -

The cycle closes at the client

+

Auto-optimism is a cache update

+

+ The generated client is a replica cache of the authorized read-model + slice, plus typed commands. The page reads query.use() and calls + commands.todo…. It does not patch arrays or write + setState recipes. +

- Generated TypeScript carries your inventory into a client replica and typed commands. - The page reads query.use() and calls commands.todo… — same - business verbs as the server. Optimism applies the projection mapping on the way around - the loop, so the UI stays aligned with the model instead of a one-off cache recipe per - screen. + When a command fires, the replica applies the same projection mutation + to the cache immediately. The server later writes SQL with that program; live/causal + confirmation reconciles. Most rows are input + defaults + claims. When the next row + needs the known record (blob’s next board), ship the domain pure function as + WASM. Gen-client hosts it. Do not write a TypeScript twin.

- tests/e2e-ui/ui/src/routes/todos/+page.svelte + tests/e2e-ui/ui/src/routes/todos/+page.svelte · crates/service/src/modules/blob.rs
@@ -700,6 +728,13 @@ Service::new()
{@html highlightCode(codeUi)}
+
+
+ blob.rs + wasm pure +
+
{@html highlightCode(codeWasm)}
+
From 6bfd5b0581ea2d2285c8778f9ba6afc83e708feb Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 17 Aug 2026 00:29:32 -0500 Subject: [PATCH 4/5] docs: say it is an end-to-end framework and a toolkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground is the full path. The same crates stay usable a la carte — aggregates only, bus only, GraphQL without the replica. Drop the old "not a toolkit" line that fought that adoption model. Implements [[tasks/service-authoring-1]] --- README.md | 33 ++++++++++++++++--------- tests/e2e-ui/ui/src/routes/+page.svelte | 21 +++++++++++----- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 87197a4d..97486cde 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,13 @@ **Distributed** is a state-of-the-art framework for building distributed systems and realtime applications. -Not a partial toolkit. An end-to-end cloud native stack — domain, service, -query edge, live client, and even GitOps — so engineers who care about quality -code can stay on the model and still ship polished, fast, maintainable -products. +An end-to-end cloud native stack — domain, service, query edge, live client, +and even GitOps — so engineers who care about quality code can stay on the +model and still ship polished, fast, maintainable products. + +It is also a toolkit of distributed-systems tools. You do not need the whole +path. Event-source aggregates and stop there. Use the service bus alone. +Take GraphQL reads without the replica. Adopt what you need. Rust · TypeScript · CQRS / ES · SvelteKit @@ -15,7 +18,7 @@ todos, blob, admin) with a **How it is built** panel on every screen. This README is the same story, in the repo. - [The bar](#the-bar) — what “state of the art” means here -- [Backstory](#backstory) — why this is one system, not a kit of parts +- [Backstory](#backstory) — why the full path is one system, and the pieces still stand alone - [How it delivers](#how-it-delivers) — the unidirectional loop, with real code - [See it run](#see-it-run) — playground, GraphiQL, live OIDC - [Use as a dependency](#use-as-a-dependency) — workspace layout and features @@ -28,8 +31,9 @@ README is the same story, in the repo. You never get perfect consistency, always-available writes, and partition tolerance at once (**CAP**). Products that stay up accept **eventual consistency** on reads — with clear rules about what the user can trust now. -The bar is not a kit of excellent parts. It is one path from domain event to -optimistic row. +The bar for the full product is not a glue job of excellent parts. It is +one path from domain event to optimistic row. You can still take one part +and ignore the rest. **Event-driven backend.** Command in, domain event out, projections update reads. The UI does not patch tables. **CQRS** keeps aggregates for rules and @@ -54,8 +58,10 @@ process runs. Today the playground is one host. Later you write another `Service` from the same modules. Eventual projectors can move; Atomic seals stay with commands. The same Rust pures can compile to WASM for the replica. -**Distributed** is that path — one system so generation can keep the DX -simple. +**Distributed** is that path when you want the whole product — one system +so generation can keep the DX simple. The same crates stay usable as tools: +aggregates, bus, outbox, locks, GraphQL, replica. Feature flags keep unused +pieces out of the binary. --- @@ -504,8 +510,13 @@ same `OidcBearer` edge is not vendor-locked. ## Use as a dependency -Copy the **e2e-ui** layout: domain crates stay feature-light; a **service -crate** lists which modules this process runs. +Adopt the whole path, or one crate feature. `#[sourced]` aggregates, the +bus, GraphQL, and the replica are independent. This playground uses all of +them. Your crate does not have to. + +Copy the **e2e-ui** layout when you want the full product: domain crates +stay feature-light; a **service crate** lists which modules this process +runs. ```text crates/ diff --git a/tests/e2e-ui/ui/src/routes/+page.svelte b/tests/e2e-ui/ui/src/routes/+page.svelte index 15696157..27cdf006 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -228,10 +228,15 @@ Service::new() for building distributed systems, and realtime applications.

- Not a partial toolkit. An end-to-end cloud native stack — domain, service, query edge, live client, and even gitops — + An end-to-end cloud native stack — domain, service, query edge, live client, and even gitops — so engineers who care about quality code can stay on the model and still ship polished, fast, maintainable products.

+

+ It is also a toolkit of distributed-systems tools. You do not need the whole path. + Event-source aggregates and stop there. Use the service bus alone. Take GraphQL reads + without the replica. Adopt what you need. +

  • Rust
  • @@ -285,8 +290,9 @@ Service::new()

    You never get perfect consistency, always-available writes, and partition tolerance at once (CAP). Products that stay up accept eventual consistency - on reads — with clear rules about what the user can trust now. The bar is not a kit of - excellent parts. It is one path from domain event to optimistic row. + on reads — with clear rules about what the user can trust now. The bar for the full + product is not a glue job of excellent parts. It is one path from domain event to + optimistic row. You can still take one part and ignore the rest.

    @@ -330,7 +336,9 @@ Service::new()

    - Distributed is that path — one system so generation can keep the DX simple. + Distributed is that path when you want the whole product — one system so + generation can keep the DX simple. The same crates stay usable as tools: aggregates, + bus, outbox, locks, GraphQL, replica. Feature flags keep unused pieces out of the binary.

    How this project got here @@ -851,8 +859,9 @@ source e2e-ui.env && make run

    Model the domain. Leave the rest to the stack.

    Pick a demo close to your problem — todos for ownership and rules, chat for live rooms — - and reuse the shapes. The vehicle is one framework, built to scale; you stay on the parts - that create customer value. Fleet hosting (ops.com.ai) is on the roadmap. + and reuse the shapes. The vehicle is one framework when you want the full path. It is + also a toolkit: start with aggregates, or the bus, and leave the rest. Fleet hosting + (ops.com.ai) is on the roadmap.

    From 048c4f567b37eb9daa537fbb0901d59b1710400c Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 17 Aug 2026 00:38:58 -0500 Subject: [PATCH 5/5] docs: fix README snippet languages and compose accuracy Split the CQRS example into ts and graphql fences. Label the todos projection as abbreviated and add epoch plus the crate-root macro. Show the real routes(...) inputs from compose.rs. Implements [[tasks/service-authoring-1]] --- README.md | 21 +++++++++++++-------- tests/e2e-ui/ui/src/routes/+page.svelte | 16 +++++++++------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 97486cde..89baf5a1 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,14 @@ open todos” is a question about a list. Commands load aggregates; queries hit a SQL-shaped read model. You avoid forcing both into “update a row,” so domain code stays about rules and screens stay about presentation. -```rust,ignore +```ts // Commands → aggregates (accept / reject business rules) commands.todo.create({ title }) commands.todo.archive({ todo_id }) +``` -// Queries → SQL-shaped read models (never write tables) +```graphql +# Queries → SQL-shaped read models (never write tables) query Todos @load { todos { todo_id @@ -261,17 +263,19 @@ not a public client field. Field names are snake_case table names [`tests/e2e-ui/crates/projections/src/todos.rs`](tests/e2e-ui/crates/projections/src/todos.rs) ```rust,ignore -// Event → mutation mapping (server projector + client optimism) -projection! { +// Abbreviated from todos.rs — event → mutation (server projector + client optimism) +distributed::projection! { pub const TODOS: ProjectionDescriptor = { name: "project_todos", version: 1, + epoch: "e2e-ui-todos-v2", model: Todos, on { events: [ TodoCreatedDomainEvent, TodoCompletedDomainEvent, TodoArchivedDomainEvent, + // … rename, reopen, reassign, force-archive ], mutation: SaveTodo, input: { todo: body }, @@ -332,16 +336,17 @@ split. Topology is explicit composition — not a hidden matrix. [`tests/e2e-ui/crates/service/src/modules/compose.rs`](tests/e2e-ui/crates/service/src/modules/compose.rs) ```rust,ignore -// Same domain + module crates. This Service lists what this process runs. +// compose.rs (trimmed). Each routes(...) takes repo, locks, read models, +// and the projection owner for that module. pub const MODULE_IDS: &[&str] = &[ todo::MODULE_ID, chat::MODULE_ID, blob::MODULE_ID, "identity", ]; Service::new() .named("e2e-ui") - .routes(todo::routes(/* commands + Eventual projector */)) - .routes(chat::routes(/* … */)) - .routes(blob::routes(/* Atomic — stays with commands */)) + .routes(todo::routes(repo.clone(), locks.clone(), read_models.clone(), projections.todo)) + .routes(chat::routes(repo.clone(), locks.clone(), read_models.clone(), projections.chat)) + .routes(blob::routes(repo, locks, read_models, projections.blob)) // Another crate can list the same modules, or only Eventual projectors. // You write that Service. You do not flip a Runtime::role flag. diff --git a/tests/e2e-ui/ui/src/routes/+page.svelte b/tests/e2e-ui/ui/src/routes/+page.svelte index 27cdf006..56b62cb8 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -89,18 +89,19 @@ impl Todos { } }`; - const codeProjection = `// Event → mutation mapping (server projector + client optimism) -projection! { + const codeProjection = `// Abbreviated from todos.rs — event → mutation +distributed::projection! { pub const TODOS: ProjectionDescriptor = { name: "project_todos", version: 1, + epoch: "e2e-ui-todos-v2", model: Todos, on { events: [ TodoCreatedDomainEvent, TodoCompletedDomainEvent, TodoArchivedDomainEvent, - // … + // … rename, reopen, reassign, force-archive ], mutation: SaveTodo, input: { todo: body }, @@ -192,16 +193,17 @@ query Todos @load { todos { todo_id title status } }`; - const codeService = `// Same domain + module crates. This Service lists what this process runs. + const codeService = `// compose.rs (trimmed). Each routes(...) takes repo, locks, +// read models, and the projection owner for that module. pub const MODULE_IDS: &[&str] = &[ todo::MODULE_ID, chat::MODULE_ID, blob::MODULE_ID, "identity", ]; Service::new() .named("e2e-ui") - .routes(todo::routes(/* commands + Eventual projector */)) - .routes(chat::routes(/* … */)) - .routes(blob::routes(/* Atomic — stays with commands */)) + .routes(todo::routes(repo.clone(), locks.clone(), read_models.clone(), projections.todo)) + .routes(chat::routes(repo.clone(), locks.clone(), read_models.clone(), projections.chat)) + .routes(blob::routes(repo, locks, read_models, projections.blob)) // Another crate can list the same modules, or only Eventual projectors. // You write that Service. You do not flip a Runtime::role flag.`;