Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ stack of deliberate files. Start here:
| [`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<Projected<BlobGameView>>` — map/score written with the event, not dual-written later. |
| [`crates/service/src/handlers/commands/blob_move.rs`](tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs) | `PreparedCommand<Atomic<BlobGameView>>` — 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. |
Expand All @@ -47,7 +47,7 @@ stack of deliberate files. Start here:
SvelteKit ──GraphQL HTTP/WS──► Rust edge (GraphqlEngine + microsvc)
│ │
│ @hops-ops/distributed ├── mutations → aggregates
│ causal replica + commands ├── Projected rows (blob) with events
│ causal replica + commands ├── Atomic rows (blob) with events
│ dctl-generated ops └── projector rows (todos, chat)
```

Expand Down Expand Up @@ -1523,9 +1523,9 @@ let loaded = repo

Auto-generated GraphQL over relational read models — Hasura-style filtering,
ordering, pagination, relationships, role-based column allowlists and row
filters, live subscriptions after write-plan commits, and typed **causal command
mutations** derived from the executable `Service` (including atomic
`Projected<T>` and eventual `Fact` + projector paths).
filters, live subscriptions after write-plan commits, and typed command
mutations derived from the executable `Service` (including `Atomic<T>` and
`Eventual<T>` + projector paths).

This is the public query/command edge for full-stack apps. The companion
TypeScript package [`@hops-ops/distributed`](js/) (see
Expand Down Expand Up @@ -1563,22 +1563,22 @@ distributed = { version = "0.1", features = ["graphql", "postgres"] }

```rust,ignore
use distributed::graphql::{
claim, col, read, typed_command, Causal, GraphqlEngine,
claim, col, read, typed_command, Eventual, GraphqlEngine,
};
use distributed::microsvc::{Routes, Service};

let routes = Routes::new()
.with_repo(repository.clone().aggregate::<Todo>())
.typed_command(
typed_command::<CreateTodoInput, Causal<TodoStatusPayload>>("todo.create")
typed_command::<CreateTodoInput, Eventual<TodoStatusPayload>>("todo.create")
.field_name("todos_create")
.roles(["user", "admin"])
.emits(distributed::events![TodoCreatedDomainEvent])
.preview(/* state_preview! binding for optimism */),
.applies(/* state_preview! binding for optimism */),
)
.handle(create_todo)
.typed_command(
typed_command::<ForceArchiveInput, Causal<TodoStatusPayload>>("todo.force_archive")
typed_command::<ForceArchiveInput, Eventual<TodoStatusPayload>>("todo.force_archive")
.field_name("todos_force_archive")
.roles(["admin"])
.emits(distributed::events![TodoArchivedDomainEvent]),
Expand Down Expand Up @@ -1732,12 +1732,14 @@ flow.

Copyable product shape (not a toy workshop): multi-crate domains, GraphQL-only
edge, real OIDC, SSR, live subscriptions, and a teaching **Blob** aggregate that
uses atomic `Projected<BlobGameView>` (direct-only — no async blob projector).
uses `Atomic<BlobGames>` (direct placement: same mutation IR as
eventual, applied in the command handler so the response can carry the row —
no async blob event handler).

| Piece | Role |
|---|---|
| Domain crates | Pure aggregates: todos, chat, blob |
| Read models | Projector-owned rows *and* direct `Projected` rows (blob) — no dual-write from handlers |
| Read models | Eventual projector rows (todos/chat) *and* handler-owned `Atomic` rows (blob) — one mutation IR, different apply site |
| GraphQL edge | Owner RLS, admin surfaces, joins to `auth_users`, chat live sub, blob commands |
| Identity | Zitadel + Auth.js (PKCE), optional Zitadel user-scrape → `auth_users` |
| SvelteKit | `$distributed` / `$distributed/admin`, SSR from co-located `+page.graphql`, hydration, generated live ops + optimistic commands |
Expand Down Expand Up @@ -1778,7 +1780,7 @@ See [`js/README.md`](js/README.md) for package API and packaging.
| `tests/graphql_*` | Engine, HTTP, SDL, dialects, harden (authz/DoS/inject), causal transport |
| `tests/graphql_identity` | Always-on OIDC/JWT matrix (mock JWKS; no Docker) |
| `tests/graphql_oidc_{zitadel,keycloak,authentik}` | **Live** multi-IdP e2e (compose + real JWKS; gated) |
| `tests/typed_commands` | Causal command / Projected / Fact registration |
| `tests/typed_commands` | Eventual / Atomic / Succeeded command registration |
| `tests/e2e-ui` | Multi-crate product template + SvelteKit + Zitadel UI login + Playwright |
| `js/tests` | Replica, command runtime, adapters |
| `examples/graphiql.rs` | Seeded local playground |
Expand Down Expand Up @@ -2117,7 +2119,7 @@ Blob game, live chat, GraphiQL.
| [`js/`](js/) | `@hops-ops/distributed` — transport, causal replica, SvelteKit/React |
| [`examples/graphiql.rs`](examples/graphiql.rs) | Seeded GraphQL playground (`--features "graphql,sqlite"`) |
| `tests/graphql_*` | Engine, HTTP/WS, harden, identity, multi-IdP OIDC |
| `tests/typed_commands/` | Causal command / `Projected` / `Fact` registration |
| `tests/typed_commands/` | Eventual / `Atomic` / `Succeeded` command registration |
| `tests/microsvc/` | Handlers on HTTP, gRPC, bus, session |
| `tests/read_models/`, `tests/distributed_read_model/` | Atomic vs eventual projections |
| `tests/sourced*` / `tests/snapshots/` / `tests/upcasting/` | Macros, snapshots, event versioning |
Expand Down
44 changes: 28 additions & 16 deletions distributed_cli/skills/distributed-graphql/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ direct command routes when that transport is intentional and independently
authenticated.

Build the executable `Service` first and pass that exact instance to
`GraphqlEngineBuilder::service`. For `Projected<M>`, pass the repository handle
`GraphqlEngineBuilder::service`. For `Atomic<M>`, pass the repository handle
itself as the GraphQL pool source; a separately cloned raw pool cannot prove the
same transactional storage identity. Configure a stable, nonzero 32-byte
protocol key on every replica serving the same endpoint.
Expand Down Expand Up @@ -120,36 +120,48 @@ Declare each GraphQL mutation on the executable route:
let routes = Routes::new()
.with_repo(repository.aggregate::<Order>())
.typed_command(
typed_command::<CreateOrderInput, Causal<CreateOrderPayload>>("order.create")
typed_command::<CreateOrderInput, Eventual<CreateOrderPayload>>("order.create")
.roles(["user"])
.emits(distributed::events![OrderCreatedDomainEvent])
.preview(/* state_preview! for client optimism */),
.applies(/* state_preview! for client optimism */),
)
.handle(create_order);
```

Handlers accept `CausalCommandContext`, stage aggregate/outbox work on that
context, and return `PreparedCommand<Succeeded<_>>`, `PreparedCommand<Causal<_>>`,
or `PreparedCommand<Projected<M>>`. Never commit outside the framework-owned
context, and return `PreparedCommand<Succeeded<_>>`, `PreparedCommand<Eventual<_>>`,
or `PreparedCommand<Atomic<M>>`. Never commit outside the framework-owned
causal boundary. Projector obligations derive from `.emits` + portable/modeled
handlers (`mutation!`), not separately authored command confirmations/effects.

### Command consistency modes
### Command consistency modes (ship contract)

**Command success does not imply projection visibility.**
**Same portable mutation IR.** Different **response proof** — do not collapse them.

| Contract | Meaning |
|----------|---------|
| `Succeeded<T>` | Command transaction succeeded; no projection visibility is promised. |
| `Causal<T>` | Domain events committed; obligations derive from `.emits` + modeled projectors. |
| `Projected<M>` | Exact read-model row is staged in the same transaction. |
| Contract | Meaning | Mutation response | Client seal |
|----------|---------|-------------------|-------------|
| `Succeeded<T>` | Tx succeeded; no projection promise | Payload only | Revalidate / live |
| `Eventual<T>` | Events committed; Eventual projectors apply later | Payload + **projection-delta** + `expects` | `.applies` → wait obligations |
| `Atomic<M>` | Exact row in **same** command tx | **Typed row `M`** + direct **`records[]`** (no eventual modeled metadata, empty `expects`) | `.applies` optional; **`confirmDirectProjection(row, records)`** before await settles |

Handler for Atomic — this *is* returning atomic read-model updates:

```rust
let row = save_*(...).from_state(...)?;
repo.readmodel(row).publish_events().commit(agg)?.atomic()
```

Rules:

1. Use `Projected<M>` only when the exact row is staged with the command.
2. Use `Causal<T>` with `.emits` (and optional `.preview`) so modeled projectors
can derive finite obligations; do not hand-author command confirmations.
3. Otherwise use `Succeeded<T>`; never invent a projected row.
1. Use `Atomic<M>` only when the exact row is staged in-handler
(`readmodel(row).…commit()?.atomic()`). Server will not attach causal
projection-delta metadata to same-tx commands (by design).
2. Use `Eventual<T>` with `.emits` + `.applies(state_preview! { … })` so eventual
projectors and client previews share one IR.
3. Direct may export portable programs for `.applies` (`is_preview_eligible`);
that is not `is_causally_eligible` (Eventual-only obligations).
4. Do not board-sim Atomic UI — the returned row is authoritative.
5. Otherwise `Succeeded<T>`; never invent a projected row.

Surface IR: SDL is built via `build_surface` → `graphql_sdl_from_surface` (shared inventory
for dialect-honest comparison ops, role grants, typed commands, and generated
Expand Down
17 changes: 13 additions & 4 deletions distributed_cli/skills/distributed-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ shapes. If you find yourself hand-writing service plumbing, routing, broker
topology, or deploy YAML, stop — the framework or CLI almost certainly
generates it, and hand-rolled copies drift.

**Composition direction (logical app vs runtime vs process role):** see
[docs/application-composition.md](../../../docs/application-composition.md).
Same packages re-cut as monolith or microservices; Eventual projectors may
live in another process; Atomic seals stay collocated with the command
handler (CAP). Persistence, locks, bus, and transports pair as one runtime
plane — not open-coded per dialect in `main`.

**Always reach for the highest-level API first.** The macros and one-call
conveniences are the recommended surface, not sugar:

Expand Down Expand Up @@ -156,7 +163,7 @@ Rules that prevent real bugs:
For a browser-facing GraphQL service, use the typed causal command path instead
of exposing a raw `Context`/`serde_json::Value` handler as the mutation contract:

- declare `.typed_command(typed_command::<Input, Succeeded<Payload> | Causal<Payload> | Projected<Model>>(...))`
- declare `.typed_command(typed_command::<Input, Succeeded<Payload> | Eventual<Payload> | Atomic<Model>>(...))`
on the executable route;
- implement the handler with `CausalCommandContext` and return a
`PreparedCommand<_>` so the framework owns commit, ledger, outbox, and
Expand Down Expand Up @@ -289,10 +296,12 @@ Rules the fixture demonstrates:

- **Domain unit tests first** (`cargo test -p todo-domain`) — no repository
- **Owner from session**, never from untrusted create body
- **Projectors only** write read models (commands commit aggregate + outbox)
- **One mutation IR, two apply sites:** Eventual projectors (event handlers)
write todos/chat; Blob stages the same IR in the command handler and seals
`Atomic` so the response can carry the row (impossible on an event handler)
- GraphQL row filter: `owner_id = claim(x-user-id)` for role `user`
- **Typed causal GraphQL commands** run through the OIDC command proxy; generic
direct command POST routes are disabled
- **Typed GraphQL commands** (`Eventual` / `Atomic`) via the OIDC command
proxy; generic direct command POST routes are disabled
- **Generated client**: `dctl client` produces the typed replica/query/command
artifacts consumed by the SvelteKit app
- **Subscriptions**: wire `SqliteRepository::read_model_changes()` into
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ pub(super) fn validate_confirmations(
) -> Result<(), ClientCompileError> {
let confirmations = command.extensions.confirmations.as_ref();
match (command.extensions.consistency.kind, confirmations) {
(ManifestConsistencyKind::Causal, None) => {
(ManifestConsistencyKind::Eventual, None) => {
return Err(command_error(
command,
"client.manifest.command_confirmations",
"causal consistency requires confirmations",
"eventual consistency requires confirmations",
));
}
(ManifestConsistencyKind::Projected, Some(_)) => {
(ManifestConsistencyKind::Atomic, Some(_)) => {
return Err(command_error(
command,
"client.manifest.command_confirmations",
"projected consistency cannot declare asynchronous confirmations",
"atomic consistency cannot declare asynchronous confirmations",
));
}
_ => {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub(super) fn projected_output_typename<'a>(
}
}
let consistency = &command.extensions.consistency;
if consistency.kind != ManifestConsistencyKind::Projected {
if consistency.kind != ManifestConsistencyKind::Atomic {
return None;
}
let ManifestCommandShape::Object { definition } = &command.output else {
Expand Down
4 changes: 2 additions & 2 deletions distributed_cli/src/client_compiler/command_manifest_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ fn command() -> ManifestCommand {
version: 2,
consistency: ManifestCommandConsistency {
version: 1,
kind: ManifestConsistencyKind::Causal,
kind: ManifestConsistencyKind::Eventual,
},
direct_projection: None,
input_defaults: Some(ManifestInputDefaults {
Expand Down Expand Up @@ -336,7 +336,7 @@ fn rejects_empty_outputs_and_ambiguous_command_type_namespaces() {
};
definition.name = "Todo".into();
let error = validate(&non_projected_model_output)
.expect_err("only an exact Projected<T> output may reuse its model object type");
.expect_err("only an exact Atomic<T> output may reuse its model object type");
assert_eq!(error.code, "client.manifest.command_type_namespace");
}

Expand Down
26 changes: 20 additions & 6 deletions distributed_cli/src/client_compiler/manifest/projections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,17 @@ pub(crate) fn validate_projection_manifest(
),
));
}
// Direct may export the same portable program for `.applies` previews.
// Server apply site is still the command handler (Atomic response);
// client never runs this as an async eventual obligation.
if binding.placement == ManifestProjectionPlacement::Direct
&& program_ids.contains(binding.program_id.as_str())
&& binding.execution_class != ManifestProjectionExecutionClass::Causal
{
return Err(projection_error(
"client.manifest.projection_placement",
format!(
"direct projection binding `{}` cannot expose an executable eventual client program",
"direct projection binding `{}` may only expose a program when causal (preview IR)",
binding.binding_id
),
));
Expand All @@ -119,6 +123,19 @@ pub(crate) fn validate_projection_manifest(
Ok((programs, bindings))
}

/// Active causal binding that may contribute client preview composition.
///
/// Eventual: async projector path + local `.applies` previews.
/// Direct: same mutation IR; handler-owned Atomic apply; previews optional.
fn is_preview_eligible_binding(binding: &ManifestProjectionBinding) -> bool {
binding.state == ManifestProjectionBindingState::Active
&& binding.execution_class == ManifestProjectionExecutionClass::Causal
&& matches!(
binding.placement,
ManifestProjectionPlacement::Eventual | ManifestProjectionPlacement::Direct
)
}

pub(crate) fn validate_command_projections(
commands: &[ManifestCommand],
programs: &[ManifestProjectionProgram],
Expand Down Expand Up @@ -231,17 +248,14 @@ pub(crate) fn validate_command_projections(
)
})?;
let eligible = bindings.iter().filter(|binding| {
binding.program_id == selected.program_id
&& binding.state == ManifestProjectionBindingState::Active
&& binding.placement == ManifestProjectionPlacement::Eventual
&& binding.execution_class == ManifestProjectionExecutionClass::Causal
binding.program_id == selected.program_id && is_preview_eligible_binding(binding)
});
if eligible.count() != 1 {
return Err(command_projection_error(
command,
"client.manifest.command_projection_eligibility",
format!(
"selected program `{}` requires exactly one active eventual causal binding",
"selected program `{}` requires exactly one active causal binding (eventual or direct)",
selected.program_id
),
));
Expand Down
4 changes: 2 additions & 2 deletions distributed_cli/src/client_compiler/manifest/projectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub(crate) fn validate_direct_projections(
let consistency = command.extensions.consistency.kind;
let direct = command.extensions.direct_projection.as_ref();
match (consistency, direct) {
(ManifestConsistencyKind::Projected, None) => {
(ManifestConsistencyKind::Atomic, None) => {
return Err(ClientCompileError::manifest(
"client.manifest.direct_projection_required",
format!(
Expand All @@ -110,7 +110,7 @@ pub(crate) fn validate_direct_projections(
),
));
}
(ManifestConsistencyKind::Projected, Some(direct)) => {
(ManifestConsistencyKind::Atomic, Some(direct)) => {
if validate_direct_projection(command, direct, models, projectors)? {
requiring_revalidation.insert(command.name.clone());
}
Expand Down
4 changes: 2 additions & 2 deletions distributed_cli/src/client_compiler/manifest/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,8 @@ pub(crate) struct ManifestProjectionTopologyIdentity {
#[serde(rename_all = "snake_case")]
pub(crate) enum ManifestConsistencyKind {
Succeeded,
Causal,
Projected,
Eventual,
Atomic,
}

#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
Expand Down
Loading
Loading