diff --git a/README.md b/README.md index 5952216e..aafd8034 100644 --- a/README.md +++ b/README.md @@ -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>` — 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>` — 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. | @@ -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) ``` @@ -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` and eventual `Fact` + projector paths). +filters, live subscriptions after write-plan commits, and typed command +mutations derived from the executable `Service` (including `Atomic` and +`Eventual` + projector paths). This is the public query/command edge for full-stack apps. The companion TypeScript package [`@hops-ops/distributed`](js/) (see @@ -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::()) .typed_command( - typed_command::>("todo.create") + typed_command::>("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::>("todo.force_archive") + typed_command::>("todo.force_archive") .field_name("todos_force_archive") .roles(["admin"]) .emits(distributed::events![TodoArchivedDomainEvent]), @@ -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` (direct-only — no async blob projector). +uses `Atomic` (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 | @@ -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 | @@ -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 | diff --git a/distributed_cli/skills/distributed-graphql/SKILL.md b/distributed_cli/skills/distributed-graphql/SKILL.md index 30dd9acc..2e2ee0a4 100644 --- a/distributed_cli/skills/distributed-graphql/SKILL.md +++ b/distributed_cli/skills/distributed-graphql/SKILL.md @@ -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`, pass the repository handle +`GraphqlEngineBuilder::service`. For `Atomic`, 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. @@ -120,36 +120,48 @@ Declare each GraphQL mutation on the executable route: let routes = Routes::new() .with_repo(repository.aggregate::()) .typed_command( - typed_command::>("order.create") + typed_command::>("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>`, `PreparedCommand>`, -or `PreparedCommand>`. Never commit outside the framework-owned +context, and return `PreparedCommand>`, `PreparedCommand>`, +or `PreparedCommand>`. 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` | Command transaction succeeded; no projection visibility is promised. | -| `Causal` | Domain events committed; obligations derive from `.emits` + modeled projectors. | -| `Projected` | Exact read-model row is staged in the same transaction. | +| Contract | Meaning | Mutation response | Client seal | +|----------|---------|-------------------|-------------| +| `Succeeded` | Tx succeeded; no projection promise | Payload only | Revalidate / live | +| `Eventual` | Events committed; Eventual projectors apply later | Payload + **projection-delta** + `expects` | `.applies` → wait obligations | +| `Atomic` | 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` only when the exact row is staged with the command. -2. Use `Causal` with `.emits` (and optional `.preview`) so modeled projectors - can derive finite obligations; do not hand-author command confirmations. -3. Otherwise use `Succeeded`; never invent a projected row. +1. Use `Atomic` 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` 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`; 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 diff --git a/distributed_cli/skills/distributed-usage/SKILL.md b/distributed_cli/skills/distributed-usage/SKILL.md index ebe636a0..90acc169 100644 --- a/distributed_cli/skills/distributed-usage/SKILL.md +++ b/distributed_cli/skills/distributed-usage/SKILL.md @@ -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: @@ -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:: | Causal | Projected>(...))` +- declare `.typed_command(typed_command:: | Eventual | Atomic>(...))` on the executable route; - implement the handler with `CausalCommandContext` and return a `PreparedCommand<_>` so the framework owns commit, ledger, outbox, and @@ -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 diff --git a/distributed_cli/src/client_compiler/command_manifest/confirmations.rs b/distributed_cli/src/client_compiler/command_manifest/confirmations.rs index 75e2093a..4ec19f29 100644 --- a/distributed_cli/src/client_compiler/command_manifest/confirmations.rs +++ b/distributed_cli/src/client_compiler/command_manifest/confirmations.rs @@ -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", )); } _ => {} diff --git a/distributed_cli/src/client_compiler/command_manifest/shape.rs b/distributed_cli/src/client_compiler/command_manifest/shape.rs index 279904b8..09eba1a7 100644 --- a/distributed_cli/src/client_compiler/command_manifest/shape.rs +++ b/distributed_cli/src/client_compiler/command_manifest/shape.rs @@ -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 { diff --git a/distributed_cli/src/client_compiler/command_manifest_tests.rs b/distributed_cli/src/client_compiler/command_manifest_tests.rs index 4a055d31..c697778d 100644 --- a/distributed_cli/src/client_compiler/command_manifest_tests.rs +++ b/distributed_cli/src/client_compiler/command_manifest_tests.rs @@ -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 { @@ -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 output may reuse its model object type"); + .expect_err("only an exact Atomic output may reuse its model object type"); assert_eq!(error.code, "client.manifest.command_type_namespace"); } diff --git a/distributed_cli/src/client_compiler/manifest/projections.rs b/distributed_cli/src/client_compiler/manifest/projections.rs index 19c2ead9..f11f176b 100644 --- a/distributed_cli/src/client_compiler/manifest/projections.rs +++ b/distributed_cli/src/client_compiler/manifest/projections.rs @@ -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 ), )); @@ -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], @@ -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 ), )); diff --git a/distributed_cli/src/client_compiler/manifest/projectors.rs b/distributed_cli/src/client_compiler/manifest/projectors.rs index 63f2829e..2d63753a 100644 --- a/distributed_cli/src/client_compiler/manifest/projectors.rs +++ b/distributed_cli/src/client_compiler/manifest/projectors.rs @@ -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!( @@ -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()); } diff --git a/distributed_cli/src/client_compiler/manifest/types.rs b/distributed_cli/src/client_compiler/manifest/types.rs index f5268237..85f632a8 100644 --- a/distributed_cli/src/client_compiler/manifest/types.rs +++ b/distributed_cli/src/client_compiler/manifest/types.rs @@ -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)] diff --git a/distributed_cli/src/client_compiler/projection_delta/preview.rs b/distributed_cli/src/client_compiler/projection_delta/preview.rs index c6e1f61c..642aa085 100644 --- a/distributed_cli/src/client_compiler/projection_delta/preview.rs +++ b/distributed_cli/src/client_compiler/projection_delta/preview.rs @@ -535,8 +535,11 @@ pub(crate) fn compile_command_preview( .find(|binding| { binding.program_id == program_id && binding.state == ManifestProjectionBindingState::Active - && binding.placement == ManifestProjectionPlacement::Eventual && binding.execution_class == ManifestProjectionExecutionClass::Causal + && matches!( + binding.placement, + ManifestProjectionPlacement::Eventual | ManifestProjectionPlacement::Direct + ) }) .expect("manifest validation proved one eligible binding"); identities.push(PreviewProjectionIdentity { @@ -1851,7 +1854,7 @@ mod tests { version: 2, consistency: ManifestCommandConsistency { version: 1, - kind: ManifestConsistencyKind::Causal, + kind: ManifestConsistencyKind::Eventual, }, direct_projection: None, input_defaults: None, diff --git a/distributed_cli/src/client_compiler/render/commands.rs b/distributed_cli/src/client_compiler/render/commands.rs index f89f34c5..f2e8c204 100644 --- a/distributed_cli/src/client_compiler/render/commands.rs +++ b/distributed_cli/src/client_compiler/render/commands.rs @@ -408,8 +408,8 @@ fn command_type_field_json(field: &ManifestTypeField) -> JsonValue { fn consistency_label(kind: ManifestConsistencyKind) -> &'static str { match kind { ManifestConsistencyKind::Succeeded => "succeeded", - ManifestConsistencyKind::Causal => "causal", - ManifestConsistencyKind::Projected => "projected", + ManifestConsistencyKind::Eventual => "eventual", + ManifestConsistencyKind::Atomic => "atomic", } } diff --git a/distributed_cli/src/client_compiler/tests.rs b/distributed_cli/src/client_compiler/tests.rs index efc5d003..375d57a8 100644 --- a/distributed_cli/src/client_compiler/tests.rs +++ b/distributed_cli/src/client_compiler/tests.rs @@ -594,7 +594,7 @@ fn projected_manifest() -> JsonValue { "operation_hash": fingerprint(mutation), "extensions": { "version": 2, - "consistency": {"version": 1, "kind": "projected"}, + "consistency": {"version": 1, "kind": "atomic"}, "direct_projection": { "topology": { "version": 1, @@ -764,7 +764,7 @@ fn generated_command_types_manifest() -> JsonValue { fn embedded_model_invalidation_manifest() -> JsonValue { let mut value = generated_command_types_manifest(); let command = &mut value["commands"][0]; - command["extensions"]["consistency"]["kind"] = json!("causal"); + command["extensions"]["consistency"]["kind"] = json!("eventual"); command["extensions"] .as_object_mut() .unwrap() @@ -2778,7 +2778,7 @@ fn command_protocol_and_extensions_are_preserved_exactly() { "operation_hash": fingerprint(mutation), "extensions": { "version": 2, - "consistency": {"version": 1, "kind": "causal"}, + "consistency": {"version": 1, "kind": "eventual"}, "input_defaults": { "version": 1, "defaults": [{"path": ["id"], "generator": "uuid_v7"}] @@ -3168,7 +3168,7 @@ fn command_protocol_and_extensions_are_preserved_exactly() { assert!(!commands.contains("\"token\"")); assert!(!commands.contains("\"effects\"")); assert!(!commands.contains("\"confirmations\"")); - assert!(commands.contains("\"consistency\": \"causal\"")); + assert!(commands.contains("\"consistency\": \"eventual\"")); assert!(commands.contains("\"revalidation\"")); assert!(commands.contains("\"trustedPresets\": []")); assert!(commands.contains("export function prepareCommand_createTodo")); @@ -3464,7 +3464,7 @@ fn manifest_v2_parses_exact_projection_program_binding_and_preview_contract() { "operation_hash": fingerprint(mutation), "extensions": { "version": 2, - "consistency": {"version": 1, "kind": "causal"}, + "consistency": {"version": 1, "kind": "eventual"}, "input_defaults": { "version": 1, "defaults": [{"path": ["id"], "generator": "uuid_v7"}] @@ -3768,7 +3768,7 @@ fn rejects_commands_without_causal_identity_or_normative_input_defaults() { "operation_hash": fingerprint(operation), "extensions": { "version": 2, - "consistency": {"version": 1, "kind": "projected"}, + "consistency": {"version": 1, "kind": "atomic"}, "input_defaults": { "version": 1, "defaults": [{"path": ["id"], "generator": generator}] diff --git a/distributed_cli/tests/cli_manifest.rs b/distributed_cli/tests/cli_manifest.rs index 161278d1..90e6bf41 100644 --- a/distributed_cli/tests/cli_manifest.rs +++ b/distributed_cli/tests/cli_manifest.rs @@ -56,7 +56,7 @@ fn client_manifest_uses_service_surface_export() { assert_eq!(manifest["surface"]["name"], "user"); assert_eq!( manifest["schema_fingerprint"], - "sha256:74b55fc0a23c6204fa002a117356277794c2c4ce35438b26119b27f52a2d6ad7" + "sha256:8f91d3fc7b6d916241b959f6bacd6228eeb06586e9681739ba3e986b4092e134" ); assert_eq!( manifest["protocol_fingerprint"], @@ -71,7 +71,7 @@ fn client_manifest_uses_service_surface_export() { assert_eq!(manifest["capabilities"]["query_fallback"], "revalidate"); assert_eq!( manifest["commands"][0]["extensions"]["consistency"]["kind"], - "projected" + "atomic" ); assert_eq!( manifest["commands"][0]["extensions"]["direct_projection"]["topology"]["version"], diff --git a/distributed_cli/tests/fixtures/generated-commands.ts b/distributed_cli/tests/fixtures/generated-commands.ts index 50d24c27..8b4079f0 100644 --- a/distributed_cli/tests/fixtures/generated-commands.ts +++ b/distributed_cli/tests/fixtures/generated-commands.ts @@ -68,7 +68,7 @@ export const Command_importTodos: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "todos-v1", "identityFields": [ @@ -435,7 +435,7 @@ export const Command_projectTodo: ReplicaCommandArtifact, _input: ProjectOrderInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { Err(HandlerError::Rejected( "manifest-only fixture does not execute commands".into(), )) @@ -118,7 +118,7 @@ pub fn distributed_client_surface() -> DistributedClientSurfaceExport { InMemoryRepository::new(), )) .typed_command( - typed_command::>("order.project") + typed_command::>("order.project") .field_name("orders_project") .roles(["user"]), ) diff --git a/distributed_macros/src/mutation.rs b/distributed_macros/src/mutation.rs index 974ab4ed..0f7655e9 100644 --- a/distributed_macros/src/mutation.rs +++ b/distributed_macros/src/mutation.rs @@ -71,6 +71,8 @@ struct MutationDeclaration { operations: Vec, } +type GraphqlFieldBinding = (Ident, Vec); + enum MutationOpSyntax { /// `upsert Model from input.root;` SugarUpsert { model: Path, input_root: Vec }, @@ -86,13 +88,13 @@ enum MutationOpSyntax { /// `delete Model by_pk { field: input.path, ... };` DeleteByPk { model: Path, - keys: Vec<(Ident, Vec)>, + keys: Vec, }, /// `update Model by_pk { key..., _set: { field: expr, ... } };` UpdateByPk { model: Path, - keys: Vec<(Ident, Vec)>, - sets: Vec<(Ident, Vec)>, + keys: Vec, + sets: Vec, }, /// `insert Model one { object: input.root };` InsertOne { @@ -175,10 +177,7 @@ impl Parse for MutationDeclaration { fn parse_graphql_token_document(input: ParseStream<'_>) -> Result { let mutation_kw: Ident = input.parse()?; if mutation_kw != "mutation" { - return Err(syn::Error::new( - mutation_kw.span(), - "expected `mutation`", - )); + return Err(syn::Error::new(mutation_kw.span(), "expected `mutation`")); } let name_ident: Ident = input.parse()?; let mut version: Option = None; @@ -212,13 +211,8 @@ fn parse_graphql_token_document(input: ParseStream<'_>) -> Result Result(tokens).map_err(|error| { syn::Error::new( @@ -289,14 +286,12 @@ fn parse_graphql_field_operation(input: ParseStream<'_>) -> Result) -> Result Result { if name.is_empty() { - return Err(syn::Error::new(span, "model name missing from mutation field")); + return Err(syn::Error::new( + span, + "model name missing from mutation field", + )); } let ident = Ident::new(name, span); Ok(Path::from(ident)) @@ -352,7 +350,7 @@ fn parse_graphql_object_arg(input: ParseStream<'_>) -> Result> { object_root.ok_or_else(|| input.error("upsert/insert requires `object: $input…`")) } -fn parse_graphql_key_args(input: ParseStream<'_>) -> Result)>> { +fn parse_graphql_key_args(input: ParseStream<'_>) -> Result> { let mut keys = Vec::new(); while !input.is_empty() { let name: Ident = input.parse()?; @@ -371,7 +369,7 @@ fn parse_graphql_key_args(input: ParseStream<'_>) -> Result, -) -> Result<(Vec<(Ident, Vec)>, Vec<(Ident, Vec)>)> { +) -> Result<(Vec, Vec)> { let mut keys = Vec::new(); let mut sets = Vec::new(); while !input.is_empty() { @@ -398,9 +396,7 @@ fn parse_graphql_update_args( } } if keys.is_empty() || sets.is_empty() { - return Err(input.error( - "update_by_pk requires key fields and `_set: { field: $input… }`", - )); + return Err(input.error("update_by_pk requires key fields and `_set: { field: $input… }`")); } Ok((keys, sets)) } diff --git a/docs/application-composition.md b/docs/application-composition.md new file mode 100644 index 00000000..b615893a --- /dev/null +++ b/docs/application-composition.md @@ -0,0 +1,207 @@ +# Application composition (logical + runtime) + +**Status:** accepted direction (product DX) +**Goal:** Make the same packages easy to run as a monolith or as microservices, with persistence/locks/transports as a separate, boring plane. +**Non-goal:** Back-compat aliases or dual public APIs for the same concept. + +Related pain today: `tests/e2e-ui/crates/service/src/service.rs` (~projection catalog, surfaces, command walls) and `tests/e2e-ui/crates/runner/src/main.rs` (SQLite/Postgres × API/outbox/consumer wiring). + +--- + +## Two planes + +| Plane | Contents | Changes when… | +|--------|----------|----------------| +| **Logical** | Domains, command defs (`.emits` / `.applies`), projections, client surfaces | Product features | +| **Runtime** | Event store, read models, locks, bus, outbox/consumer, GraphQL/HTTP, identity | Deploy cut & dialect | + +Microservices re-cut **logical mounts** and **process role**. +Dialect (SQLite ↔ Postgres) re-cuts **runtime only**. + +### CAP / Atomic + +| Consistency | Split command process vs projector process? | +|-------------|-----------------------------------------------| +| **Eventual** | Yes — command emits; projector applies the same mutation IR async | +| **Atomic** | No for the seal — handler applies IR in-process / same tx as commit. Other services may *read* the row later; they cannot own the atomic seal | + +Framework must **reject** Atomic command mounts on projector-only or query-only processes. + +Client optimism is always the same path (`.applies` → optimistic layer → seal). Eventual vs Atomic only changes **where the server applies IR** and **how the client proves the seal**. + +--- + +## Logical building blocks + +### Packages (write once) + +```text +domains/ aggregates + events +projections/ descriptors + mutation IR +commands/ CommandDef (emits, applies, handler) — not bound to one process +readmodels/ schemas + grants +``` + +### Mounts (compose) + +```rust +// Sketch — names may evolve; intent is stable. +app.projection(TODOS).eventual().epoch("todos-v2"); +app.projection(BLOB_GAMES).atomic().epoch("blob-v2"); // collocated with blob commands + +app.command(todo_create::def()); +app.command(blob_move::def()); + +app.surface("e2e-ui").eligible(["user", "admin"]).schema(["user"]); +app.surface("e2e-ui-admin").roles(["admin"]); +``` + +### Command defs co-located with handlers + +Product intent lives next to the handler; `service`/app only **registers**: + +```rust +// handlers/todo_complete.rs (sketch) +pub fn def() -> CommandDef { + command::>(COMMAND) + .field_name("todos_complete") // or derive from COMMAND + .roles(["user", "admin"]) // or route-group default + .emits(events![TodoCompletedDomainEvent]) + .applies(state_preview! { /* optimism contract */ }) + .handle(handle) +} +``` + +Do **not** auto-invent `state_preview` — that is the optimism contract and stays explicit. +Do hide: topology digests, partition codec version, source binding, catalog activation ceremony. + +### SystemSlice (microservices re-cut) + +```rust +// package-level slice +fn todo_system() -> SystemSlice { + SystemSlice::new() + .commands([/* … */]) + .projection(TODOS).eventual().epoch("todos-v2") +} + +// deployables +Application::new("todos-write").include(todo_system().commands_only())… +Application::new("todos-projector").include(todo_system().projectors_only())… +Application::new("e2e-ui").include(todo_system()).include(chat_system())… // Full +``` + +--- + +## Runtime building blocks + +### One dialect choice pairs store + locks + bus + +```rust +let runtime = Runtime::sqlite(database_url).await?; +// Runtime::postgres(database_url).await? +// Runtime::in_memory() // client-manifest / unit tests +``` + +Defaults from the same pool (e2e today): + +| Concern | Paired implementation | +|---------|------------------------| +| Event store | Sqlite / Postgres / InMemory repository | +| Read models | Same store unless explicitly split later | +| Locks | Matching lock manager | +| Bus | Matching bus + group name | +| Migrations | `connect_and_migrate` at runtime build | + +### Process roles (what this process runs) + +| Role | Spawns / enables | +|------|------------------| +| **Full** | Commands + eventual projectors + outbox + consumer + GraphQL (monolith / e2e) | +| **CommandWriter** | Aggregates, commands, outbox publish; no consumer | +| **EventualProjector** | Bus consume + projector mounts; no public write GraphQL required | +| **QueryApi** | GraphQL + surfaces; read path | + +```rust +runtime + .app(logical_app) + .role(ProcessRole::Full) // or Writer / Projector / QueryApi + .identity(identity_from_env()) + .bind(bind) + .run() + .await?; +``` + +Outbox dispatcher and eventual consumer are **role-selected**, not copy-pasted per dialect in `main`. + +### Transports as edges + +| Transport | Typical role | +|-----------|----------------| +| GraphQL (queries + commands) | Full / CommandWriter / QueryApi | +| Bus (outbox → consumer) | Full / Writer publish + Projector consume | +| HTTP command routes | Optional / ingress only (e.g. Zitadel) | +| OIDC / identity | API-facing processes | + +Primary write path for apps remains GraphQL commands (as e2e-ui already prefers with `without_http_command_routes`). + +--- + +## Layered mental model + +```text +┌──────────────────────────────────────────────────────────┐ +│ Packages: domain · commands · projections · read models │ write once +├──────────────────────────────────────────────────────────┤ +│ Logical app: mounts, surfaces, Eventual vs Atomic │ product graph +├──────────────────────────────────────────────────────────┤ +│ Process role: Full | Writer | Projector | Query │ microservice cut +├──────────────────────────────────────────────────────────┤ +│ Runtime: store + locks + bus + workers + HTTP/GraphQL │ dialect / host +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## Target shape of e2e-ui + +| Today | Target | +|--------|--------| +| `projection_owners()` catalog ceremony | `projection_inventory!` / `ProjectionMount` defaults | +| 15-line `typed_command` walls in `build_service` | `.register(todo_create::def())` | +| Three surface helpers rebuilding InMemory | `app.surface(...).export()` | +| Runner: sqlite/postgres forks × outbox/consumer | `Runtime::from_env().role(Full).app(...).run()` | + +Rough size: **~100–150 lines of product wiring** for logical app + a tiny deployable main — not ~950 lines of framework internals. + +--- + +## Implementation order + +1. **Projection mount / inventory** — hide catalog, topology digests, activation. +2. **CommandDef + `.register()`** — co-locate emits/applies/handler with command modules. +3. **Runtime::{sqlite, postgres, in_memory}** — pair repo + locks + bus. +4. **ProcessRole** — Full / CommandWriter / EventualProjector / QueryApi spawn policy. +5. **Atomic mount checks** — fail closed if Atomic is mounted without write path. +6. **Collapse e2e-ui `service.rs` + `runner`** onto the new APIs (no dual/legacy surface). + +No back-compat shims: one name per concept (e.g. `.applies` only, not `.preview`). + +--- + +## Acceptance sketch (later) + +- [ ] e2e-ui logical app does not mention partition codec, topology digests, or manual catalog activate. +- [ ] Adding a command is “def next to handler + one register line,” not a 12-line block in a central file. +- [ ] Runner has one path for sqlite/postgres selected by URL/env. +- [ ] A dual-process smoke (command writer + eventual projector) can share packages with Full e2e. +- [ ] Atomic blob commands refuse projector-only process configuration. +- [ ] Client gen still works from logical app (preview IR) without a live pool. + +--- + +## Non-goals + +- Hiding `.emits` / `.applies` (optimism contract stays visible). +- Pretending remote Atomic projectors exist. +- Second “simple” API that still requires the full catalog dance in user code. diff --git a/js/README.md b/js/README.md index b7fc727c..fa131b16 100644 --- a/js/README.md +++ b/js/README.md @@ -310,16 +310,22 @@ same replica and GraphQL transport. A command call: 1. validates and freezes its typed input; 2. fills generated UUIDv7, ULID, or literal defaults exactly once; -3. applies the generated optimistic effect transaction; +3. applies the generated optimistic effect transaction (from `.applies` / + portable mutation IR — works for **Eventual and Direct** when fields are known); 4. dispatches the exact compiler-owned mutation; 5. keeps ambiguous commits recoverable by command ID; 6. confirms or rejects only its own optimistic layer; -7. resolves projected completion only after exact causal evidence arrives. +7. retires the layer on the path that placement allows: + - **Eventual** — wait for projection obligations (event handler ran + async; there is no authoritative row on the command response); + - **Atomic / Direct** — normalize the **returned** row + (`confirmDirectProjection`) before the call settles. The server waited in + the command handler because it could; an event handler cannot. Applications do not provide list targets, merge functions, mutation update -callbacks, or invalidation maps. If the compiler cannot prove safe maintenance, -the generated plan marks the affected projection stale and the replica performs -one deduplicated revalidation. +callbacks, board simulators, or invalidation maps. If the compiler cannot prove +safe maintenance, the generated plan marks the affected projection stale and +the replica performs one deduplicated revalidation. Callers may bound their own causal wait without inventing a rollback: diff --git a/js/src/protocol.ts b/js/src/protocol.ts index aeb8d35f..19ff4f4e 100644 --- a/js/src/protocol.ts +++ b/js/src/protocol.ts @@ -31,7 +31,7 @@ export type DistributedCommandState = | 'in_progress' | 'succeeded' | 'succeeded_pending_projection' - | 'projected' + | 'atomic' | 'rejected' | 'projection_failed' | 'expired' @@ -39,8 +39,8 @@ export type DistributedCommandState = export type DistributedCommandConsistency = | 'succeeded' - | 'causal' - | 'projected'; + | 'eventual' + | 'atomic'; /** Current-scope handling for authenticated historical projector evidence. */ export type DistributedProjectionDisposition = 'revalidate'; @@ -229,7 +229,7 @@ const COMMAND_STATES = new Set([ 'in_progress', 'succeeded', 'succeeded_pending_projection', - 'projected', + 'atomic', 'rejected', 'projection_failed', 'expired', @@ -238,8 +238,8 @@ const COMMAND_STATES = new Set([ const COMMAND_CONSISTENCIES = new Set([ 'succeeded', - 'causal', - 'projected' + 'eventual', + 'atomic' ]); const MAX_PUBLIC_NAME_LENGTH = 512; @@ -505,7 +505,7 @@ function parseCommand(value: unknown): DistributedCommandMetadata { ![ 'succeeded', 'succeeded_pending_projection', - 'projected', + 'atomic', 'projection_failed' ].includes(state)) ) { diff --git a/js/src/replica/command-runtime/create.ts b/js/src/replica/command-runtime/create.ts index a5e9c5dc..f8b7f23e 100644 --- a/js/src/replica/command-runtime/create.ts +++ b/js/src/replica/command-runtime/create.ts @@ -635,7 +635,7 @@ export function createReplicaCommandRuntime< switch (metadata.state) { case 'succeeded': case 'succeeded_pending_projection': - case 'projected': + case 'atomic': return validateActualProjection(prepared, metadata, authority); case 'in_progress': case 'rejected': @@ -889,7 +889,9 @@ export function createReplicaCommandRuntime< * delta. */ statusRequiresRevalidation = true; - } else { + } else if (prepared.consistency !== 'atomic') { + // Atomic rows are confirmed on the mutation response, + // not via async projection-delta status envelopes. const validated = validateProjectionForState( prepared as ReplicaPreparedCommand, status.metadata, @@ -945,7 +947,7 @@ export function createReplicaCommandRuntime< break; case 'succeeded': case 'succeeded_pending_projection': - case 'projected': { + case 'atomic': { const metadata = status.metadata!; if (metadata.projectionDisposition === 'revalidate') { if (metadata.state === 'succeeded_pending_projection') { @@ -1020,7 +1022,7 @@ export function createReplicaCommandRuntime< settleTrackedProjection(tracker, pending); } } else if ( - metadata.state === 'projected' || + metadata.state === 'atomic' || (metadata.state === 'succeeded' && metadata.expects.length === 0 && (prepared.revalidation.required || @@ -1365,24 +1367,32 @@ export function createReplicaCommandRuntime< } ); } + /* + * Ship contract: Atomic seals from the atomic GraphQL row + + * direct `records` (confirmDirectProjection). Eventual applies + * projection-delta when present. Same portable IR for `.applies` + * previews either way — different response proof by design. + */ let actualRequiresRevalidation = false; - try { - actualRequiresRevalidation = applyActualProjection( - prepared as ReplicaPreparedCommand, - metadata, - authority - ); - } catch (error) { - rejectUnmanagedLayer(prepared.commandId); - revalidateInBackground(prepared, authority); - throw new ReplicaCommandRuntimeError( - 'REPLICA_COMMAND_PROTOCOL_INVALID', - { - commandId: prepared.commandId, - cause: error, - ...(statusArtifact === undefined ? {} : { recovery }) - } - ); + if (prepared.consistency !== 'atomic') { + try { + actualRequiresRevalidation = applyActualProjection( + prepared as ReplicaPreparedCommand, + metadata, + authority + ); + } catch (error) { + rejectUnmanagedLayer(prepared.commandId); + revalidateInBackground(prepared, authority); + throw new ReplicaCommandRuntimeError( + 'REPLICA_COMMAND_PROTOCOL_INVALID', + { + commandId: prepared.commandId, + cause: error, + ...(statusArtifact === undefined ? {} : { recovery }) + } + ); + } } let projected: | Promise> @@ -1390,8 +1400,8 @@ export function createReplicaCommandRuntime< let projectionLifecycle: | Promise> | undefined; - if (prepared.consistency === 'projected') { - if (metadata.state !== 'projected') { + if (prepared.consistency === 'atomic') { + if (metadata.state !== 'atomic') { statusTracker.state = metadata.state; statusTracker.metadata = metadata; throw new ReplicaCommandRuntimeError( @@ -1423,7 +1433,7 @@ export function createReplicaCommandRuntime< projected = Promise.resolve( Object.freeze({ commandId: prepared.commandId, - state: 'projected' as const, + state: 'atomic' as const, result: output, metadata }) diff --git a/js/src/replica/command-runtime/lib/projection.ts b/js/src/replica/command-runtime/lib/projection.ts index f1c6076b..2de05198 100644 --- a/js/src/replica/command-runtime/lib/projection.ts +++ b/js/src/replica/command-runtime/lib/projection.ts @@ -254,7 +254,7 @@ export function settleProjectionSuccess(controller: PendingProjection): void { controller.resolve( Object.freeze({ commandId: controller.commandId, - state: 'projected', + state: 'atomic', metadata: controller.metadata }) ); diff --git a/js/src/replica/command-runtime/lib/status.ts b/js/src/replica/command-runtime/lib/status.ts index 0fb9e4ba..4e00cf66 100644 --- a/js/src/replica/command-runtime/lib/status.ts +++ b/js/src/replica/command-runtime/lib/status.ts @@ -102,7 +102,7 @@ export function commandStatusOutput( case 'in_progress': case 'succeeded': case 'succeeded_pending_projection': - case 'projected': + case 'atomic': case 'rejected': case 'projection_failed': case 'expired': @@ -192,12 +192,12 @@ export function isStatusTransition( case 'succeeded_pending_projection': return ( next === 'succeeded_pending_projection' || - next === 'projected' || + next === 'atomic' || next === 'projection_failed' || next === 'expired' ); - case 'projected': - return next === 'projected' || next === 'expired'; + case 'atomic': + return next === 'atomic' || next === 'expired'; case 'rejected': return next === 'rejected' || next === 'expired'; case 'projection_failed': diff --git a/js/src/replica/command-runtime/types.ts b/js/src/replica/command-runtime/types.ts index 402e7dfd..dd753369 100644 --- a/js/src/replica/command-runtime/types.ts +++ b/js/src/replica/command-runtime/types.ts @@ -138,8 +138,8 @@ export interface ReplicaCommandTransport { export type ReplicaCommandProjectedOutcome = Readonly<{ commandId: string; - state: 'projected'; - /** Present for same-transaction Projected, absent for async facts. */ + state: 'atomic'; + /** Present for same-transaction Atomic, absent for async facts. */ result?: TOutput; metadata?: DistributedCommandMetadata; }>; @@ -158,7 +158,7 @@ export type ReplicaCommandReceipt = Readonly<{ commandId: string; state: Extract< DistributedCommandState, - 'succeeded' | 'succeeded_pending_projection' | 'projected' + 'succeeded' | 'succeeded_pending_projection' | 'atomic' >; /** Typed application payload returned by the generated mutation. */ result: TOutput; @@ -166,7 +166,7 @@ export type ReplicaCommandReceipt = Readonly<{ /** One exact generated status read. Calls coalesce while in flight. */ status(): Promise; /** - * Causal visibility awaitable. It is omitted when no finite projection + * Eventual visibility awaitable. It is omitted when no finite projection * contract exists and never resolves because a wall-clock timer elapsed. */ projected?: Promise>; diff --git a/js/src/replica/commands/receipt.ts b/js/src/replica/commands/receipt.ts index 277ef4e7..13609b1d 100644 --- a/js/src/replica/commands/receipt.ts +++ b/js/src/replica/commands/receipt.ts @@ -45,7 +45,16 @@ export function verifyReplicaCommandReceipt( receiptMismatch('receipt.expects'); } } else if (receipt.projection === undefined) { - receiptMismatch('receipt.expects'); + /* + * Ship contract (see server routes: Atomic never persists eventual + * modeled projection metadata): + * - Eventual: response carries projection-delta (+ expects) when modeled. + * - Atomic: response carries typed row + direct `records`; optional + * `.applies` previews use the same IR but seal via the row, not a delta. + */ + if (prepared.consistency !== 'atomic') { + receiptMismatch('receipt.expects'); + } } return Object.freeze({ kind: receipt.state === 'in_progress' ? 'deferred' : 'matched', diff --git a/js/src/replica/commands/validate.ts b/js/src/replica/commands/validate.ts index dde31da1..f782b8ca 100644 --- a/js/src/replica/commands/validate.ts +++ b/js/src/replica/commands/validate.ts @@ -86,8 +86,8 @@ export function validateArtifact( validateDefaults(artifact.input, artifact.inputDefaults); if ( artifact.consistency !== 'succeeded' && - artifact.consistency !== 'causal' && - artifact.consistency !== 'projected' + artifact.consistency !== 'eventual' && + artifact.consistency !== 'atomic' ) { artifactInvalid('artifact.consistency'); } @@ -418,7 +418,7 @@ export function validateConfirmations( ): void { const confirmations = artifact.confirmations; if (confirmations === undefined) { - if (artifact.consistency === 'causal') { + if (artifact.consistency === 'eventual') { artifactInvalid('artifact.confirmations'); } return; @@ -441,7 +441,7 @@ export function validateConfirmations( } else { artifactInvalid('artifact.confirmations.kind'); } - if (artifact.consistency === 'projected') { + if (artifact.consistency === 'atomic') { artifactInvalid('artifact.confirmations'); } if (confirmations.kind === 'finite') { @@ -466,10 +466,10 @@ export function validateDirectProjection( artifact: ReplicaCommandArtifact ): void { const direct = artifact.directProjection; - if (artifact.consistency === 'projected' && direct === undefined) { + if (artifact.consistency === 'atomic' && direct === undefined) { artifactInvalid('artifact.directProjection'); } - if (artifact.consistency !== 'projected' && direct !== undefined) { + if (artifact.consistency !== 'atomic' && direct !== undefined) { artifactInvalid('artifact.directProjection'); } if (direct === undefined) return; @@ -548,7 +548,7 @@ export function validateRevalidation( } if ( ((artifact.projection?.preview.operations.length ?? 0) === 0 && - artifact.consistency !== 'projected') && + artifact.consistency !== 'atomic') && !plan.required ) { artifactInvalid('artifact.revalidation.required'); diff --git a/js/src/replica/diagnostics/types.ts b/js/src/replica/diagnostics/types.ts index edadd556..d72d538c 100644 --- a/js/src/replica/diagnostics/types.ts +++ b/js/src/replica/diagnostics/types.ts @@ -127,7 +127,7 @@ export type ReplicaDiagnosticReceiptExpectationInput = Readonly<{ export type ReplicaDiagnosticReceiptInput = Readonly<{ commandId: string; state: 'optimistic' | 'succeeded' | 'succeeded_pending_projection'; - consistency?: 'succeeded' | 'causal' | 'projected'; + consistency?: 'succeeded' | 'eventual' | 'atomic'; expectations: readonly ReplicaDiagnosticReceiptExpectationInput[]; }>; @@ -176,7 +176,7 @@ export type ReplicaDiagnosticEventInput = | 'optimistic' | 'succeeded' | 'succeeded_pending_projection' - | 'projected' + | 'atomic' | 'rejected'; obligations: number; observed: number; @@ -257,7 +257,7 @@ export type ReplicaDiagnosticLayer = Readonly<{ export type ReplicaDiagnosticReceipt = Readonly<{ commandId: string; state: 'optimistic' | 'succeeded' | 'succeeded_pending_projection'; - consistency?: 'succeeded' | 'causal' | 'projected'; + consistency?: 'succeeded' | 'eventual' | 'atomic'; expectations: readonly ReplicaDiagnosticReceiptExpectationInput[]; }>; @@ -318,7 +318,7 @@ export type ReplicaCommandArtifactInspection = Readonly<{ kind: 'command'; name: string; operation: string; - consistency: 'succeeded' | 'causal' | 'projected'; + consistency: 'succeeded' | 'eventual' | 'atomic'; effects: readonly ReplicaCommandEffectInspection[]; revalidation: Readonly<{ required: boolean; diff --git a/js/src/replica/distributed-replica/helpers.ts b/js/src/replica/distributed-replica/helpers.ts index c6b56e6e..4f0b58b4 100644 --- a/js/src/replica/distributed-replica/helpers.ts +++ b/js/src/replica/distributed-replica/helpers.ts @@ -724,7 +724,7 @@ export function graphqlError(error: unknown): GqlError { } export function assertWriteSource(source: ReplicaWriteSource): void { - if (!['network', 'live', 'ssr', 'restore', 'projected'].includes(source)) { + if (!['network', 'live', 'ssr', 'restore', 'atomic'].includes(source)) { throw new TypeError(`unsupported replica write source: ${source}`); } } diff --git a/js/src/replica/distributed-replica/impl-diagnostics.ts b/js/src/replica/distributed-replica/impl-diagnostics.ts index 3f4dd0fe..677a2ab9 100644 --- a/js/src/replica/distributed-replica/impl-diagnostics.ts +++ b/js/src/replica/distributed-replica/impl-diagnostics.ts @@ -230,7 +230,7 @@ export function retireDiagnosticLayer( host: DiagnosticsHost, id: string, action: 'retired' | 'rejected', - receiptState: 'projected' | 'rejected', + receiptState: 'atomic' | 'rejected', receipt?: OptimisticReceiptState ): void { const layers = host.diagnosticLayers; diff --git a/js/src/replica/distributed-replica/impl-optimistic.ts b/js/src/replica/distributed-replica/impl-optimistic.ts index 3929e5ef..8b37acc1 100644 --- a/js/src/replica/distributed-replica/impl-optimistic.ts +++ b/js/src/replica/distributed-replica/impl-optimistic.ts @@ -45,7 +45,7 @@ export type OptimisticHost = { retireDiagnosticLayer( id: string, action: 'retired' | 'rejected', - receiptState: 'projected' | 'rejected', + receiptState: 'atomic' | 'rejected', receipt?: OptimisticReceiptState ): void; }; @@ -223,7 +223,7 @@ export function confirmOptimisticLayerOn( const result = host.engine.confirmOptimisticLayer(id, (writer) => update(baseWriter(writer)) ); - host.retireDiagnosticLayer(id, 'retired', 'projected'); + host.retireDiagnosticLayer(id, 'retired', 'atomic'); host.optimisticReceipts.delete(id); host.syncDiagnostics(); return result; diff --git a/js/src/replica/distributed-replica/impl.ts b/js/src/replica/distributed-replica/impl.ts index 7ec4e854..ce03991f 100644 --- a/js/src/replica/distributed-replica/impl.ts +++ b/js/src/replica/distributed-replica/impl.ts @@ -1421,7 +1421,7 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { } for (const [id, receipt] of receiptPlan.updates) { if (receiptPlan.satisfied.includes(id)) { - this.#retireDiagnosticLayer(id, 'retired', 'projected', receipt); + this.#retireDiagnosticLayer(id, 'retired', 'atomic', receipt); this.#optimisticReceipts.delete(id); } else { this.#optimisticReceipts.set(id, receipt); @@ -1696,7 +1696,7 @@ export class DistributedReplicaImpl implements DistributedReplicaApi { #retireDiagnosticLayer( id: string, action: 'retired' | 'rejected', - receiptState: 'projected' | 'rejected', + receiptState: 'atomic' | 'rejected', receipt?: OptimisticReceiptState ): void { retireDiagnosticLayerOn(this.#diagnosticsHost(), id, action, receiptState, receipt); diff --git a/js/src/replica/index-maintenance/engine.ts b/js/src/replica/index-maintenance/engine.ts index ee196b6d..5918f284 100644 --- a/js/src/replica/index-maintenance/engine.ts +++ b/js/src/replica/index-maintenance/engine.ts @@ -736,10 +736,8 @@ export function maintainEntityIndex( index.metadata.coverage.limit !== undefined && records.length > index.metadata.coverage.limit ) { - // Locality above is possible only because the base first page was - // non-full, so this is the complete optimistic ordered set. Preserve the - // operation's exact window when stacked optimistic inserts cross its - // limit. + // First-page inserts re-sort known members then clamp to the operation + // window so a full live page still shows optimistic rows that sort in. records = records.slice(0, index.metadata.coverage.limit); } return sameStringList(records, current) @@ -781,6 +779,11 @@ export function certifyOffsetCoverage( typeof artifact.maxLimit === 'number' ? Math.min(configuredLimit, artifact.maxLimit) : configuredLimit; + // First-page inserts remain local even when hasNext is true: membership is + // re-sorted and truncated to the limit among known rows. Delete/reorder on a + // page with a next boundary still fail closed (unseen fill-in). + const hasNextBlocksLocality = + coverage.hasNext === true && changeKind !== 'insert'; if ( typeof expectedOffset !== 'number' || !Number.isSafeInteger(expectedOffset) || @@ -792,7 +795,7 @@ export function certifyOffsetCoverage( coverage.offset !== expectedOffset || coverage.limit !== expectedLimit || coverage.returned !== confirmedRecords || - coverage.hasNext === true + hasNextBlocksLocality ) { return reason( 'invalid_index_metadata', diff --git a/js/src/replica/query-plan/pagination.ts b/js/src/replica/query-plan/pagination.ts index f65f1689..91b17697 100644 --- a/js/src/replica/query-plan/pagination.ts +++ b/js/src/replica/query-plan/pagination.ts @@ -58,21 +58,33 @@ export function decideReplicaPaginationMaintenance( if ( coverage.kind === 'offset' && coverage.offset === 0 && - coverage.limit !== undefined && - coverage.returned !== undefined && - coverage.returned < coverage.limit + coverage.limit !== undefined ) { - // A non-full first page proves that the server returned the complete - // ordered set. The index runtime can therefore apply all optimistic - // membership/order changes and truncate back to the declared limit. - return PAGINATION_LOCAL; + /* + * First-page (offset 0) locality: + * - insert: always local when a limit is known. The maintainer + * re-sorts known members and truncates to the window, so a full + * lobby page still shows an optimistic chat/todo at the front. + * - delete/reorder: only when the page is non-full (complete ordered + * set). A full page with hasNext cannot prove the next boundary + * after a delete or order change. + */ + if (change.kind === 'insert') { + return PAGINATION_LOCAL; + } + if ( + coverage.returned !== undefined && + coverage.returned < coverage.limit + ) { + return PAGINATION_LOCAL; + } } } const [code, message]: readonly [ReplicaQueryPlanReasonCode, string] = change.kind === 'insert' ? [ 'insert_changes_offset_window', - 'an insert is local only for a proven non-full first offset page' + 'an insert is local only for the first offset page (offset 0) with a known limit' ] : change.kind === 'delete' ? [ diff --git a/js/src/replica/types.ts b/js/src/replica/types.ts index 63e9531b..c6b00f8c 100644 --- a/js/src/replica/types.ts +++ b/js/src/replica/types.ts @@ -561,7 +561,7 @@ export type ReplicaOperationArtifact< TVariables extends GraphqlVariables = GraphqlVariables > = ReplicaProtocolOperationArtifact; -export type ReplicaWriteSource = 'network' | 'live' | 'ssr' | 'restore' | 'projected'; +export type ReplicaWriteSource = 'network' | 'live' | 'ssr' | 'restore' | 'atomic'; export type ReplicaResultEnvelope = { readonly data?: TData | null; diff --git a/js/tests/cache-engine-conformance.test.mjs b/js/tests/cache-engine-conformance.test.mjs index f05a74c9..bfa37eeb 100644 --- a/js/tests/cache-engine-conformance.test.mjs +++ b/js/tests/cache-engine-conformance.test.mjs @@ -390,12 +390,12 @@ test('causal confirmation writes base and retires its layer atomically', () => { ); engine.confirmOptimisticLayer('complete-1', (writer) => { - writer.writeRecord({ key: TODO, revision: 2, fields: { status: 'projected' } }); + writer.writeRecord({ key: TODO, revision: 2, fields: { status: 'atomic' } }); }); assert.equal(calls, 1); - assert.deepEqual(values, ['projected']); + assert.deepEqual(values, ['atomic']); assert.equal(engine.optimisticLayerState('complete-1'), undefined); - assert.equal(engine.read((reader) => reader.record(TODO)?.fields.status), 'projected'); + assert.equal(engine.read((reader) => reader.record(TODO)?.fields.status), 'atomic'); }); test('same-entity optimistic layers confirm and reject out of order without cross-rollback', () => { diff --git a/js/tests/fixtures/adapter-conformance.mjs b/js/tests/fixtures/adapter-conformance.mjs index 129e829e..8484439c 100644 --- a/js/tests/fixtures/adapter-conformance.mjs +++ b/js/tests/fixtures/adapter-conformance.mjs @@ -464,14 +464,14 @@ export async function assertReplicaAdapterConformance({ mount }) { await adapter.settle(() => { replica.confirmOptimisticLayer('cmd-pending', (writer) => writer.writeRecord(TodoModel, 'todo-1', '3', { - fields: { title: 'projected' } + fields: { title: 'atomic' } }) ); }); assert.equal( currentTitle(adapter.getSnapshot()), - 'projected', - 'Projected confirmation must atomically replace the pending layer' + 'atomic', + 'Atomic confirmation must replace the pending layer atomically' ); await adapter.settle(() => { @@ -485,7 +485,7 @@ export async function assertReplicaAdapterConformance({ mount }) { await adapter.settle(() => { assert.equal(replica.rejectOptimisticLayer('cmd-rejected'), true); }); - assert.equal(currentTitle(adapter.getSnapshot()), 'projected'); + assert.equal(currentTitle(adapter.getSnapshot()), 'atomic'); const backgroundFetches = transport.fetches.filter( (entry) => !entry.response.settled @@ -496,7 +496,7 @@ export async function assertReplicaAdapterConformance({ mount }) { entry.response.resolve( todoFrame( TodosArtifact, - [{ id: 'todo-1', title: 'projected', status: 'done' }], + [{ id: 'todo-1', title: 'atomic', status: 'done' }], { position: '30' } ) ); diff --git a/js/tests/protocol-transport.test.mjs b/js/tests/protocol-transport.test.mjs index e121d12f..6881e006 100644 --- a/js/tests/protocol-transport.test.mjs +++ b/js/tests/protocol-transport.test.mjs @@ -21,7 +21,7 @@ function distributedEnvelope() { commandId: 'opaque-command-id', causationId: 'opaque-causation-id', state: 'succeeded_pending_projection', - consistency: 'causal', + consistency: 'eventual', expects: [ { projection: 'todos', diff --git a/js/tests/replica-command-artifacts.test.mjs b/js/tests/replica-command-artifacts.test.mjs index baab7acd..107c73f8 100644 --- a/js/tests/replica-command-artifacts.test.mjs +++ b/js/tests/replica-command-artifacts.test.mjs @@ -243,7 +243,7 @@ function baseArtifact(overrides = {}) { { path: ['id'], generator: 'uuid_v7' } ] }, - consistency: 'causal', + consistency: 'eventual', projection: baseProjection(), revalidation: { version: 1, @@ -259,7 +259,7 @@ function baseArtifact(overrides = {}) { function projectedArtifact(directOverrides = {}) { return baseArtifact({ output: PROJECTED_OUTPUT, - consistency: 'projected', + consistency: 'atomic', projection: undefined, directProjection: { topology: { @@ -1042,7 +1042,7 @@ test('unavailable confirmation contracts always force conservative revalidation' }); test('generated Draining command authorizes lifecycle revalidation without projection application authority', () => { - assert.equal(GENERATED_DRAINING_COMMAND.consistency, 'causal'); + assert.equal(GENERATED_DRAINING_COMMAND.consistency, 'eventual'); assert.equal(GENERATED_DRAINING_COMMAND.projection, undefined); assert.equal(GENERATED_DRAINING_COMMAND.revalidation.required, true); const prepared = prepareReplicaCommand( @@ -1081,7 +1081,7 @@ test('revalidation disposition without projection or command-level capability fa () => verifyReplicaCommandReceipt( prepared, - receipt(prepared, 'projected', { + receipt(prepared, 'atomic', { projectionDisposition: 'revalidate' }) ), @@ -1151,7 +1151,7 @@ test('projected commands close the direct projection partition from finalized in assert.equal(Object.isFrozen(prepared.directProjection.topology), true); assert.equal(Object.isFrozen(prepared.directProjection.identityFields), true); assert.deepEqual( - verifyReplicaCommandReceipt(prepared, receipt(prepared, 'projected')), + verifyReplicaCommandReceipt(prepared, receipt(prepared, 'atomic')), { kind: 'matched', revalidate: false } ); }); diff --git a/js/tests/replica-command-runtime.test.mjs b/js/tests/replica-command-runtime.test.mjs index 5e117ca2..27d84372 100644 --- a/js/tests/replica-command-runtime.test.mjs +++ b/js/tests/replica-command-runtime.test.mjs @@ -236,7 +236,7 @@ function artifact(options = {}) { }), input: Object.freeze({ kind: 'object', definition: TodoInput }), output: Object.freeze({ kind: 'object', definition: ResultOutput }), - consistency: options.consistency ?? 'causal', + consistency: options.consistency ?? 'eventual', ...(options.modeled === false ? {} : { projection: projection(operation) }), ...(options.directProjection === undefined ? {} @@ -267,7 +267,7 @@ function directProjectionArtifact() { return Object.freeze({ ...artifact({ name: 'todo.project', - consistency: 'projected', + consistency: 'atomic', modeled: false, directProjection: Object.freeze({ topology: Object.freeze({ @@ -381,7 +381,7 @@ function commandMetadata(request, options = {}) { commandId: request.commandId, causationId, state, - consistency: options.consistency ?? 'causal', + consistency: options.consistency ?? 'eventual', expects: [], observations: [], records: [] @@ -451,7 +451,7 @@ function commandMetadata(request, options = {}) { commandId: request.commandId, causationId, state, - consistency: options.consistency ?? 'causal', + consistency: options.consistency ?? 'eventual', expects: obligations.map((obligation) => ({ projection: PROGRAM, model: obligation.model, @@ -927,7 +927,7 @@ test('draining lifecycle status revalidates without applying old-scope delta and commandId: COMMAND_A, causationId: `cause:${COMMAND_A}`, state, - consistency: 'causal', + consistency: 'eventual', projectionDisposition: 'revalidate', expects: [], observations: [], @@ -956,7 +956,7 @@ test('draining lifecycle status revalidates without applying old-scope delta and disposition( statusCalls === 1 ? 'succeeded_pending_projection' - : 'projected' + : 'atomic' ) ) ); @@ -999,7 +999,7 @@ test('draining lifecycle status revalidates without applying old-scope delta and assert.notEqual(replica.layer(COMMAND_A), undefined); terminalRefresh.resolve(); - assert.equal((await terminalStatus).state, 'projected'); + assert.equal((await terminalStatus).state, 'atomic'); assert.equal(replica.replacements.length, 0); assert.equal(replica.layer(COMMAND_A), undefined); assert.equal(replica.record('todo-1'), undefined); @@ -1030,7 +1030,7 @@ test('generated Draining command handles a fresh succeeded response through curr commandId, causationId: `cause:${commandId}`, state: 'succeeded', - consistency: 'causal', + consistency: 'eventual', projectionDisposition: 'revalidate', expects: [], observations: [], @@ -1134,7 +1134,7 @@ test('draining lifecycle live frames keep polling while pending and retire only commandId: commandRequest.commandId, causationId: `cause:${commandRequest.commandId}`, state, - consistency: 'causal', + consistency: 'eventual', projectionDisposition: 'revalidate', expects: [], observations: [], @@ -1193,7 +1193,7 @@ test('draining lifecycle live frames keep polling while pending and retire only terminalObserved = true; runtime.observeResult({ extensions: envelope(commandRequest, { - command: disposition('projected') + command: disposition('atomic') }).extensions }); await tick(); @@ -1210,7 +1210,7 @@ test('draining lifecycle live frames keep polling while pending and retire only runtime.observeResult({ extensions: envelope(commandRequest, { - command: disposition('projected') + command: disposition('atomic') }).extensions }); await tick(); @@ -1221,7 +1221,7 @@ test('draining lifecycle live frames keep polling while pending and retire only assert.equal(backgroundErrors.length, 1); terminalRefresh.resolve(); - assert.equal((await projected).state, 'projected'); + assert.equal((await projected).state, 'atomic'); assert.equal(replica.replacements.length, 1); assert.equal(replica.layer(COMMAND_A), undefined); assert.equal(replica.record('todo-1'), undefined); @@ -1365,9 +1365,9 @@ test('live command state cannot regress before actual projection mutation', asyn runtime.dispose(); }); -for (const statusState of ['projected', 'succeeded_pending_projection']) { +for (const statusState of ['atomic', 'succeeded_pending_projection']) { test(`live terminal progression ${ - statusState === 'projected' + statusState === 'atomic' ? 'permits an idempotent status replay' : 'rejects a later status regression' }`, async () => { @@ -1399,10 +1399,10 @@ for (const statusState of ['projected', 'succeeded_pending_projection']) { ); liveMetadata = Object.freeze({ ...receipt.metadata, - state: 'projected' + state: 'atomic' }); const projected = - statusState === 'projected' + statusState === 'atomic' ? receipt.projected : assert.rejects(receipt.projected, { code: 'REPLICA_COMMAND_PROTOCOL_INVALID' @@ -1412,9 +1412,9 @@ for (const statusState of ['projected', 'succeeded_pending_projection']) { command: liveMetadata }).extensions }); - if (statusState === 'projected') { - assert.equal((await receipt.status()).state, 'projected'); - assert.equal((await projected).state, 'projected'); + if (statusState === 'atomic') { + assert.equal((await receipt.status()).state, 'atomic'); + assert.equal((await projected).state, 'atomic'); } else { await assert.rejects(receipt.status(), { code: 'REPLICA_COMMAND_PROTOCOL_INVALID' @@ -1451,7 +1451,7 @@ test('invalid live progression cannot poison a later valid status transition', a ); projectedMetadata = Object.freeze({ ...receipt.metadata, - state: 'projected' + state: 'atomic' }); const projected = assert.rejects(receipt.projected, { code: 'REPLICA_COMMAND_PROTOCOL_INVALID' @@ -1465,7 +1465,7 @@ test('invalid live progression cannot poison a later valid status transition', a }).extensions }); await projected; - assert.equal((await receipt.status()).state, 'projected'); + assert.equal((await receipt.status()).state, 'atomic'); runtime.dispose(); }); @@ -1802,7 +1802,7 @@ test('delete remains a provisional tombstone while its causal obligation is pend runtime.dispose(); }); -test('direct Projected results retain the canonical record-clock path', async () => { +test('direct Atomic results retain the canonical record-clock path', async () => { const replica = new TestReplica(); const directArtifact = directProjectionArtifact(); const runtime = createReplicaCommandRuntime( @@ -1812,8 +1812,8 @@ test('direct Projected results retain the canonical record-clock path', async () const metadata = { commandId: request.commandId, causationId: `cause:${request.commandId}`, - state: 'projected', - consistency: 'projected', + state: 'atomic', + consistency: 'atomic', expects: [], observations: [], records: [ @@ -1845,7 +1845,7 @@ test('direct Projected results retain the canonical record-clock path', async () { id: 'todo-1', title: 'preview' }, { commandId: COMMAND_A } ); - assert.equal(receipt.state, 'projected'); + assert.equal(receipt.state, 'atomic'); assert.equal(replica.direct.length, 1); assert.equal(replica.record('todo-1').fields.title, 'canonical'); assert.equal(replica.layer(COMMAND_A), undefined); @@ -2170,7 +2170,7 @@ test('every modeled projection requires a coordinator before dispatch or layers' assert.deepEqual(replica.semanticChanges, []); }); -test('unmodeled direct Projected commands do not require a revalidation coordinator', () => { +test('unmodeled direct Atomic commands do not require a revalidation coordinator', () => { const replica = new TestReplica(); replica.revalidate = undefined; const runtime = createReplicaCommandRuntime( @@ -2467,7 +2467,7 @@ test('commands fail before optimism when no authoritative scope is available', a }); for (const scenario of ['older-row', 'newer-row', 'newer-tombstone']) { - test(`Projected direct path fences ${scenario}`, async () => { + test(`Atomic direct path fences ${scenario}`, async () => { const { replica, runtime } = await directProjectionRuntime(); replica.engine.batch((writer) => { if (scenario === 'newer-tombstone') { @@ -2499,6 +2499,80 @@ for (const scenario of ['older-row', 'newer-row', 'newer-tombstone']) { }); } +test('Atomic with portable preview IR does not require an eventual projection-delta response', async () => { + // Direct commands may export the same mutation program for `.applies` + // previews. The response still seals via confirmDirectProjection only — + // no async projection-delta envelope. + const replica = new TestReplica(); + const directWithPreview = Object.freeze({ + ...artifact({ + name: 'todo.project', + consistency: 'atomic', + modeled: true, + directProjection: Object.freeze({ + topology: Object.freeze({ + version: 1, + name: 'todos', + digest: HASH_D + }), + model: Todo.id, + identityFields: Todo.identityFields, + changeEpoch: 'todos-v1' + }) + }), + output: Object.freeze({ + kind: 'object', + definition: Object.freeze({ + name: Todo.id, + fields: Object.freeze([scalar('id', 'ID'), scalar('title')]) + }) + }) + }); + const runtime = createReplicaCommandRuntime( + replica, + { + dispatch(request) { + return Promise.resolve( + envelope(request, { + command: { + commandId: request.commandId, + causationId: `cause:${request.commandId}`, + state: 'atomic', + consistency: 'atomic', + expects: [], + observations: [], + records: [ + { + model: Todo.id, + scopeToken: token('record-scope', 7), + incarnation: '1', + revision: '2', + tombstone: false + } + ] + }, + data: { + [request.mutationField]: { + id: 'todo-1', + title: 'from-handler' + } + } + }) + ); + } + }, + { project: directWithPreview } + ); + const receipt = await runtime.commands.project( + { id: 'todo-1', title: 'preview' }, + { commandId: COMMAND_A } + ); + assert.equal(receipt.result.title, 'from-handler'); + assert.equal((await receipt.projected).state, 'atomic'); + assert.equal(replica.record('todo-1').fields.title, 'from-handler'); + runtime.dispose(); +}); + test('link obligations may name any server-selected affected model', async () => { const replica = new TestReplica(); const runtime = createReplicaCommandRuntime( @@ -2562,7 +2636,7 @@ async function directProjectionRuntime() { const directArtifact = Object.freeze({ ...artifact({ name: 'todo.project', - consistency: 'projected', + consistency: 'atomic', modeled: false, directProjection: Object.freeze({ topology: Object.freeze({ @@ -2592,8 +2666,8 @@ async function directProjectionRuntime() { command: { commandId: request.commandId, causationId: `cause:${request.commandId}`, - state: 'projected', - consistency: 'projected', + state: 'atomic', + consistency: 'atomic', expects: [], observations: [], records: [ diff --git a/js/tests/replica-diagnostics.test.mjs b/js/tests/replica-diagnostics.test.mjs index e90b0205..9b9049b2 100644 --- a/js/tests/replica-diagnostics.test.mjs +++ b/js/tests/replica-diagnostics.test.mjs @@ -181,7 +181,7 @@ const commandArtifact = Object.freeze({ ]) }) }), - consistency: 'causal', + consistency: 'eventual', projection: Object.freeze({ version: 2, deltaWireVersion: 1, @@ -673,7 +673,7 @@ test('replica integration exposes structural normalization, index, layer, receip commandId: 'command-private-a', causationId: 'private-causation', state: 'succeeded_pending_projection', - consistency: 'causal', + consistency: 'eventual', expects: [ { projection: 'todos', diff --git a/js/tests/replica-index-maintenance.test.mjs b/js/tests/replica-index-maintenance.test.mjs index 3825511e..d33c3346 100644 --- a/js/tests/replica-index-maintenance.test.mjs +++ b/js/tests/replica-index-maintenance.test.mjs @@ -758,8 +758,15 @@ test('missing dependencies, claims, and offset windows become precise stale deci ]) ] )[0]; - assert.equal(decision.kind, 'stale'); - assert.equal(decision.reason.code, 'insert_changes_offset_window'); + // Full first page still accepts inserts: re-sort + truncate to limit. + assert.equal(decision.kind, 'write'); + assert.equal(decision.records.length, 10); + assert.equal(decision.records[0], replicaRecordKey(Todo, 'offset')); + assert.equal( + decision.records.includes(replicaRecordKey(Todo, 'base-9')), + false, + 'drops the worst page member after the optimistic insert' + ); const first = record(Todo, 'first', { id: 'first', @@ -785,10 +792,10 @@ test('missing dependencies, claims, and offset windows become precise stale deci coverage: Object.freeze(coverage) }) }); + // Mismatched offset / returned still fail closed. for (const coverage of [ { kind: 'offset', offset: 1, limit: 10, returned: 2 }, - { kind: 'offset', offset: 0, limit: 10, returned: 1 }, - { kind: 'offset', offset: 0, limit: 10, returned: 2, hasNext: true } + { kind: 'offset', offset: 0, limit: 10, returned: 1 } ]) { decision = offsetRegistry.evaluate( snapshot([first, boundary], [forgedCoverage(coverage)]), @@ -806,6 +813,37 @@ test('missing dependencies, claims, and offset windows become precise stale deci assert.equal(decision.kind, 'stale'); assert.equal(decision.reason.code, 'invalid_index_metadata'); } + // hasNext on the first page does not block optimistic inserts. + decision = offsetRegistry.evaluate( + snapshot( + [first, boundary], + [ + forgedCoverage({ + kind: 'offset', + offset: 0, + limit: 10, + returned: 2, + hasNext: true + }) + ] + ), + [ + layer('has-next-insert', [ + upsert(Todo, 'third', { + id: 'third', + active: true, + rank: 0, + tenantId: 'tenant-1' + }) + ]) + ] + )[0]; + assert.equal(decision.kind, 'write'); + assert.deepEqual(decision.records, [ + replicaRecordKey(Todo, 'third'), + first.key, + boundary.key + ]); }); test('stacked local offset inserts preserve the exact first-page limit', () => { diff --git a/js/tests/replica-protocol.test.mjs b/js/tests/replica-protocol.test.mjs index 39c682b0..e49f4124 100644 --- a/js/tests/replica-protocol.test.mjs +++ b/js/tests/replica-protocol.test.mjs @@ -472,7 +472,7 @@ function commandMetadata(options = {}) { commandId: options.commandId ?? 'cmd-1', causationId: options.causationId ?? 'cause-1', state: options.state ?? 'succeeded_pending_projection', - consistency: 'causal', + consistency: 'eventual', expects: [ { projection: 'todos-projector', @@ -568,7 +568,7 @@ test('authoritative revalidation succeeds against confirmed data while a server- fetches[1].resolve( frame('2', [ { id: 'todo-1', title: 'base' }, - { id: 'todo-2', title: 'projected' } + { id: 'todo-2', title: 'atomic' } ]) ); await new Promise((resolve) => setImmediate(resolve)); @@ -589,7 +589,7 @@ test('authoritative revalidation succeeds against confirmed data while a server- fetches[2].resolve( frame('3', [ { id: 'todo-1', title: 'base' }, - { id: 'todo-2', title: 'projected' } + { id: 'todo-2', title: 'atomic' } ]) ); await revalidation; @@ -605,7 +605,7 @@ test('authoritative revalidation succeeds against confirmed data while a server- assert.equal(watch.get().stale, false); assert.deepEqual(watch.get().data.todos, [ { id: 'todo-1', title: 'base' }, - { id: 'todo-2', title: 'projected' } + { id: 'todo-2', title: 'atomic' } ]); watch.destroy(); }); @@ -1890,7 +1890,7 @@ test('only exact causation and expectation observations retire optimism', () => write(replica, { position: '3', revision: '3', - rows: [{ id: 'todo-1', title: 'projected' }], + rows: [{ id: 'todo-1', title: 'atomic' }], observations: [ { causationId: 'cause-1', @@ -1900,7 +1900,7 @@ test('only exact causation and expectation observations retire optimism', () => } ] }); - assert.equal(replica.read(Todos, {}).data.todos[0].title, 'projected'); + assert.equal(replica.read(Todos, {}).data.todos[0].title, 'atomic'); }); test('discarded or incomplete snapshots cannot use observations to retire optimism', () => { diff --git a/js/tests/replica-query-plan.test.mjs b/js/tests/replica-query-plan.test.mjs index 409650d6..5ae40b99 100644 --- a/js/tests/replica-query-plan.test.mjs +++ b/js/tests/replica-query-plan.test.mjs @@ -1153,6 +1153,7 @@ test('pagination plans make complete and offset maintenance decisions explicit', }).decision, 'local' ); + // OFFSET_PAGINATION marks insert/delete/reorder as revalidate (policy gate). assert.equal( decideReplicaPaginationMaintenance(OFFSET_PAGINATION, offset, { kind: 'insert' @@ -1244,7 +1245,7 @@ test('pagination refuses unknown, mismatched, unsafe offset, and unproven cursor ); }); -test('offset locality requires a proven non-full first page', () => { +test('offset locality: first-page insert always; delete/reorder need non-full page', () => { const safe = { kind: 'offset', offset: 0, limit: 10, returned: 9 }; for (const kind of ['insert', 'delete', 'reorder', 'stable_update']) { assert.equal( @@ -1257,11 +1258,21 @@ test('offset locality requires a proven non-full first page', () => { ); } + // Full first page: insert stays local; delete/reorder still fail closed. + assert.equal( + decideReplicaPaginationMaintenance( + LOCAL_OFFSET_PAGINATION, + { kind: 'offset', offset: 0, limit: 10, returned: 10 }, + { kind: 'insert' } + ).decision, + 'local' + ); + for (const [coverage, kind, code] of [ [ { kind: 'offset', offset: 0, limit: 10, returned: 10 }, - 'insert', - 'insert_changes_offset_window' + 'delete', + 'delete_changes_offset_window' ], [ { kind: 'offset', offset: 1, limit: 10, returned: 1 }, @@ -1272,6 +1283,11 @@ test('offset locality requires a proven non-full first page', () => { { kind: 'offset', offset: 0, limit: 10 }, 'reorder', 'reorder_changes_offset_window' + ], + [ + { kind: 'offset', offset: 1, limit: 10, returned: 1 }, + 'insert', + 'insert_changes_offset_window' ] ]) { assert.equal( diff --git a/js/tests/sveltekit-adapter.test.mjs b/js/tests/sveltekit-adapter.test.mjs index 649f66ef..09bc3a94 100644 --- a/js/tests/sveltekit-adapter.test.mjs +++ b/js/tests/sveltekit-adapter.test.mjs @@ -289,7 +289,7 @@ test('caller projection cancellation does not hide a globally pending command', resolveGlobal({ commandId: receipt.commandId, - state: 'projected' + state: 'atomic' }); await flushMicrotasks(); assert.deepEqual(query.get().pending, []); diff --git a/migrations/postgres/0004_command_ledger_atomic_state.sql b/migrations/postgres/0004_command_ledger_atomic_state.sql new file mode 100644 index 00000000..75562522 --- /dev/null +++ b/migrations/postgres/0004_command_ledger_atomic_state.sql @@ -0,0 +1,72 @@ +-- Rename terminal command-ledger state projected → atomic (aggregate+read-model +-- same-tx completion). Drop CHECKs first, rewrite rows, then re-add CHECKs. + +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT c.conname + FROM pg_constraint c + WHERE c.conrelid = 'command_ledger'::regclass + AND c.contype = 'c' + AND ( + pg_get_constraintdef(c.oid) LIKE '%projected%' + OR pg_get_constraintdef(c.oid) LIKE '%state IN%' + ) + LOOP + EXECUTE format('ALTER TABLE command_ledger DROP CONSTRAINT %I', r.conname); + END LOOP; +END $$; + +UPDATE command_ledger +SET state = 'atomic' +WHERE state = 'projected'; + +ALTER TABLE command_ledger + ADD CONSTRAINT command_ledger_state_values_check CHECK (state IN ( + 'in_progress', + 'retryable_unknown', + 'succeeded', + 'succeeded_pending_projection', + 'atomic', + 'rejected', + 'projection_failed', + 'expired' + )); + +ALTER TABLE command_ledger + ADD CONSTRAINT command_ledger_state_shape_check CHECK ( + (state = 'in_progress' + AND attempt_token IS NOT NULL + AND lease_expires_at IS NOT NULL + AND outcome IS NULL + AND completed_at IS NULL + AND compacted_at IS NULL) + OR + (state = 'retryable_unknown' + AND attempt_token IS NULL + AND lease_expires_at IS NULL + AND outcome IS NULL + AND completed_at IS NULL + AND compacted_at IS NULL) + OR + (state IN ( + 'succeeded', + 'succeeded_pending_projection', + 'atomic', + 'rejected', + 'projection_failed' + ) + AND attempt_token IS NULL + AND lease_expires_at IS NULL + AND outcome IS NOT NULL + AND completed_at IS NOT NULL + AND compacted_at IS NULL) + OR + (state = 'expired' + AND attempt_token IS NULL + AND lease_expires_at IS NULL + AND outcome IS NULL + AND compacted_at IS NOT NULL) + ); diff --git a/migrations/sqlite/0004_command_ledger_atomic_state.sql b/migrations/sqlite/0004_command_ledger_atomic_state.sql new file mode 100644 index 00000000..ede6fe21 --- /dev/null +++ b/migrations/sqlite/0004_command_ledger_atomic_state.sql @@ -0,0 +1,122 @@ +-- SQLite cannot ALTER CHECK constraints in place. Rebuild command_ledger with +-- atomic as the same-tx terminal state (was projected). + +CREATE TABLE command_ledger_atomic ( + service_id TEXT NOT NULL, + principal_partition TEXT NOT NULL, + command_id TEXT NOT NULL, + command_name TEXT NOT NULL, + command_contract_hash BLOB NOT NULL, + input_hash BLOB NOT NULL, + state TEXT NOT NULL, + causation_id TEXT NOT NULL, + attempt_token TEXT, + attempt_number INTEGER NOT NULL, + lease_expires_at REAL, + outcome TEXT, + created_at REAL NOT NULL DEFAULT (unixepoch('now','subsec')), + updated_at REAL NOT NULL DEFAULT (unixepoch('now','subsec')), + completed_at REAL, + retention_expires_at REAL NOT NULL, + compacted_at REAL, + PRIMARY KEY (service_id, principal_partition, command_id), + UNIQUE (service_id, causation_id), + CHECK (service_id <> ''), + CHECK (principal_partition <> ''), + CHECK (command_id <> ''), + CHECK (command_name <> ''), + CHECK (typeof(command_contract_hash) = 'blob' AND length(command_contract_hash) = 32), + CHECK (typeof(input_hash) = 'blob' AND length(input_hash) = 32), + CHECK (causation_id <> ''), + CHECK (attempt_number > 0), + CHECK (outcome IS NULL OR json_valid(outcome)), + CHECK (state IN ( + 'in_progress', + 'retryable_unknown', + 'succeeded', + 'succeeded_pending_projection', + 'atomic', + 'rejected', + 'projection_failed', + 'expired' + )), + CHECK ( + (state = 'in_progress' + AND attempt_token IS NOT NULL + AND lease_expires_at IS NOT NULL + AND outcome IS NULL + AND completed_at IS NULL + AND compacted_at IS NULL) + OR + (state = 'retryable_unknown' + AND attempt_token IS NULL + AND lease_expires_at IS NULL + AND outcome IS NULL + AND completed_at IS NULL + AND compacted_at IS NULL) + OR + (state IN ( + 'succeeded', + 'succeeded_pending_projection', + 'atomic', + 'rejected', + 'projection_failed' + ) + AND attempt_token IS NULL + AND lease_expires_at IS NULL + AND outcome IS NOT NULL + AND completed_at IS NOT NULL + AND compacted_at IS NULL) + OR + (state = 'expired' + AND attempt_token IS NULL + AND lease_expires_at IS NULL + AND outcome IS NULL + AND compacted_at IS NOT NULL) + ) +); + +INSERT INTO command_ledger_atomic ( + service_id, + principal_partition, + command_id, + command_name, + command_contract_hash, + input_hash, + state, + causation_id, + attempt_token, + attempt_number, + lease_expires_at, + outcome, + created_at, + updated_at, + completed_at, + retention_expires_at, + compacted_at +) +SELECT + service_id, + principal_partition, + command_id, + command_name, + command_contract_hash, + input_hash, + CASE state WHEN 'projected' THEN 'atomic' ELSE state END, + causation_id, + attempt_token, + attempt_number, + lease_expires_at, + outcome, + created_at, + updated_at, + completed_at, + retention_expires_at, + compacted_at +FROM command_ledger; +DROP TABLE command_ledger; +ALTER TABLE command_ledger_atomic RENAME TO command_ledger; + +CREATE INDEX IF NOT EXISTS command_ledger_retention_idx + ON command_ledger (retention_expires_at) + WHERE state <> 'expired'; diff --git a/src/command_ledger/record.rs b/src/command_ledger/record.rs index ebdc7cf6..6f62f15c 100644 --- a/src/command_ledger/record.rs +++ b/src/command_ledger/record.rs @@ -421,7 +421,7 @@ impl CommandLedgerRecord { } let direct_projection = envelope.remove("direct_projection"); match (&direct_projection, self.state) { - (Some(value), CommandLedgerState::Projected) => { + (Some(value), CommandLedgerState::Atomic) => { SameTransactionProjectionEvidence::validate_replay_value(value).map_err( |error| { CommandLedgerError::Corrupt(format!( @@ -431,7 +431,7 @@ impl CommandLedgerRecord { }, )?; } - (None, CommandLedgerState::Projected) => { + (None, CommandLedgerState::Atomic) => { return Err(CommandLedgerError::Corrupt(format!( "command `{}` projected replay has no exact direct projection evidence", self.key.command_id() diff --git a/src/command_ledger/reservation.rs b/src/command_ledger/reservation.rs index f4db1192..a22d273a 100644 --- a/src/command_ledger/reservation.rs +++ b/src/command_ledger/reservation.rs @@ -482,7 +482,7 @@ impl CommandCompletion { &mut self, evidence: &SameTransactionProjectionEvidence, ) -> Result<(), CommandLedgerError> { - if self.state != TerminalCommandState::Projected { + if self.state != TerminalCommandState::Atomic { return Err(CommandLedgerError::Invalid( "direct projection evidence may only complete a projected command".into(), )); @@ -525,14 +525,14 @@ impl CommandCompletion { pub(super) fn validate_direct_projection(&self) -> Result<(), CommandLedgerError> { match (self.state, self.direct_projection.is_some()) { - (TerminalCommandState::Projected, true) + (TerminalCommandState::Atomic, true) | ( TerminalCommandState::Succeeded | TerminalCommandState::SucceededPendingProjection | TerminalCommandState::Rejected, false, ) => Ok(()), - (TerminalCommandState::Projected, false) => Err(CommandLedgerError::Invalid( + (TerminalCommandState::Atomic, false) => Err(CommandLedgerError::Invalid( "projected command completion has no exact direct projection evidence".into(), )), (_, true) => Err(CommandLedgerError::Invalid( diff --git a/src/command_ledger/state.rs b/src/command_ledger/state.rs index 459d3005..57fb861e 100644 --- a/src/command_ledger/state.rs +++ b/src/command_ledger/state.rs @@ -12,7 +12,8 @@ pub(crate) enum CommandLedgerState { RetryableUnknown, Succeeded, SucceededPendingProjection, - Projected, + /// Terminal for an **atomic** command (same-tx read-model row sealed). + Atomic, Rejected, ProjectionFailed, Expired, @@ -25,7 +26,7 @@ impl CommandLedgerState { Self::RetryableUnknown => "retryable_unknown", Self::Succeeded => "succeeded", Self::SucceededPendingProjection => "succeeded_pending_projection", - Self::Projected => "projected", + Self::Atomic => "atomic", Self::Rejected => "rejected", Self::ProjectionFailed => "projection_failed", Self::Expired => "expired", @@ -38,7 +39,7 @@ impl CommandLedgerState { "retryable_unknown" => Ok(Self::RetryableUnknown), "succeeded" => Ok(Self::Succeeded), "succeeded_pending_projection" => Ok(Self::SucceededPendingProjection), - "projected" => Ok(Self::Projected), + "atomic" => Ok(Self::Atomic), "rejected" => Ok(Self::Rejected), "projection_failed" => Ok(Self::ProjectionFailed), "expired" => Ok(Self::Expired), @@ -53,7 +54,7 @@ impl CommandLedgerState { self, Self::Succeeded | Self::SucceededPendingProjection - | Self::Projected + | Self::Atomic | Self::Rejected | Self::ProjectionFailed ) @@ -65,7 +66,7 @@ impl CommandLedgerState { pub(crate) enum TerminalCommandState { Succeeded, SucceededPendingProjection, - Projected, + Atomic, Rejected, } @@ -74,7 +75,7 @@ impl From for CommandLedgerState { match value { TerminalCommandState::Succeeded => Self::Succeeded, TerminalCommandState::SucceededPendingProjection => Self::SucceededPendingProjection, - TerminalCommandState::Projected => Self::Projected, + TerminalCommandState::Atomic => Self::Atomic, TerminalCommandState::Rejected => Self::Rejected, } } @@ -86,7 +87,7 @@ pub(super) fn validate_projection_obligation_semantics( ) -> Result<(), String> { match state { CommandLedgerState::Succeeded - | CommandLedgerState::Projected + | CommandLedgerState::Atomic | CommandLedgerState::Rejected => { if !obligations.is_empty() { return Err(format!( diff --git a/src/command_ledger/tests.rs b/src/command_ledger/tests.rs index bbf77b8a..da9e426e 100644 --- a/src/command_ledger/tests.rs +++ b/src/command_ledger/tests.rs @@ -466,10 +466,7 @@ where TerminalCommandState::SucceededPendingProjection, CommandLedgerState::SucceededPendingProjection, ), - ( - TerminalCommandState::Projected, - CommandLedgerState::Projected, - ), + (TerminalCommandState::Atomic, CommandLedgerState::Atomic), (TerminalCommandState::Rejected, CommandLedgerState::Rejected), ]; @@ -498,7 +495,7 @@ where ) .unwrap(); let expected_direct_projection = - (terminal_state == TerminalCommandState::Projected).then(|| { + (terminal_state == TerminalCommandState::Atomic).then(|| { let evidence = direct_projection_evidence(&format!("terminal-{index}")); completion.attach_direct_projection(&evidence).unwrap(); evidence.replay_value() @@ -1073,7 +1070,7 @@ fn stale_attempt_cannot_complete_after_reclaim() { fn completion_rejects_inconsistent_projection_obligation_states() { for state in [ TerminalCommandState::Succeeded, - TerminalCommandState::Projected, + TerminalCommandState::Atomic, TerminalCommandState::Rejected, ] { assert!(matches!( @@ -1099,7 +1096,7 @@ fn completion_rejects_inconsistent_projection_obligation_states() { for state in [ TerminalCommandState::Succeeded, - TerminalCommandState::Projected, + TerminalCommandState::Atomic, TerminalCommandState::Rejected, ] { assert!(fresh_attempt() @@ -1159,7 +1156,7 @@ fn completion_rejects_malformed_projection_obligations() { fn replay_rejects_inconsistent_projection_obligation_states() { for state in [ CommandLedgerState::Succeeded, - CommandLedgerState::Projected, + CommandLedgerState::Atomic, CommandLedgerState::Rejected, ] { let row = completed_replay_record(state, vec![resolved_obligation("unexpected")]); @@ -1345,7 +1342,7 @@ fn modeled_projection_metadata_bounds_fail_before_completion() { } assert!(matches!( fresh_attempt().complete_with_projection_metadata( - TerminalCommandState::Projected, + TerminalCommandState::Atomic, serde_json::json!({"ok": true}), b"{}".to_vec(), Duration::from_secs(300), @@ -1720,7 +1717,7 @@ async fn sqlite_adapter_enforces_attempt_fence_and_replays() { let completion = second .complete( - TerminalCommandState::Projected, + TerminalCommandState::Atomic, serde_json::json!({"winner": true}), Duration::from_secs(300), ) @@ -1736,7 +1733,7 @@ async fn sqlite_adapter_enforces_attempt_fence_and_replays() { .unwrap() { CommandLookup::Replay(replay) => { - assert_eq!(replay.state, CommandLedgerState::Projected); + assert_eq!(replay.state, CommandLedgerState::Atomic); assert_eq!(replay.causation_id, cause); assert_eq!(replay.outcome, serde_json::json!({"winner": true})); } @@ -1771,7 +1768,7 @@ fn durable_success_states_use_only_the_succeeded_vocabulary() { CommandLedgerState::SucceededPendingProjection, "succeeded_pending_projection", ), - (CommandLedgerState::Projected, "projected"), + (CommandLedgerState::Atomic, "atomic"), ]; for (state, encoded) in cases { diff --git a/src/graphql/client_manifest/build.rs b/src/graphql/client_manifest/build.rs index 6f8f8bed..67543004 100644 --- a/src/graphql/client_manifest/build.rs +++ b/src/graphql/client_manifest/build.rs @@ -317,8 +317,8 @@ pub(super) fn client_manifest_from_surface_with_execution( version: 1, kind: match command.consistency { CommandConsistency::Succeeded => "succeeded", - CommandConsistency::Causal => "causal", - CommandConsistency::Projected => "projected", + CommandConsistency::Eventual => "eventual", + CommandConsistency::Atomic => "atomic", } .into(), }; diff --git a/src/graphql/client_manifest/commands.rs b/src/graphql/client_manifest/commands.rs index 16294f34..b2f43cb5 100644 --- a/src/graphql/client_manifest/commands.rs +++ b/src/graphql/client_manifest/commands.rs @@ -4,7 +4,7 @@ pub(super) fn command_direct_projection_extension( command: &SurfaceCommand, surface: &Surface, ) -> Result, ClientManifestError> { - let projected = command.consistency == CommandConsistency::Projected; + let projected = command.consistency == CommandConsistency::Atomic; let Some(target) = command.direct_projection.as_ref() else { return if projected { Err(ClientManifestError(format!( diff --git a/src/graphql/client_manifest/limits.rs b/src/graphql/client_manifest/limits.rs index 95cbb264..f19307d5 100644 --- a/src/graphql/client_manifest/limits.rs +++ b/src/graphql/client_manifest/limits.rs @@ -22,6 +22,7 @@ impl Default for ClientExecutionLimits { } impl ClientExecutionLimits { + #[cfg(feature = "graphql")] pub(crate) fn from_runtime( max_depth: usize, max_complexity: usize, diff --git a/src/graphql/client_manifest/projections.rs b/src/graphql/client_manifest/projections.rs index f8f7c9af..a37d66d9 100644 --- a/src/graphql/client_manifest/projections.rs +++ b/src/graphql/client_manifest/projections.rs @@ -106,7 +106,10 @@ pub(super) fn command_projection_extension( let mut slot_origins = Vec::new(); for owner in &surface.projectors { for modeled in &owner.modeled { - if !modeled.is_causally_eligible() { + // Preview composition uses portable mutation IR for both Eventual + // and Direct placements. Async causal obligations still use + // `is_causally_eligible` (Eventual-only) elsewhere. + if !modeled.is_preview_eligible() { continue; } let Some(program) = modeled.selected_program() else { @@ -1560,10 +1563,17 @@ mod tests { } #[test] - fn direct_bindings_export_inventory_without_an_executable_program() { - let surface = surface_with_modeled([modeled(6, ProjectionPlacement::Direct, None)]); + fn direct_bindings_export_program_and_command_previews() { + // Same mutation IR as eventual; server apply site is the command handler + // (Projected). Client still composes .applies previews from the program. + let selector = typed_selector::(); + let surface = surface_with_modeled([modeled( + 6, + ProjectionPlacement::Direct, + Some(selected_program("direct-preview", [selector])), + )]); let (programs, bindings) = projection_manifest(&surface).unwrap(); - assert!(programs.is_empty()); + assert_eq!(programs.len(), 1); assert_eq!(bindings.len(), 1); assert_eq!(bindings[0].placement, ClientProjectionPlacement::Direct); @@ -1577,12 +1587,21 @@ mod tests { CommandProjectionPreviewSource::input(["value"]), ), ); - assert!( - command_projection_extension(&command, &surface, &[]) - .unwrap() - .is_none(), - "direct projections are reconciled by the projected response, not event previews" - ); + let projection = command_projection_extension(&command, &surface, &[]) + .unwrap() + .expect("direct placement still exports applies previews"); + assert_eq!(projection.event_set.len(), 1); + assert_eq!(projection.preview_occurrences.len(), 1); + assert_eq!(projection.program_arms.len(), 1); + } + + #[test] + fn direct_binding_without_selected_program_exports_inventory_only() { + let surface = surface_with_modeled([modeled(7, ProjectionPlacement::Direct, None)]); + let (programs, bindings) = projection_manifest(&surface).unwrap(); + assert!(programs.is_empty()); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].placement, ClientProjectionPlacement::Direct); } #[test] diff --git a/src/graphql/client_manifest/tests.rs b/src/graphql/client_manifest/tests.rs index 1ae703ad..c5ddc0f3 100644 --- a/src/graphql/client_manifest/tests.rs +++ b/src/graphql/client_manifest/tests.rs @@ -1,8 +1,9 @@ use super::*; use crate::graphql::{ build_surface, claim, col, rel, surface_for_application, surface_for_role, typed_command, - Causal, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, PreparedCommand, - RoleGrant, Succeeded, SurfaceCommand, SurfaceOptions, SurfaceProjector, SurfaceTypeField, + Eventual, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, + PreparedCommand, RoleGrant, Succeeded, SurfaceCommand, SurfaceOptions, SurfaceProjector, + SurfaceTypeField, }; use crate::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; use crate::table::{ @@ -253,9 +254,9 @@ async fn complete_handler( async fn complete_causal_handler( _context: &CausalCommandContext<'_, ManifestAggregate>, _input: CompleteInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { Ok( - PreparedCommand::>::prepare(CompletePayload) + PreparedCommand::>::prepare(CompletePayload) .expect("serializable command payload"), ) } @@ -304,11 +305,11 @@ fn causal_full_surface() -> Surface { crate::InMemoryRepository::new(), )) .typed_command( - typed_command::>("todo.complete") + typed_command::>("todo.complete") .field_name("todos_complete") .roles(["admin", "user"]) .emits(crate::events![ManifestTodoProjected]) - .preview(crate::event_preview! { + .applies(crate::event_preview! { ManifestTodoProjected => ManifestTodoProjected { todo_id: input.todo_id, ..unknown @@ -516,7 +517,7 @@ fn projected_surface() -> Surface { name: todo_model.object_name, fields: output_fields, }), - consistency: CommandConsistency::Projected, + consistency: CommandConsistency::Atomic, input_defaults: Vec::new(), effects: Some(CommandEffects::revalidate()), confirmations: Vec::new(), @@ -760,7 +761,7 @@ fn role_manifest_is_deterministic_and_hides_denied_identity_and_commands() { assert_eq!(first.schema_fingerprint, second.schema_fingerprint); assert_eq!( first.schema_fingerprint, - "sha256:a3c6c2b019a334c393c6c97e7879d2de9830b48c4eaa002051035857a5c0dd81" + "sha256:d170cb2de47ed71c0127206a5a42970abff278cec2bcb494551da554053f3a83" ); assert_eq!( first.protocol_fingerprint, @@ -805,7 +806,7 @@ fn role_manifest_is_deterministic_and_hides_denied_identity_and_commands() { .iter() .find(|rel| rel.name == "owner") .unwrap(); - assert!(!owner.nullable); + assert!(owner.nullable); assert_eq!(owner.key_mapping, RelationshipKeyMapping::Embedded); assert_eq!(owner.maintenance, ClientRelationshipMaintenance::Revalidate); assert_eq!(owner.dependencies, vec!["todos", "users"]); @@ -1482,7 +1483,7 @@ fn filter_execution_limits_are_schema_fingerprinted_without_changing_protocol_ep } #[test] -fn relationship_nullability_is_copied_from_the_authoritative_surface() { +fn belongs_to_relationships_stay_nullable_independent_of_fk_storage() { let mut fingerprints = Vec::new(); for nullable in [false, true] { let mut todo_schema = todos(); @@ -1508,7 +1509,7 @@ fn relationship_nullability_is_copied_from_the_authoritative_surface() { .find(|relationship| relationship.name == "owner") .expect("surface relationship") .nullable; - assert_eq!(surface_nullable, nullable); + assert!(surface_nullable); let manifest = client_manifest_from_surface( "todos-service", @@ -1527,11 +1528,11 @@ fn relationship_nullability_is_copied_from_the_authoritative_surface() { .find(|relationship| relationship.name == "owner") .unwrap(); assert_eq!(owner.nullable, surface_nullable); - assert_eq!(serde_json::to_value(owner).unwrap()["nullable"], nullable); + assert_eq!(serde_json::to_value(owner).unwrap()["nullable"], true); } assert_ne!( fingerprints[0], fingerprints[1], - "relationship nullability is part of the schema fingerprint" + "foreign-key storage nullability remains part of the schema fingerprint" ); } diff --git a/src/graphql/client_manifest/types.rs b/src/graphql/client_manifest/types.rs index e33139c4..348f0045 100644 --- a/src/graphql/client_manifest/types.rs +++ b/src/graphql/client_manifest/types.rs @@ -512,7 +512,7 @@ pub struct CommandConsistencyExtension { pub kind: String, } -/// Opaque same-transaction target for one `Projected` command. +/// Opaque same-transaction target for one `Atomic` command. /// /// The topology digest binds the scope-codec version, accepted facts, complete /// owned schemas, partition declaration, and physical ownership on the server. diff --git a/src/graphql/command_contract/direct_projection.rs b/src/graphql/command_contract/direct_projection.rs index 7a0f970b..e454b66f 100644 --- a/src/graphql/command_contract/direct_projection.rs +++ b/src/graphql/command_contract/direct_projection.rs @@ -17,7 +17,7 @@ use crate::read_model::RelationalReadModel; use crate::table::{TableMutation, TableSchema}; use crate::{ProjectionProgramId, ResolvedProjectionPlan}; -/// Compiler-retained relational identity for one ordinary `Projected` +/// Compiler-retained relational identity for one ordinary `Atomic` /// declaration before the GraphQL Surface resolves its unique physical owner. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CommandProjectedModel { @@ -102,7 +102,7 @@ impl CommandProjectedModel { } } -/// Compiler-owned direct target for one `Projected` command. +/// Compiler-owned direct target for one `Atomic` command. /// /// This metadata is deliberately hidden from ordinary handler code. Generated /// declarations bind it once; application handlers select the narrow diff --git a/src/graphql/command_contract/mod.rs b/src/graphql/command_contract/mod.rs index e8c7005c..5e28c303 100644 --- a/src/graphql/command_contract/mod.rs +++ b/src/graphql/command_contract/mod.rs @@ -4,7 +4,7 @@ //! handler may prepare a typed payload, but it cannot choose which projection //! confirmations count. That finite plan belongs to the command declaration, //! and only the framework-owned command-ledger committer may turn a preparation into -//! an [`Succeeded`], [`Causal`], or [`Projected`] value. +//! an [`Succeeded`], [`Eventual`], or [`Atomic`] value. #![cfg_attr(not(feature = "graphql"), allow(dead_code))] @@ -44,7 +44,7 @@ pub(crate) use effects::{ EffectRelationship, }; pub use outcomes::{ - Causal, CommandConsistency, CommandOutcome, PrepareCommandError, PreparedCommand, Projected, + Atomic, CommandConsistency, CommandOutcome, Eventual, PrepareCommandError, PreparedCommand, Succeeded, }; pub(crate) use projection_obligations::{ diff --git a/src/graphql/command_contract/outcomes.rs b/src/graphql/command_contract/outcomes.rs index b1a8bbb6..d279e59c 100644 --- a/src/graphql/command_contract/outcomes.rs +++ b/src/graphql/command_contract/outcomes.rs @@ -21,10 +21,11 @@ pub enum CommandConsistency { /// The command transaction succeeded. With no confirmation plan this is /// terminal; with an explicit finite plan it is pending projection. Succeeded, - /// Domain events were committed and declared projectors are expected. - Causal, - /// The returned view was committed in the command transaction. - Projected, + /// Aggregate committed; read models update **eventually** (projectors / + /// event handlers after the command transaction). + Eventual, + /// Aggregate + read-model row in the **same** command transaction. + Atomic, } mod sealed { @@ -41,23 +42,25 @@ pub struct Succeeded { payload: T, } -/// A committed command result with finite causal projection obligations. +/// Eventual aggregate + read-model update: domain events committed; projectors +/// apply later. Client may use `.applies` previews until obligations complete. /// /// There is intentionally no public constructor. The durable command /// committer is the only framework component allowed to create this wrapper. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Causal { +pub struct Eventual { payload: T, } -/// A committed same-transaction projection result. +/// Atomic aggregate + read-model update: exact row staged and returned in the +/// command transaction (`readmodel(row).…commit()?.atomic()`). /// /// There is intentionally no public constructor. The durable command /// committer is the only framework component allowed to create this wrapper. /// `T` must be a relational read model, and preparation is available only /// through the framework-owned workspace that stages the exact row upsert. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Projected { +pub struct Atomic { payload: T, } @@ -88,16 +91,16 @@ macro_rules! committed_outcome { } committed_outcome!(Succeeded, CommandConsistency::Succeeded); -committed_outcome!(Causal, CommandConsistency::Causal); +committed_outcome!(Eventual, CommandConsistency::Eventual); -impl sealed::Outcome for Projected where T: RelationalReadModel {} +impl sealed::Outcome for Atomic where T: RelationalReadModel {} -impl CommandOutcome for Projected +impl CommandOutcome for Atomic where T: RelationalReadModel + Serialize + Send + Sync + 'static, { type Payload = T; - const CONSISTENCY: CommandConsistency = CommandConsistency::Projected; + const CONSISTENCY: CommandConsistency = CommandConsistency::Atomic; fn payload(&self) -> &T { &self.payload @@ -143,9 +146,9 @@ macro_rules! crate_committed_constructor { } crate_committed_constructor!(Succeeded); -crate_committed_constructor!(Causal); +crate_committed_constructor!(Eventual); -impl Projected +impl Atomic where T: RelationalReadModel, { @@ -156,7 +159,7 @@ where } impl sealed::PreparableOutcome for Succeeded {} -impl sealed::PreparableOutcome for Causal {} +impl sealed::PreparableOutcome for Eventual {} /// Sealed type-level contract implemented by committed command outcomes. pub trait CommandOutcome: sealed::Outcome + Send + Sync + 'static { @@ -172,8 +175,8 @@ pub trait CommandOutcome: sealed::Outcome + Send + Sync + 'static { fn __graphql_output_type() -> GraphqlTypeDef; /// Compiler-only model identity retained by an ordinary - /// `typed_command::>` declaration. The sealed default keeps - /// succeeded/causal outcomes unbound while `Projected` supplies its exact + /// `typed_command::>` declaration. The sealed default keeps + /// succeeded/eventual outcomes unbound while `Atomic` supplies its exact /// relational schema without an application-facing projection target API. #[doc(hidden)] fn __projected_model() -> Option<(TypeId, &'static TableSchema)> { @@ -285,13 +288,13 @@ impl PreparedCommand { } match K::CONSISTENCY { - CommandConsistency::Succeeded | CommandConsistency::Causal => { + CommandConsistency::Succeeded | CommandConsistency::Eventual => { if self.projection_proof.is_some() || modeled_direct_plan.is_some() { return Err(CommandCommitProofError::UnexpectedProjectionProof); } contract.validate_outbox_fact_coverage(outbox_messages) } - CommandConsistency::Projected => { + CommandConsistency::Atomic => { if !has_staged_aggregate_events && outbox_messages.is_empty() { return Err(CommandCommitProofError::DurableEventMissing); } @@ -389,7 +392,7 @@ impl PreparedCommand { /// Remove the proof-matched projected upsert from ordinary table plans and /// seal it as the repository's causal direct-projection participant. /// - /// Succeeded and Causal commands return no participant. A Projected command + /// Succeeded and Eventual commands return no participant. An Atomic command /// must have exactly one resolved declaration-owned target; the extracted /// mutation is never also submitted through the legacy/raw plan path. pub(crate) fn seal_direct_projection( @@ -400,13 +403,13 @@ impl PreparedCommand { causation_id: &str, ) -> Result, CommandCommitProofError> { match K::CONSISTENCY { - CommandConsistency::Succeeded | CommandConsistency::Causal => { + CommandConsistency::Succeeded | CommandConsistency::Eventual => { if target.is_some() || modeled_direct_plan.is_some() { return Err(CommandCommitProofError::UnexpectedDirectProjectionTarget); } Ok(None) } - CommandConsistency::Projected => { + CommandConsistency::Atomic => { let target = target.ok_or(CommandCommitProofError::MissingDirectProjectionTarget)?; let proof = self @@ -447,13 +450,13 @@ impl PreparedCommand { } } -impl PreparedCommand> +impl PreparedCommand> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { /// Build a projected completion from the exact model value whose full-row /// upsert was staged by the framework-owned causal workspace. - pub(crate) fn prepare_projected( + pub(crate) fn prepare_atomic( payload: M, proof: ProjectionCommitProof, ) -> Result { @@ -467,7 +470,7 @@ where }) } - pub(crate) fn prepare_modeled_projected() -> Self { + pub(crate) fn prepare_modeled_atomic() -> Self { Self { payload: None, serialized_payload: None, @@ -483,7 +486,7 @@ where K: CommandOutcome + sealed::PreparableOutcome, { /// Prepare a succeeded or causal payload for the durable committer. - /// Projected results require a staged transactional proof and do + /// Atomic results require a staged transactional proof and do /// not implement the private preparation capability. pub fn prepare(payload: K::Payload) -> Result { Self::prepare_payload(payload) diff --git a/src/graphql/command_contract/projection_proof.rs b/src/graphql/command_contract/projection_proof.rs index 89a48112..d42862a2 100644 --- a/src/graphql/command_contract/projection_proof.rs +++ b/src/graphql/command_contract/projection_proof.rs @@ -106,7 +106,7 @@ impl std::fmt::Display for CommandCommitProofError { impl std::error::Error for CommandCommitProofError {} -/// Private evidence tying a `Projected` payload to one exact full-row +/// Private evidence tying a `Atomic` payload to one exact full-row /// upsert. Application handlers can obtain this only through the causal /// workspace's stage-and-prepare operation. pub(crate) struct ProjectionCommitProof { diff --git a/src/graphql/command_contract/tests.rs b/src/graphql/command_contract/tests.rs index 9449650d..055ba996 100644 --- a/src/graphql/command_contract/tests.rs +++ b/src/graphql/command_contract/tests.rs @@ -129,11 +129,11 @@ impl GraphqlOutputType for Payload { #[test] fn preparation_serializes_and_retains_the_typed_payload_until_commit() { - let prepared = PreparedCommand::>::prepare(Payload { + let prepared = PreparedCommand::>::prepare(Payload { id: "todo-1".into(), }) .unwrap(); - assert_eq!(prepared.consistency(), CommandConsistency::Causal); + assert_eq!(prepared.consistency(), CommandConsistency::Eventual); assert_eq!(prepared.serialized_payload()["id"], "todo-1"); let (committed, serialized) = prepared.finalize_after_commit(); assert_eq!(committed.payload().id, "todo-1"); @@ -142,7 +142,7 @@ fn preparation_serializes_and_retains_the_typed_payload_until_commit() { #[test] fn projected_output_is_generated_from_relational_schema_without_graphql_output_derive() { - let contract = typed_command::>("row.project").into_contract(); + let contract = typed_command::>("row.project").into_contract(); assert_eq!(contract.output.name, "ProjectedRow"); assert_eq!( @@ -170,8 +170,8 @@ fn projected_output_is_generated_from_relational_schema_without_graphql_output_d fn successful_consistency_wire_vocabulary_is_exact_and_breaking() { let cases = [ (CommandConsistency::Succeeded, "\"succeeded\""), - (CommandConsistency::Causal, "\"causal\""), - (CommandConsistency::Projected, "\"projected\""), + (CommandConsistency::Eventual, "\"eventual\""), + (CommandConsistency::Atomic, "\"atomic\""), ]; for (consistency, encoded) in cases { @@ -181,8 +181,11 @@ fn successful_consistency_wire_vocabulary_is_exact_and_breaking() { consistency ); } + // No wire aliases / back-compat names. assert!(serde_json::from_str::("\"accepted\"").is_err()); assert!(serde_json::from_str::("\"fact\"").is_err()); + assert!(serde_json::from_str::("\"causal\"").is_err()); + assert!(serde_json::from_str::("\"projected\"").is_err()); } #[test] @@ -235,7 +238,7 @@ fn command_registration_distrusts_manual_event_contracts() { let mismatched_state = typed_command::>("todo.dishonest-state") .emits(crate::events![DishonestStateContract]) - .preview(crate::state_preview! { + .applies(crate::state_preview! { DishonestStateContract => TodoState { todo_id: input.id, ..unknown @@ -253,7 +256,7 @@ fn command_registration_distrusts_manual_event_contracts() { fn command_preview_requires_membership_and_rejects_server_only_sources() { let outside = typed_command::>("todo.outside") .emits(crate::events![TodoCompleted]) - .preview(crate::event_preview! { + .applies(crate::event_preview! { TodoRenamed => TodoRenamed { todo_id: input.id, ..unknown @@ -268,7 +271,7 @@ fn command_preview_requires_membership_and_rejects_server_only_sources() { let server_only = typed_command::>("todo.server-only") .emits(crate::events![TodoCompleted]) - .preview( + .applies( CommandProjectionPreview::new() .events(crate::events![TodoCompleted]) .field(["todo_id"], CommandProjectionPreviewSource::ServerOnly), @@ -285,7 +288,7 @@ fn command_preview_requires_membership_and_rejects_server_only_sources() { fn partial_preview_retains_known_unknown_and_typed_constant_sources() { let contract = typed_command::>("todo.partial") .emits(crate::events![TodoCompleted]) - .preview(crate::event_preview! { + .applies(crate::event_preview! { TodoCompleted => TodoCompleted { todo_id: input.id, status: "completed", @@ -301,13 +304,13 @@ fn partial_preview_retains_known_unknown_and_typed_constant_sources() { fn repeated_preview_declarations_preserve_synthetic_occurrence_order() { let contract = typed_command::>("todo.repeated-preview") .emits(crate::events![TodoCompleted]) - .preview(crate::event_preview! { + .applies(crate::event_preview! { TodoCompleted => TodoCompleted { todo_id: "first", ..unknown } }) - .preview(crate::event_preview! { + .applies(crate::event_preview! { TodoCompleted => TodoCompleted { todo_id: input.id, ..unknown @@ -656,8 +659,8 @@ fn succeeded_without_confirmations_allows_an_empty_domain_batch() { #[test] fn causal_without_a_finite_confirmation_fails_at_commit_validation() { - let contract = typed_command::>("todo.create").into_contract(); - let prepared = PreparedCommand::>::prepare(Payload { + let contract = typed_command::>("todo.create").into_contract(); + let prepared = PreparedCommand::>::prepare(Payload { id: "todo-1".into(), }) .unwrap(); diff --git a/src/graphql/command_contract/typed_command.rs b/src/graphql/command_contract/typed_command.rs index ef64558f..25dd0bfa 100644 --- a/src/graphql/command_contract/typed_command.rs +++ b/src/graphql/command_contract/typed_command.rs @@ -15,7 +15,7 @@ use super::effect_wire::CompiledInputDefaults; use super::effects::{ invalid_confirmation_constant, invalid_expression_constant, CommandEffects, EffectExpression, }; -use super::outcomes::{CommandConsistency, CommandOutcome, Projected}; +use super::outcomes::{Atomic, CommandConsistency, CommandOutcome}; use super::projection_obligations::{ CommandInputDefault, CommandProjectionConfirmation, ProjectionObligationResolutionError, }; @@ -44,7 +44,7 @@ pub(crate) struct TypedCommandContract { pub input_defaults: Vec, pub effects: CommandEffects, pub confirmations: Vec, - /// Present automatically for `Projected` before Surface ownership is + /// Present automatically for `Atomic` before Surface ownership is /// resolved. This never requires an application declaration. pub projected_model: Option, pub direct_projection: Option, @@ -254,7 +254,7 @@ impl TypedCommandContract { &self, outbox_messages: &[OutboxMessage], ) -> Result<(), CommandCommitProofError> { - if self.consistency == CommandConsistency::Causal + if self.consistency == CommandConsistency::Eventual && self.confirmations.is_empty() && self.projections.selectors.is_empty() { @@ -407,7 +407,7 @@ impl TypedServiceCommandBinding { )); } match contract.consistency { - CommandConsistency::Causal + CommandConsistency::Eventual if contract.confirmations.is_empty() && contract.projections.selectors.is_empty() => { @@ -416,19 +416,19 @@ impl TypedServiceCommandBinding { contract.name )); } - CommandConsistency::Projected if !contract.confirmations.is_empty() => { + CommandConsistency::Atomic if !contract.confirmations.is_empty() => { return Err(format!( "typed projected command `{}` cannot declare asynchronous projector confirmations", contract.name )); } - CommandConsistency::Projected if contract.projected_model.is_none() => { + CommandConsistency::Atomic if contract.projected_model.is_none() => { return Err(format!( "typed projected command `{}` is missing its compiler-retained relational model", contract.name )); } - CommandConsistency::Succeeded | CommandConsistency::Causal + CommandConsistency::Succeeded | CommandConsistency::Eventual if contract.projected_model.is_some() || contract.direct_projection.is_some() => { @@ -437,9 +437,9 @@ impl TypedServiceCommandBinding { contract.name )); } - CommandConsistency::Causal + CommandConsistency::Eventual | CommandConsistency::Succeeded - | CommandConsistency::Projected => {} + | CommandConsistency::Atomic => {} } if let Some(projected) = &contract.projected_model { if projected.output_type_id != contract.output_type_id { @@ -653,12 +653,6 @@ impl TypedCommand { self } - /// Alias for [`Self::applies`] (historical name). - #[must_use] - pub fn preview(self, preview: CommandProjectionPreview) -> Self { - self.applies(preview) - } - pub fn name(&self) -> &str { &self.contract.name } @@ -677,7 +671,7 @@ impl TypedCommand { } } -impl TypedCommand> +impl TypedCommand> where I: GraphqlInputType + DeserializeOwned + Send + 'static, M: RelationalReadModel + Serialize + Send + Sync + 'static, diff --git a/src/graphql/commands.rs b/src/graphql/commands.rs index ee35e104..bdff37ae 100644 --- a/src/graphql/commands.rs +++ b/src/graphql/commands.rs @@ -97,7 +97,7 @@ impl TypedCommandInventory { self.contracts.clone() } - /// Bind every confirmation and ordinary `Projected` target to the exact + /// Bind every confirmation and ordinary `Atomic` target to the exact /// compiled projector registry. Runtime lowering never reconstructs /// authority from projector/model strings. #[cfg(feature = "graphql")] @@ -177,7 +177,7 @@ impl TypedCommandInventory { confirmation.bind_protocol_topology(topology.clone()); } - if contract.consistency != CommandConsistency::Projected { + if contract.consistency != CommandConsistency::Atomic { continue; } let projected = contract.projected_model.as_ref().ok_or_else(|| { diff --git a/src/graphql/engine/builder.rs b/src/graphql/engine/builder.rs index 7c7568c4..bb137692 100644 --- a/src/graphql/engine/builder.rs +++ b/src/graphql/engine/builder.rs @@ -856,19 +856,18 @@ impl GraphqlEngineBuilder { } else { format!("app:{application}") }; - let (authorization_fingerprint, claim_keys) = if registration.schema_roles.len() - == 1 - { - role_authorization_info(®istration.schema_roles[0], &self.permissions)? - } else { - // Multi-privilege: fingerprint the application surface grant - // intersection under the synthetic privilege key. - role_authorization_info_for_roles( - &privilege_key, - ®istration.schema_roles, - &self.permissions, - )? - }; + let (authorization_fingerprint, claim_keys) = + if registration.schema_roles.len() == 1 { + role_authorization_info(®istration.schema_roles[0], &self.permissions)? + } else { + // Multi-privilege: fingerprint the application surface grant + // intersection under the synthetic privilege key. + role_authorization_info_for_roles( + &privilege_key, + ®istration.schema_roles, + &self.permissions, + )? + }; // Ensure the privilege key has a role surface for projection // visibility and a GraphQL schema when synthetic. if registration.schema_roles.len() > 1 { diff --git a/src/graphql/engine/protocol.rs b/src/graphql/engine/protocol.rs index a10b0a05..a009e685 100644 --- a/src/graphql/engine/protocol.rs +++ b/src/graphql/engine/protocol.rs @@ -94,8 +94,7 @@ pub(crate) fn resolve_execution_authority( match requested.surface { ClientSurfaceIdentity::Role { name } => { // Anonymous role surface may be opened without asserted roles. - let allowed = name == anonymous - || principal_has_role(&asserted, &name); + let allowed = name == anonymous || principal_has_role(&asserted, &name); if !allowed { return Err(()); } @@ -143,7 +142,15 @@ pub(crate) fn resolve_execution_authority( pub(crate) fn select_protocol_surface<'a>( runtime: &'a ProtocolRuntime, authority: &ExecutionAuthority, -) -> Result<(ClientSurfaceIdentity, &'a ProtocolSurfaceInfo, &'a str, &'a [String]), ()> { +) -> Result< + ( + ClientSurfaceIdentity, + &'a ProtocolSurfaceInfo, + &'a str, + &'a [String], + ), + (), +> { match &authority.surface { ClientSurfaceIdentity::Role { name } => { let info = runtime.roles.get(name).ok_or(())?; diff --git a/src/graphql/engine/tests.rs b/src/graphql/engine/tests.rs index be066fbe..31a07427 100644 --- a/src/graphql/engine/tests.rs +++ b/src/graphql/engine/tests.rs @@ -573,7 +573,10 @@ mod client_surface_parity_tests { dual.set("x-roles", "admin,user"); dual.set("x-user-id", "person-1"); let response = engine.execute(&dual, Request::new("{ __typename }")).await; - assert!(response.is_err(), "multi-role must name a surface: {response:?}"); + assert!( + response.is_err(), + "multi-role must name a surface: {response:?}" + ); } #[cfg(feature = "sqlite")] @@ -661,12 +664,7 @@ mod client_surface_parity_tests { ); // Wire roles are eligible; schema privilege is user-only so x-user-id // remains a trusted preset (portable owner-style policy). - let application_presets = &engine - .inner - .protocol - .as_ref() - .unwrap() - .applications["console"] + let application_presets = &engine.inner.protocol.as_ref().unwrap().applications["console"] .surface .trusted_presets; assert_eq!( @@ -736,13 +734,7 @@ mod client_surface_parity_tests { ); // Privilege pack is user for both openers (not admin unrestricted). assert_eq!( - engine - .inner - .protocol - .as_ref() - .unwrap() - .applications["console"] - .privilege_key, + engine.inner.protocol.as_ref().unwrap().applications["console"].privilege_key, "user" ); @@ -1818,29 +1810,29 @@ mod client_surface_parity_tests { #[cfg(feature = "sqlite")] const SQLITE_RESTRICTED_GOLDENS: ArtifactGoldens = ArtifactGoldens { manifest: "sha256:a2b97c4156fd9e6c99c3ad516af5cf2c57781fa4f13757902685989a691b2515", - static_sdl: "sha256:8a20c1fa94fff628c42a49105664ee74fb917ba85e63ca559d20719376c64b99", - runtime_sdl: "sha256:30b8a229a2670e974a623ccec5d514a08656d2b28935adf73d13d350c911fc19", + static_sdl: "sha256:6ac07aaa60a726bdde7c1632125a3ab933766931187654dacc2dd4ab19ffece1", + runtime_sdl: "sha256:fb41d43fa1b58fec7224d768124abc8bb0b30407e1ee56b44f620ddc8d8c0007", }; #[cfg(feature = "sqlite")] const SQLITE_ADMIN_GOLDENS: ArtifactGoldens = ArtifactGoldens { manifest: "sha256:4619fb257bd0b3b0155ebdff5f34a8d15f6c23bc0fc8a99459e7d56aad444932", - static_sdl: "sha256:2fc19b61914de28ee914fa851fa4c9508d16883fd2979ae009865065ba0ffd20", - runtime_sdl: "sha256:384854eead7c691058b3098a049c1b1bbecd1438e05d3bf12fb5d6211f081b2b", + static_sdl: "sha256:4d7ba7651ff632d32e538a083165ff718e094858c6c5bdb2705d38d9f0665e2f", + runtime_sdl: "sha256:be0f13249ec0cb394457572097a1d201649deeec1eba9c980f48b1751a13062b", }; #[cfg(feature = "postgres")] const POSTGRES_RESTRICTED_GOLDENS: ArtifactGoldens = ArtifactGoldens { manifest: "sha256:a2b97c4156fd9e6c99c3ad516af5cf2c57781fa4f13757902685989a691b2515", - static_sdl: "sha256:8a20c1fa94fff628c42a49105664ee74fb917ba85e63ca559d20719376c64b99", - runtime_sdl: "sha256:30b8a229a2670e974a623ccec5d514a08656d2b28935adf73d13d350c911fc19", + static_sdl: "sha256:6ac07aaa60a726bdde7c1632125a3ab933766931187654dacc2dd4ab19ffece1", + runtime_sdl: "sha256:fb41d43fa1b58fec7224d768124abc8bb0b30407e1ee56b44f620ddc8d8c0007", }; #[cfg(feature = "postgres")] const POSTGRES_ADMIN_GOLDENS: ArtifactGoldens = ArtifactGoldens { manifest: "sha256:66cddbcf76eac385f94de011497fe4752f7fb6102de28c14c24d83c67367788b", - static_sdl: "sha256:a0e46c91321b65612079734417b15871aac9f8622396f086f154ba19cec2a934", - runtime_sdl: "sha256:8a3bb1c740de44c011c38fc0285f7aa2cd4dc251ff65bb26ef2eff434e9dfec7", + static_sdl: "sha256:d128621aea3ffa6c38abc44a9b7f3b2716aada4af4b579b10e7c415040281751", + runtime_sdl: "sha256:ae58e2ed718955a6d400197a4cfa2f363d3057c80c6f4e528d10615a3df804cb", }; #[cfg(feature = "sqlite")] diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 7651f23a..07bf7066 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -37,10 +37,10 @@ pub use command_contract::{ pub use command_contract::{ __command_projection_event_descriptor, __command_projection_event_preview, __command_projection_events, __command_projection_preview_constant, - __command_projection_state_preview, typed_command, Causal, CommandConsistency, + __command_projection_state_preview, typed_command, Atomic, CommandConsistency, CommandProjectionEventSet, CommandProjectionPreview, CommandProjectionPreviewSource, - CompiledDirectProjectionTarget, CompiledInputDefaults, PrepareCommandError, PreparedCommand, - Projected, Succeeded, TypedCommand, TypedEffectExpression, TypedEffectKey, + CompiledDirectProjectionTarget, CompiledInputDefaults, Eventual, PrepareCommandError, + PreparedCommand, Succeeded, TypedCommand, TypedEffectExpression, TypedEffectKey, TypedEffectRelationship, }; pub use naming::{ @@ -54,12 +54,11 @@ pub use sdl::{ }; pub use surface::{ build_surface, role_grants_for_role, surface_for_application, surface_for_application_contract, - surface_for_role, RoleGrant, - RootField, RootKind, Surface, SurfaceArgument, SurfaceArgumentKind, SurfaceCommand, - SurfaceCommandShape, SurfaceDialect, SurfaceDirectProjection, SurfaceModel, - SurfaceModeledProjection, SurfaceOptions, SurfaceProjectionOwner, SurfaceProjector, - SurfaceRelationshipAggregate, SurfaceRelationshipKeys, SurfaceRowPolicy, SurfaceTypeDef, - SurfaceTypeField, + surface_for_role, RoleGrant, RootField, RootKind, Surface, SurfaceArgument, + SurfaceArgumentKind, SurfaceCommand, SurfaceCommandShape, SurfaceDialect, + SurfaceDirectProjection, SurfaceModel, SurfaceModeledProjection, SurfaceOptions, + SurfaceProjectionOwner, SurfaceProjector, SurfaceRelationshipAggregate, + SurfaceRelationshipKeys, SurfaceRowPolicy, SurfaceTypeDef, SurfaceTypeField, }; pub use filter::{claim, col, lit, rel, ClaimRef, ColRef, FilterExpr, LitValue, Operand}; diff --git a/src/graphql/naming.rs b/src/graphql/naming.rs index c8b0b569..ba8ea58e 100644 --- a/src/graphql/naming.rs +++ b/src/graphql/naming.rs @@ -133,7 +133,7 @@ pub const DISTRIBUTED_COMMAND_STATE_VALUES: &[&str] = &[ "in_progress", "succeeded", "succeeded_pending_projection", - "projected", + "atomic", "rejected", "projection_failed", "expired", @@ -244,7 +244,7 @@ mod tests { "in_progress", "succeeded", "succeeded_pending_projection", - "projected", + "atomic", "rejected", "projection_failed", "expired", diff --git a/src/graphql/projection_delta/tests.rs b/src/graphql/projection_delta/tests.rs index 3f665723..df482ff4 100644 --- a/src/graphql/projection_delta/tests.rs +++ b/src/graphql/projection_delta/tests.rs @@ -686,7 +686,7 @@ fn zero_obligation_modeled_metadata_is_revalidated_on_every_receipt_emission() { command_id: "0190a000-0000-7000-8000-000000000011".into(), command_name: Some(TEST_COMMAND_NAME.into()), causation_id: Some(TEST_CAUSATION_ID.into()), - consistency: Some(crate::graphql::command_contract::CommandConsistency::Causal), + consistency: Some(crate::graphql::command_contract::CommandConsistency::Eventual), outcome: None, obligations: Vec::new(), projection_metadata: Some(metadata), @@ -701,7 +701,7 @@ fn zero_obligation_modeled_metadata_is_revalidated_on_every_receipt_emission() { command_id: "modeled-status-command".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Causal, + consistency: crate::graphql::command_contract::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::SucceededPendingProjection, outcome: serde_json::json!({"ok": true}), obligations: Vec::new(), @@ -879,7 +879,7 @@ fn zero_occurrence_metadata_is_classified_from_the_current_causal_command_contra command_id: "0190a000-0000-7000-8000-000000000012".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Causal, + consistency: crate::graphql::command_contract::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::Succeeded, outcome: serde_json::json!({"accepted": true}), obligations: Vec::new(), @@ -1423,8 +1423,8 @@ fn active_metadata_revalidates_but_remains_queryable_while_projection_is_drainin command_id: "0190a000-0000-7000-8000-000000000018".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Causal, - state: crate::command_ledger::CommandLedgerState::Projected, + consistency: crate::graphql::command_contract::CommandConsistency::Eventual, + state: crate::command_ledger::CommandLedgerState::Atomic, outcome: serde_json::json!({"id": "todo-draining-status"}), obligations: Vec::new(), projection_metadata: Some(exact_scope_metadata), @@ -1484,7 +1484,7 @@ fn active_metadata_revalidates_but_remains_queryable_while_projection_is_drainin command_id: "0190a000-0000-7000-8000-000000000019".into(), command_name: Some(TEST_COMMAND_NAME.into()), causation_id: Some(TEST_CAUSATION_ID.into()), - consistency: Some(crate::graphql::command_contract::CommandConsistency::Causal), + consistency: Some(crate::graphql::command_contract::CommandConsistency::Eventual), outcome: None, obligations: Vec::new(), projection_metadata: Some(metadata.clone()), @@ -1522,7 +1522,7 @@ fn active_metadata_revalidates_but_remains_queryable_while_projection_is_drainin command_id: "0190a000-0000-7000-8000-000000000019".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Causal, + consistency: crate::graphql::command_contract::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::SucceededPendingProjection, outcome: serde_json::json!({"id": "todo-draining-status"}), obligations: Vec::new(), @@ -1590,7 +1590,7 @@ fn lifecycle_status_rejects_changed_deployment_identity_and_mixed_fanout_tamperi command_id: "0190a000-0000-7000-8000-000000000020".into(), command_name: TEST_COMMAND_NAME.into(), causation_id: TEST_CAUSATION_ID.into(), - consistency: crate::graphql::command_contract::CommandConsistency::Causal, + consistency: crate::graphql::command_contract::CommandConsistency::Eventual, state: crate::command_ledger::CommandLedgerState::SucceededPendingProjection, outcome: serde_json::json!({"id": "todo-lifecycle-hostile"}), obligations: Vec::new(), @@ -3124,6 +3124,7 @@ fn modeled_fixture( modeled_fixture_config(state, execution, ProjectionMutationKind::Upsert, false) } +#[cfg(feature = "graphql")] fn modeled_fixture_with_opaque_fallback(state: ProjectionBindingState) -> ModeledFixture { let fixture = modeled_fixture(state, ProjectionExecutionClass::Causal); let mut surface = fixture.surface; @@ -3453,6 +3454,7 @@ fn selected_export(surface: &crate::graphql::Surface) -> DistributedClientSurfac DistributedClientSurfaceExport::from_selected("delta-service", selected).unwrap() } +#[cfg(feature = "graphql")] fn selected_export_with_owner_policy( surface: &crate::graphql::Surface, ) -> DistributedClientSurfaceExport { @@ -3526,7 +3528,7 @@ fn selected_export_join(surface: &crate::graphql::Surface) -> DistributedClientS fn surface_with_modeled_command(surface: &crate::graphql::Surface) -> crate::graphql::Surface { let contract = crate::graphql::typed_command::< ModeledCommandInput, - crate::graphql::Causal, + crate::graphql::Eventual, >(TEST_COMMAND_NAME) .roles(["delta-user"]) .emits(crate::graphql::__command_projection_events([Ok( diff --git a/src/graphql/protocol/accumulator.rs b/src/graphql/protocol/accumulator.rs index 651cb627..277ee4c0 100644 --- a/src/graphql/protocol/accumulator.rs +++ b/src/graphql/protocol/accumulator.rs @@ -672,7 +672,7 @@ impl ProtocolResponseAccumulator { .map_or(receipt.obligations.len(), |metadata| { metadata.obligations.len() }); - let observed = if receipt.state == CommandLedgerState::Projected { + let observed = if receipt.state == CommandLedgerState::Atomic { (0..obligation_count).collect::>() } else { Vec::new() @@ -1017,8 +1017,8 @@ fn modeled_obligation_label( fn command_consistency(value: CommandConsistency) -> DistributedCommandConsistency { match value { CommandConsistency::Succeeded => DistributedCommandConsistency::Succeeded, - CommandConsistency::Causal => DistributedCommandConsistency::Causal, - CommandConsistency::Projected => DistributedCommandConsistency::Projected, + CommandConsistency::Eventual => DistributedCommandConsistency::Eventual, + CommandConsistency::Atomic => DistributedCommandConsistency::Atomic, } } @@ -1031,7 +1031,7 @@ fn command_state(value: CommandLedgerState) -> DistributedCommandState { CommandLedgerState::SucceededPendingProjection => { DistributedCommandState::SucceededPendingProjection } - CommandLedgerState::Projected => DistributedCommandState::Projected, + CommandLedgerState::Atomic => DistributedCommandState::Atomic, CommandLedgerState::Rejected => DistributedCommandState::Rejected, CommandLedgerState::ProjectionFailed => DistributedCommandState::ProjectionFailed, CommandLedgerState::Expired => DistributedCommandState::Expired, @@ -1045,7 +1045,7 @@ fn public_command_state(value: CausalCommandPublicState) -> DistributedCommandSt CausalCommandPublicState::SucceededPendingProjection => { DistributedCommandState::SucceededPendingProjection } - CausalCommandPublicState::Projected => DistributedCommandState::Projected, + CausalCommandPublicState::Atomic => DistributedCommandState::Atomic, CausalCommandPublicState::Rejected => DistributedCommandState::Rejected, CausalCommandPublicState::ProjectionFailed => DistributedCommandState::ProjectionFailed, CausalCommandPublicState::Expired => DistributedCommandState::Expired, diff --git a/src/graphql/protocol/tests.rs b/src/graphql/protocol/tests.rs index 774ddbe8..e2fc204b 100644 --- a/src/graphql/protocol/tests.rs +++ b/src/graphql/protocol/tests.rs @@ -86,7 +86,7 @@ fn command(id: &str) -> DistributedCommandMetadata { command_id: id.into(), causation_id: "cause-17".into(), state: DistributedCommandState::SucceededPendingProjection, - consistency: DistributedCommandConsistency::Causal, + consistency: DistributedCommandConsistency::Eventual, projection_disposition: None, expects: vec![DistributedProjectionExpectation { projection: "todos".into(), @@ -116,7 +116,7 @@ fn receipt() -> CausalCommandReceiptSource { command_id: "0190a000-0000-7000-8000-000000000042".into(), command_name: "todo.complete".into(), causation_id: "0190a000-0000-7000-8000-000000000017".into(), - consistency: CommandConsistency::Causal, + consistency: CommandConsistency::Eventual, state: CommandLedgerState::SucceededPendingProjection, outcome: serde_json::json!({ "accepted": true }), obligations: vec![CausalCommandProjectionObligation { @@ -141,8 +141,8 @@ fn direct_projected_receipt() -> CausalCommandReceiptSource { 17, ) .unwrap(); - receipt.consistency = CommandConsistency::Projected; - receipt.state = CommandLedgerState::Projected; + receipt.consistency = CommandConsistency::Atomic; + receipt.state = CommandLedgerState::Atomic; receipt.obligations.clear(); receipt.direct_projection = Some(SameTransactionProjectionEvidence { records: vec![ProjectionRecordMetadata { @@ -272,7 +272,7 @@ fn durable_receipts_issue_stable_generation_bound_non_disclosing_obligations() { .as_str() .unwrap(); assert_eq!(first["command"]["state"], "succeeded_pending_projection"); - assert_eq!(first["command"]["consistency"], "causal"); + assert_eq!(first["command"]["consistency"], "eventual"); assert!(!first_token.contains("tenant-private")); assert!(!first_token.contains("9223372036854775807")); assert!(!first_token.contains("child-private")); @@ -309,8 +309,8 @@ fn direct_projected_receipt_replays_exact_record_revision_as_decimal_strings() { let first = accumulator(17, "principal-a", "sha256:schema-a"); first.record_receipt(&receipt).unwrap(); let command = serde_json::to_value(first.snapshot().unwrap()).unwrap()["command"].clone(); - assert_eq!(command["state"], "projected"); - assert_eq!(command["consistency"], "projected"); + assert_eq!(command["state"], "atomic"); + assert_eq!(command["consistency"], "atomic"); assert_eq!(command["records"][0]["incarnation"], "3"); assert_eq!(command["records"][0]["revision"], "9007199254740991"); assert_eq!(command["records"][0]["tombstone"], false); @@ -354,7 +354,7 @@ fn projected_status_exposes_only_matching_opaque_observations() { let accumulator = accumulator(5, "principal-a", "sha256:schema-a"); accumulator .record_status(&CausalCommandPublicStatus { - state: CausalCommandPublicState::Projected, + state: CausalCommandPublicState::Atomic, command_id: source.command_id, command_name: Some(source.command_name), causation_id: Some(source.causation_id.clone()), @@ -373,7 +373,7 @@ fn projected_status_exposes_only_matching_opaque_observations() { }) .unwrap(); let command = serde_json::to_value(accumulator.snapshot().unwrap()).unwrap()["command"].clone(); - assert_eq!(command["state"], "projected"); + assert_eq!(command["state"], "atomic"); assert_eq!( command["observations"][0]["causationId"], source.causation_id diff --git a/src/graphql/protocol/types.rs b/src/graphql/protocol/types.rs index 7735899e..73543255 100644 --- a/src/graphql/protocol/types.rs +++ b/src/graphql/protocol/types.rs @@ -11,7 +11,8 @@ pub(crate) enum DistributedCommandState { InProgress, Succeeded, SucceededPendingProjection, - Projected, + /// Terminal state for an atomic command (same-tx read-model row sealed). + Atomic, Rejected, ProjectionFailed, Expired, @@ -23,8 +24,8 @@ pub(crate) enum DistributedCommandState { #[serde(rename_all = "snake_case")] pub(crate) enum DistributedCommandConsistency { Succeeded, - Causal, - Projected, + Eventual, + Atomic, } /// Current-scope handling for durable projection work that was committed diff --git a/src/graphql/surface/application.rs b/src/graphql/surface/application.rs index 005f9f85..77ba0edc 100644 --- a/src/graphql/surface/application.rs +++ b/src/graphql/surface/application.rs @@ -305,8 +305,13 @@ pub fn surface_for_role( .into_iter() .collect() }; + // Direct owners never advertise async facts (binding_facts is empty). + // Selected programs still export for client `.applies` previews — that is + // IR inventory, not eventual fact topology. let selected_facts = if projector.modeled.is_empty() { projector.facts.clone() + } else if projector.is_direct() { + Vec::new() } else { modeled .iter() diff --git a/src/graphql/surface/build.rs b/src/graphql/surface/build.rs index 28ae2db7..fb930733 100644 --- a/src/graphql/surface/build.rs +++ b/src/graphql/surface/build.rs @@ -145,20 +145,12 @@ pub fn build_surface(tables: &[TableSchema], options: &SurfaceOptions) -> Result rel.kind, RelationshipKind::HasMany | RelationshipKind::ManyToMany ); - let nullable = if matches!(rel.kind, RelationshipKind::BelongsTo) { - schema - .columns - .iter() - .find(|column| { - rel.foreign_key.as_deref().is_some_and(|key| { - column.column_name == key || column.field_name == key - }) - }) - .map(|column| column.nullable) - .unwrap_or(true) - } else { - false - }; + // Belongs-to is always `Option` in the read-model macro, and the + // client materializer needs a nullable edge so optimistic parent + // upserts remain complete before the join target is linked (e.g. + // ChatMessages.author → AuthUsers). FK column nullability is a + // storage constraint, not GraphQL join nullability. + let nullable = matches!(rel.kind, RelationshipKind::BelongsTo); let (keys, mut dependencies) = relationship_keys(schema, rel, target, &by_table)?; dependencies.sort(); dependencies.dedup(); diff --git a/src/graphql/surface/commands.rs b/src/graphql/surface/commands.rs index 34e0a713..d57f0996 100644 --- a/src/graphql/surface/commands.rs +++ b/src/graphql/surface/commands.rs @@ -70,7 +70,7 @@ pub(in crate::graphql::surface) fn validate_and_canonicalize_commands( definition, models, )? { - // `Projected` deliberately returns the already-exposed + // `Atomic` deliberately returns the already-exposed // normalized model object. Do not claim or re-emit a second // GraphQL type with the same name. } else { @@ -173,7 +173,7 @@ pub(crate) fn projected_output_reuses_surface_model( definition: &SurfaceTypeDef, models: &BTreeMap, ) -> Result { - if consistency != CommandConsistency::Projected { + if consistency != CommandConsistency::Atomic { return Ok(false); } let Some(projected) = projected else { @@ -275,7 +275,7 @@ pub(in crate::graphql::surface) fn validate_command_confirmations( ) -> Result<(), String> { validate_projection_confirmation_count(&command.command_name, command.confirmations.len())?; match command.consistency { - CommandConsistency::Causal + CommandConsistency::Eventual if command.confirmations.is_empty() && command.projections.selectors.is_empty() => { return Err(format!( @@ -283,19 +283,19 @@ pub(in crate::graphql::surface) fn validate_command_confirmations( command.command_name )); } - CommandConsistency::Projected if !command.confirmations.is_empty() => { + CommandConsistency::Atomic if !command.confirmations.is_empty() => { return Err(format!( "typed projected command `{}` cannot declare asynchronous projector confirmations", command.command_name )); } - CommandConsistency::Projected if command.projected_model.is_none() => { + CommandConsistency::Atomic if command.projected_model.is_none() => { return Err(format!( "typed projected command `{}` is missing its compiler-retained relational model", command.command_name )); } - CommandConsistency::Succeeded | CommandConsistency::Causal + CommandConsistency::Succeeded | CommandConsistency::Eventual if command.projected_model.is_some() || command.direct_projection.is_some() => { return Err(format!( @@ -470,7 +470,7 @@ pub(in crate::graphql::surface) fn bind_surface_direct_projection_targets( confirmation.bind_protocol_topology(topology.clone()); } - if command.consistency != CommandConsistency::Projected { + if command.consistency != CommandConsistency::Atomic { continue; } let projected = command.projected_model.as_ref().ok_or_else(|| { diff --git a/src/graphql/surface/projections.rs b/src/graphql/surface/projections.rs index 350f4602..423f2c09 100644 --- a/src/graphql/surface/projections.rs +++ b/src/graphql/surface/projections.rs @@ -262,15 +262,34 @@ impl SurfaceModeledProjection { &self.route } - /// Whether this exact selected registration may mint client causal work. + /// Whether this exact selected registration may mint **async causal work** + /// (confirmations / obligations waiting on eventual projectors). + /// + /// Direct / same-transaction projected rows are not in this set: the command + /// response already carries the authoritative row. Previews for Direct still + /// use [`Self::is_preview_eligible`]. pub fn is_causally_eligible(&self) -> bool { self.state == ProjectionBindingState::Active && self.placement == ProjectionPlacement::Eventual && self.execution_class == ProjectionExecutionClass::Causal } + /// Whether this registration may contribute **client cache previews** from + /// `.applies` / event→mutation IR. + /// + /// Same mutation IR as the server; apply site differs (command handler + /// Projected vs eventual event handler). Background-only consumers are + /// excluded. Direct and Eventual causal placements both qualify when Active + /// and a selected program is present for composition. + pub fn is_preview_eligible(&self) -> bool { + self.state == ProjectionBindingState::Active + && self.execution_class == ProjectionExecutionClass::Causal + && self.selected_program().is_some() + } + /// Whether this exact live registration may validate causal work minted /// before or during a rollout. + #[cfg(feature = "graphql")] pub(crate) fn is_causal_evidence_eligible(&self) -> bool { matches!( self.state, @@ -399,24 +418,27 @@ impl SurfaceModeledProjection { if output_models.is_empty() { return Ok(None); } - let selected = if self.placement == ProjectionPlacement::Direct { + // Direct and Eventual both export selected arms: client previews compose + // the same portable mutation IR. Server apply site differs (handler-owned + // same-tx Projected vs eventual event handler). + let mut arms = Vec::new(); + for arm in program.arms() { + let operations = arm + .operations() + .iter() + .filter_map(|operation| select_operation(operation, models)) + .collect::>(); + if !operations.is_empty() { + arms.push(SurfaceProjectionArm { + arm_id: arm.arm_id().to_owned(), + selector: arm.selector().clone(), + operations, + }); + } + } + let selected = if arms.is_empty() { None } else { - let mut arms = Vec::new(); - for arm in program.arms() { - let operations = arm - .operations() - .iter() - .filter_map(|operation| select_operation(operation, models)) - .collect::>(); - if !operations.is_empty() { - arms.push(SurfaceProjectionArm { - arm_id: arm.arm_id().to_owned(), - selector: arm.selector().clone(), - operations, - }); - } - } Some(SurfaceSelectedProjectionProgram { name: program.name().to_owned(), version: program.version(), @@ -440,6 +462,7 @@ impl SurfaceModeledProjection { Some((self.raw_program.as_ref()?, self.raw_binding.as_ref()?)) } + #[cfg(feature = "graphql")] pub(crate) fn server_executor( &self, ) -> Option<&crate::projection::lower::ProjectionServerExecutorDescriptor> { diff --git a/src/graphql/surface/tests.rs b/src/graphql/surface/tests.rs index 2af8cb28..61f9005c 100644 --- a/src/graphql/surface/tests.rs +++ b/src/graphql/surface/tests.rs @@ -277,7 +277,7 @@ fn causal_surface_commands_accept_modeled_event_selectors_but_not_empty_authorit ) }; let mut modeled = test_command("order.modeled", "order_modeled", output()); - modeled.consistency = CommandConsistency::Causal; + modeled.consistency = CommandConsistency::Eventual; modeled.projections.selectors.push( crate::ProjectionEventSelector::try_new( 1, @@ -302,7 +302,7 @@ fn causal_surface_commands_accept_modeled_event_selectors_but_not_empty_authorit ); let mut empty = test_command("order.empty", "order_empty", output()); - empty.consistency = CommandConsistency::Causal; + empty.consistency = CommandConsistency::Eventual; let error = build_surface(&[orders()], &SurfaceOptions::sqlite()) .unwrap() .with_typed_commands(&test_inventory([empty])) @@ -917,7 +917,7 @@ fn projected_output_reuse_and_sdl_emission_use_the_same_exact_predicate() { roles: Vec::new(), input: SurfaceCommandShape::None, output: SurfaceCommandShape::Typed(output), - consistency: CommandConsistency::Projected, + consistency: CommandConsistency::Atomic, input_defaults: Vec::new(), effects: Some(CommandEffects::revalidate()), confirmations: Vec::new(), diff --git a/src/graphql/surface/types.rs b/src/graphql/surface/types.rs index d8fcb57f..30f9f520 100644 --- a/src/graphql/surface/types.rs +++ b/src/graphql/surface/types.rs @@ -294,7 +294,7 @@ impl SurfaceProjectionOwner { /// /// This is the only projection declaration accepted by /// [`crate::microsvc::Routes::causal_projector`]. Use -/// [`SurfaceDirectProjection`] when `Projected` owns the row entirely +/// [`SurfaceDirectProjection`] when `Atomic` owns the row entirely /// inside the command transaction. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SurfaceProjector { @@ -369,7 +369,7 @@ impl SurfaceProjector { self } - /// Compiler seam for binding one `Projected` command to this exact + /// Compiler seam for binding one `Atomic` command to this exact /// registered topology. Ordinary handlers never receive or construct it. #[doc(hidden)] pub fn __distributed_direct_projection(&self) -> CompiledDirectProjectionTarget @@ -392,7 +392,7 @@ impl From for SurfaceProjectionOwner { } } -/// Same-transaction-only projection owner for `Projected` commands. +/// Same-transaction-only projection owner for `Atomic` commands. /// /// It intentionally has no fact inventory and cannot be passed to an /// asynchronous projector route. The owner still supplies the complete model diff --git a/src/graphql/types.rs b/src/graphql/types.rs index cb0e6269..bb50ebd2 100644 --- a/src/graphql/types.rs +++ b/src/graphql/types.rs @@ -75,7 +75,7 @@ pub trait GraphqlOutputType { /// Build a command-output object from the same relational schema that owns /// the generated query object. /// -/// Projected command results contain stored columns only. Relationships stay +/// Atomic command results contain stored columns only. Relationships stay /// query-time fields and are never invented by a same-transaction row result. pub(crate) fn read_model_graphql_type() -> GraphqlTypeDef where diff --git a/src/in_memory_repo/projection_protocol/tests.rs b/src/in_memory_repo/projection_protocol/tests.rs index 599d3406..5546a105 100644 --- a/src/in_memory_repo/projection_protocol/tests.rs +++ b/src/in_memory_repo/projection_protocol/tests.rs @@ -1301,7 +1301,7 @@ async fn stale_direct_attempt_is_fenced_before_projection_drift_is_inspected() { let direct = direct_batch(attempt.causation_id().as_str()); let completion = attempt .complete( - TerminalCommandState::Projected, + TerminalCommandState::Atomic, serde_json::json!({"projected": "must-not-run"}), retention, ) diff --git a/src/lib.rs b/src/lib.rs index 21fc1680..170c3a96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -412,8 +412,7 @@ pub use microsvc::{ROLE_KEY, USER_ID_KEY}; // mount); commands predict events via `.emits`/`.preview`. pub use distributed_macros::{ aggregate, command_input_defaults, digest, mutation, mutation_file, sourced, DomainEvent, - DomainState, - GraphqlInput, GraphqlOutput, ReadModel, Snapshot, + DomainState, GraphqlInput, GraphqlOutput, ReadModel, Snapshot, }; // Re-export enqueue macro (requires "emitter" feature) diff --git a/src/microsvc/causal.rs b/src/microsvc/causal.rs index 7bbc4acf..3ba2e54b 100644 --- a/src/microsvc/causal.rs +++ b/src/microsvc/causal.rs @@ -23,7 +23,7 @@ use crate::graphql::command_contract::{ validate_resolved_direct_plan, CommandCommitProofError, CommandOutcome, ProjectionCommitProof, ResolvedDirectProjectionTarget, TypedCommandContract, }; -use crate::graphql::{PrepareCommandError, PreparedCommand, Projected}; +use crate::graphql::{Atomic, PrepareCommandError, PreparedCommand}; use crate::outbox::{OutboxMessage, PreparedDomainEvent}; use crate::projection::lower::{ DirectCandidate, LoweredProjectionPlan, ProjectionDescriptor, @@ -441,11 +441,11 @@ where } /// Atomically stage the returned view as one full-row upsert and prepare a - /// non-forgeable `Projected` completion tied to that exact row. - pub(crate) fn prepare_projected( + /// non-forgeable `Atomic` completion tied to that exact row. + pub(crate) fn prepare_atomic( &self, model: M, - ) -> Result>, CausalWorkspaceError> + ) -> Result>, CausalWorkspaceError> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { @@ -453,7 +453,7 @@ where builder.upsert(&model)?; let plan = builder.into_write_plan()?; let proof = ProjectionCommitProof::for_model(&model)?; - let prepared = PreparedCommand::prepare_projected(model, proof)?; + let prepared = PreparedCommand::prepare_atomic(model, proof)?; let mut state = self .state @@ -470,24 +470,24 @@ where /// Prepare a projected result whose exact row will be resolved from the /// authoritative domain-event occurrence after the dispatcher stamps its /// ledger causation. - pub(crate) fn prepare_modeled_projected( + pub(crate) fn prepare_modeled_atomic( &self, projection: ProjectionDescriptor, - ) -> Result>, CausalWorkspaceError> + ) -> Result>, CausalWorkspaceError> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { let executor = projection .server_executor() .map_err(|error| CausalWorkspaceError::ModeledDirectProjection(error.to_string()))?; - self.prepare_modeled_projected_from_executor(executor) + self.prepare_modeled_atomic_from_executor(executor) } /// Placement-selected direct projection: the service registration chose the /// executor; command code names neither a projection nor a selector. - pub(crate) fn prepare_placement_selected_projected( + pub(crate) fn prepare_placement_selected_atomic( &self, - ) -> Result>, CausalWorkspaceError> + ) -> Result>, CausalWorkspaceError> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { @@ -500,13 +500,13 @@ where schema.model_name )) })?; - self.prepare_modeled_projected_from_executor(executor) + self.prepare_modeled_atomic_from_executor(executor) } - fn prepare_modeled_projected_from_executor( + fn prepare_modeled_atomic_from_executor( &self, executor: ProjectionServerExecutorDescriptor, - ) -> Result>, CausalWorkspaceError> + ) -> Result>, CausalWorkspaceError> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { @@ -526,7 +526,7 @@ where executor.name, schema.model_name, schema.table_name ))); } - let prepared = PreparedCommand::prepare_modeled_projected(); + let prepared = PreparedCommand::prepare_modeled_atomic(); let mut state = self .state @@ -1286,15 +1286,14 @@ mod tests { id: "v-1".into(), title: "projected".into(), }; - let mut prepared = workspace.prepare_projected(view).unwrap(); + let mut prepared = workspace.prepare_atomic(view).unwrap(); let mut aggregate = workspace.load("a-1").await.unwrap().unwrap(); aggregate.entity_mut().digest_empty("Projected").unwrap(); workspace.stage(aggregate).unwrap(); let mut parts = workspace.into_parts().unwrap(); - let contract = - crate::graphql::typed_command::>("test.project") - .into_contract(); + let contract = crate::graphql::typed_command::>("test.project") + .into_contract(); parts.validate_prepared(&contract, &mut prepared).unwrap(); } @@ -1303,15 +1302,14 @@ mod tests { let repository = loaded_repo(); let workspace = CausalWorkspace::new(&repository); let mut prepared = workspace - .prepare_projected(TestView { + .prepare_atomic(TestView { id: "v-1".into(), title: "projected".into(), }) .unwrap(); let mut parts = workspace.into_parts().unwrap(); - let contract = - crate::graphql::typed_command::>("test.project") - .into_contract(); + let contract = crate::graphql::typed_command::>("test.project") + .into_contract(); assert!(matches!( parts.validate_prepared(&contract, &mut prepared), @@ -1324,7 +1322,7 @@ mod tests { let repository = loaded_repo(); let workspace = CausalWorkspace::new(&repository); let mut prepared = workspace - .prepare_projected(TestView { + .prepare_atomic(TestView { id: "v-1".into(), title: "returned".into(), }) @@ -1341,9 +1339,8 @@ mod tests { .unwrap(); workspace.stage_read_models(conflicting).unwrap(); let mut parts = workspace.into_parts().unwrap(); - let contract = - crate::graphql::typed_command::>("test.project") - .into_contract(); + let contract = crate::graphql::typed_command::>("test.project") + .into_contract(); assert!(matches!( parts.validate_prepared(&contract, &mut prepared), @@ -1356,7 +1353,7 @@ mod tests { let repository = loaded_repo(); let workspace = CausalWorkspace::new(&repository); let mut prepared = workspace - .prepare_projected(TestView { + .prepare_atomic(TestView { id: "v-1".into(), title: "returned".into(), }) @@ -1371,9 +1368,8 @@ mod tests { mutation .values .insert("title", RowValue::String("different".into())); - let contract = - crate::graphql::typed_command::>("test.project") - .into_contract(); + let contract = crate::graphql::typed_command::>("test.project") + .into_contract(); assert!(matches!( parts.validate_prepared(&contract, &mut prepared), @@ -1388,10 +1384,8 @@ mod tests { } fn modeled_direct_contract() -> TypedCommandContract { - crate::graphql::typed_command::>( - "test.modeled-direct", - ) - .into_contract() + crate::graphql::typed_command::>("test.modeled-direct") + .into_contract() } #[test] @@ -1408,7 +1402,7 @@ mod tests { .unwrap(); workspace.stage(aggregate).unwrap(); let mut prepared = workspace - .prepare_placement_selected_projected::() + .prepare_placement_selected_atomic::() .expect("placement-selected projected without .project(MODELED_DIRECT)"); let mut parts = workspace.into_parts().unwrap(); @@ -1431,7 +1425,7 @@ mod tests { .unwrap(); workspace.stage(aggregate).unwrap(); let mut prepared = workspace - .prepare_modeled_projected::(MODELED_DIRECT) + .prepare_modeled_atomic::(MODELED_DIRECT) .unwrap(); let mut parts = workspace.into_parts().unwrap(); @@ -1479,7 +1473,7 @@ mod tests { .unwrap(); workspace.stage(aggregate).unwrap(); let mut prepared = workspace - .prepare_modeled_projected::(MODELED_DIRECT) + .prepare_modeled_atomic::(MODELED_DIRECT) .unwrap(); let mut separate = ReadModelWritePlanBuilder::new(); separate @@ -1511,7 +1505,7 @@ mod tests { .unwrap(); workspace.stage(aggregate).unwrap(); let mut prepared = workspace - .prepare_modeled_projected::(MODELED_DIRECT) + .prepare_modeled_atomic::(MODELED_DIRECT) .unwrap(); let mut parts = workspace.into_parts().unwrap(); @@ -1539,7 +1533,7 @@ mod tests { .unwrap(); workspace.stage(aggregate).unwrap(); let mut prepared = workspace - .prepare_modeled_projected::(MODELED_DIRECT) + .prepare_modeled_atomic::(MODELED_DIRECT) .unwrap(); let mut parts = workspace.into_parts().unwrap(); parts @@ -1562,7 +1556,7 @@ mod tests { .unwrap(); workspace.stage(aggregate).unwrap(); let mut prepared = workspace - .prepare_modeled_projected::(MODELED_DIRECT) + .prepare_modeled_atomic::(MODELED_DIRECT) .unwrap(); let mut parts = workspace.into_parts().unwrap(); parts @@ -1586,7 +1580,7 @@ mod tests { aggregate.rename("renamed".into()).unwrap(); workspace.stage(aggregate).unwrap(); let mut prepared = workspace - .prepare_modeled_projected::(MODELED_DIRECT) + .prepare_modeled_atomic::(MODELED_DIRECT) .unwrap(); let mut parts = workspace.into_parts().unwrap(); parts @@ -1617,7 +1611,7 @@ mod tests { ) .unwrap(); let mut prepared = workspace - .prepare_modeled_projected::(MODELED_DIRECT) + .prepare_modeled_atomic::(MODELED_DIRECT) .unwrap(); let mut parts = workspace.into_parts().unwrap(); parts diff --git a/src/microsvc/projector/context.rs b/src/microsvc/projector/context.rs index 11f0d7fc..cdb62105 100644 --- a/src/microsvc/projector/context.rs +++ b/src/microsvc/projector/context.rs @@ -4,8 +4,11 @@ use std::pin::Pin; use std::sync::{Arc, Mutex}; use crate::bus::Message; +#[cfg(feature = "graphql")] use crate::projection_protocol::{ ProjectionExecutionSnapshotBatch, ProjectionExecutionSnapshotBatchRequest, +}; +use crate::projection_protocol::{ ProjectionProtocolError, ProjectionProtocolStore, ProjectionQuerySnapshot, ProjectionQuerySnapshotRequest, ProjectionRecordScope, ProjectionWorkspace, RecordRevision, MAX_PROJECTION_QUERY_BATCH_ROWS, @@ -62,6 +65,7 @@ pub(super) trait ProjectionSnapshotReader: Send + Sync { >, >; + #[cfg(feature = "graphql")] fn execution_snapshots<'a>( &'a self, request: &'a ProjectionExecutionSnapshotBatchRequest, @@ -91,6 +95,7 @@ where Box::pin(self.projection_query_snapshot(request)) } + #[cfg(feature = "graphql")] fn execution_snapshots<'a>( &'a self, request: &'a ProjectionExecutionSnapshotBatchRequest, @@ -169,6 +174,7 @@ impl CausalProjectorContext { &self.causation_id } + #[cfg(feature = "graphql")] pub(crate) async fn apply_portable( &self, plan: crate::projection::lower::LoweredProjectionPlan, diff --git a/src/microsvc/projector/registration.rs b/src/microsvc/projector/registration.rs index c38e3a36..c04dadcf 100644 --- a/src/microsvc/projector/registration.rs +++ b/src/microsvc/projector/registration.rs @@ -4,10 +4,12 @@ use std::pin::Pin; use std::sync::Arc; use crate::graphql::SurfaceProjector; +#[cfg(feature = "graphql")] use crate::projection::lower::ProjectionDescriptor; use crate::projection_protocol::{CompiledProjectionTopology, ProjectionEpoch}; use crate::read_model::RelationalReadModel; use crate::table::TableSchema; +#[cfg(feature = "graphql")] use crate::ProjectionProgramId; use super::super::dependencies::CausalProjectionRouteDependencies; @@ -29,6 +31,7 @@ where Arc::new(move |context, input| Box::pin(handler(context, input))) } +#[cfg(feature = "graphql")] pub(in crate::microsvc) type ModeledProjectorHandlerFn = dyn Fn(CausalProjectorContext, ModeledProjection) -> ProjectorHandlerFuture + Send + Sync; @@ -47,12 +50,14 @@ where /// capability-restricted [`CausalProjectorContext`]. The runtime rejects a /// handler that returns success without applying the token. #[must_use = "a modeled projection handler must apply this token"] +#[cfg(feature = "graphql")] pub struct ModeledProjection { program_id: ProjectionProgramId, plan: Option, applied: Arc, } +#[cfg(feature = "graphql")] impl ModeledProjection { pub(in crate::microsvc) fn new( program_id: ProjectionProgramId, diff --git a/src/microsvc/projector/runtime.rs b/src/microsvc/projector/runtime.rs index d0ce87d0..c0b3f67f 100644 --- a/src/microsvc/projector/runtime.rs +++ b/src/microsvc/projector/runtime.rs @@ -21,6 +21,7 @@ use super::errors::{ }; use super::handle::ProjectionRepairHandle; use super::registration::ProjectorHandlerFn; +#[cfg(feature = "graphql")] use super::registration::{ModeledProjection, ModeledProjectorHandlerFn}; pub(in crate::microsvc) type ProjectorDispatchFuture<'a> = @@ -67,6 +68,7 @@ pub(super) struct RegisteredProjector { pub(super) handle: Arc>, } +#[cfg(feature = "graphql")] pub(in crate::microsvc) struct RegisteredModeledProjector { pub(in crate::microsvc) compiled: CompiledProjectionTopology, pub(in crate::microsvc) change_epoch: ProjectionEpoch, @@ -74,6 +76,7 @@ pub(in crate::microsvc) struct RegisteredModeledProjector { pub(in crate::microsvc) handle: Option>, } +#[cfg(feature = "graphql")] impl ErasedProjectorHandler for RegisteredModeledProjector where D: CausalProjectionRouteDependencies + Send + Sync + 'static, diff --git a/src/microsvc/service/causal.rs b/src/microsvc/service/causal.rs index 38ea9cb8..cbcc1bbf 100644 --- a/src/microsvc/service/causal.rs +++ b/src/microsvc/service/causal.rs @@ -226,7 +226,8 @@ pub(crate) enum CausalCommandPublicState { InProgress, Succeeded, SucceededPendingProjection, - Projected, + /// Terminal for an atomic command (same-tx read-model row sealed). + Atomic, Rejected, ProjectionFailed, Expired, @@ -240,7 +241,7 @@ impl CausalCommandPublicState { Self::InProgress => "in_progress", Self::Succeeded => "succeeded", Self::SucceededPendingProjection => "succeeded_pending_projection", - Self::Projected => "projected", + Self::Atomic => "atomic", Self::Rejected => "rejected", Self::ProjectionFailed => "projection_failed", Self::Expired => "expired", @@ -379,7 +380,7 @@ pub(super) fn replay_result( match replay.state { CommandLedgerState::Succeeded | CommandLedgerState::SucceededPendingProjection - | CommandLedgerState::Projected + | CommandLedgerState::Atomic | CommandLedgerState::ProjectionFailed => { let receipt = CausalCommandReceiptSource::from_replay(consistency, replay)?; Ok(CausalDispatchResult { @@ -641,8 +642,8 @@ where }); let (state, evidence) = match receipt.state { CommandLedgerState::Succeeded => (CausalCommandPublicState::Succeeded, Vec::new()), - CommandLedgerState::Projected => ( - CausalCommandPublicState::Projected, + CommandLedgerState::Atomic => ( + CausalCommandPublicState::Atomic, (0..receipt .projection_metadata .as_ref() @@ -956,7 +957,7 @@ pub(super) fn collapse_projection_evidence( .iter() .all(|item| item.state == CausalProjectionEvidenceState::Observed) { - CausalCommandPublicState::Projected + CausalCommandPublicState::Atomic } else { CausalCommandPublicState::SucceededPendingProjection } diff --git a/src/microsvc/service/handlers.rs b/src/microsvc/service/handlers.rs index dbc723de..5f7a17e3 100644 --- a/src/microsvc/service/handlers.rs +++ b/src/microsvc/service/handlers.rs @@ -10,7 +10,7 @@ use crate::aggregate::Aggregate; use crate::bus::Message; use crate::domain_event::DomainEvent; use crate::graphql::command_contract::CommandOutcome; -use crate::graphql::{Causal, GraphqlOutputType, PreparedCommand, Projected, Succeeded}; +use crate::graphql::{Atomic, Eventual, GraphqlOutputType, PreparedCommand, Succeeded}; use crate::microsvc::causal::{AggregatePublication, CausalWorkspace, CausalWorkspaceError}; use crate::microsvc::context::Context; use crate::microsvc::error::HandlerError; @@ -174,7 +174,7 @@ pub struct NoDirectProjection; /// ) where /// A: Aggregate + Send + Sync + 'static, /// { -/// let _ = commit.projected(()); +/// let _ = commit.atomic(()); /// } /// ``` pub struct DirectReadModelProjection(PhantomData M>); @@ -197,7 +197,7 @@ pub const fn direct_read_model() -> DirectReadModelProjection { DirectReadModelProjection::new() } -/// Handler-owned exact row staged for a same-transaction `Projected` result. +/// Handler-owned exact row staged for a same-transaction `Atomic` result. /// /// Built by [`CausalRepository::readmodel`] / [`CausalCommandContext::readmodel`]. /// The row should come from the same mutation program used for event→mutation @@ -444,7 +444,7 @@ where { /// Prepare a causal result. This terminal exists only after a publication /// leg; the dispatcher additionally proves actual durable outbox coverage. - pub fn causal(self, payload: T) -> Result>, HandlerError> + pub fn eventual(self, payload: T) -> Result>, HandlerError> where T: GraphqlOutputType + Serialize + Send + Sync + 'static, { @@ -462,14 +462,14 @@ where /// This method is available only when the preceding `project(...)` token is /// eligible for `M`. Dispatcher proof validation still rejects missing /// ownership, conflicts, partial rows, or a mismatched returned value. - pub fn projected(self, payload: M) -> Result>, HandlerError> + pub fn atomic(self, payload: M) -> Result>, HandlerError> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { let _ = self.projection; self.context .workspace - .prepare_projected(payload) + .prepare_atomic(payload) .map_err(workspace_handler_error) } } @@ -483,10 +483,10 @@ where /// /// The dispatcher proves one complete-row upsert matching `M` and commits it /// with the aggregate. No service placement registry is consulted. - pub fn projected(self) -> Result>, HandlerError> { + pub fn atomic(self) -> Result>, HandlerError> { self.context .workspace - .prepare_projected(self.projection.0) + .prepare_atomic(self.projection.0) .map_err(workspace_handler_error) } } @@ -507,13 +507,13 @@ where /// Prefer handler-owned [`CausalRepository::readmodel`] + /// [`PreparedCausalCommit::projected`] on [`StagedProjectedRow`] for new /// projected commands. - pub fn projected(self) -> Result>, HandlerError> + pub fn atomic(self) -> Result>, HandlerError> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { self.context .workspace - .prepare_modeled_projected(self.projection) + .prepare_modeled_atomic(self.projection) .map_err(workspace_handler_error) } } @@ -527,13 +527,13 @@ where /// Prefer [`CausalRepository::readmodel`] with a mutation-derived row for /// new code. This path remains for compatibility when a service registers /// a placement-selected direct executor for the returned model. - pub fn projected(self) -> Result>, HandlerError> + pub fn atomic(self) -> Result>, HandlerError> where M: RelationalReadModel + Serialize + Send + Sync + 'static, { self.context .workspace - .prepare_placement_selected_projected() + .prepare_placement_selected_atomic() .map_err(workspace_handler_error) } } diff --git a/src/microsvc/service/mod.rs b/src/microsvc/service/mod.rs index d36867e6..a01aec1c 100644 --- a/src/microsvc/service/mod.rs +++ b/src/microsvc/service/mod.rs @@ -44,12 +44,12 @@ pub(crate) use causal::{ CausalCommandProjectionObligation, CausalCommandPublicState, CausalCommandPublicStatus, CausalCommandReceiptSource, CausalProjectionEvidenceState, }; +#[allow(unused_imports)] // public API surface for handler-owned projected commits +pub use handlers::StagedProjectedRow; pub use handlers::{ direct_read_model, CausalCommandContext, CausalCommitBuilder, CausalRepository, DirectReadModelProjection, PreparedCausalCommit, PreparedCommandHandler, }; -#[allow(unused_imports)] // public API surface for handler-owned projected commits -pub use handlers::StagedProjectedRow; pub use request::{CommandRequest, CommandResponse}; pub(crate) use routes::DynBusPublisher; pub use routes::{ diff --git a/src/microsvc/service/routes.rs b/src/microsvc/service/routes.rs index 6d0d8bac..40055b02 100644 --- a/src/microsvc/service/routes.rs +++ b/src/microsvc/service/routes.rs @@ -1112,7 +1112,7 @@ where } let projection_metadata = match protocol.as_ref() { Some(protocol) - if self.contract.consistency != CommandConsistency::Projected + if self.contract.consistency != CommandConsistency::Atomic && !self.contract.projections.selectors.is_empty() => { if !projection_obligations.is_empty() { @@ -1207,24 +1207,24 @@ where }; let terminal_state = match (&projection_metadata, self.contract.consistency) { - (Some(metadata), CommandConsistency::Succeeded | CommandConsistency::Causal) + (Some(metadata), CommandConsistency::Succeeded | CommandConsistency::Eventual) if metadata.obligations.is_empty() => { TerminalCommandState::Succeeded } - (Some(_), CommandConsistency::Succeeded | CommandConsistency::Causal) => { + (Some(_), CommandConsistency::Succeeded | CommandConsistency::Eventual) => { TerminalCommandState::SucceededPendingProjection } - (Some(_), CommandConsistency::Projected) => unreachable!( + (Some(_), CommandConsistency::Atomic) => unreachable!( "same-transaction commands do not persist eventual modeled metadata" ), (None, CommandConsistency::Succeeded) if self.contract.confirmations.is_empty() => { TerminalCommandState::Succeeded } - (None, CommandConsistency::Succeeded | CommandConsistency::Causal) => { + (None, CommandConsistency::Succeeded | CommandConsistency::Eventual) => { TerminalCommandState::SucceededPendingProjection } - (None, CommandConsistency::Projected) => TerminalCommandState::Projected, + (None, CommandConsistency::Atomic) => TerminalCommandState::Atomic, }; let replay_payload = prepared.serialized_payload().clone(); let publisher = aggregate_repository.outbox_publisher(); @@ -1693,7 +1693,7 @@ where .flat_map(|handlers| handlers.values()) .filter_map(|handler| match handler { RegisteredHandler::Causal(handler) - if handler.contract().consistency == CommandConsistency::Projected => + if handler.contract().consistency == CommandConsistency::Atomic => { Some(handler.storage_identity(&self.dependencies)) } diff --git a/src/microsvc/service/runtime.rs b/src/microsvc/service/runtime.rs index e2b73532..b7817e45 100644 --- a/src/microsvc/service/runtime.rs +++ b/src/microsvc/service/runtime.rs @@ -126,7 +126,7 @@ impl Service { /// Typed commands are compared by service ID, a canonical structural /// fingerprint, and exact Rust input/output `TypeId`s. A validated engine /// may attach and serve reads and durable typed mutations only when its - /// opaque causal protocol tokens are configured. `Projected` commands + /// opaque causal protocol tokens are configured. `Atomic` commands /// additionally require the engine and command repository to carry the /// same opaque causal-storage identity. Services with no typed commands /// may attach a read-only engine. @@ -227,7 +227,7 @@ impl Service { if !projected_identities.is_empty() { let engine_identity = engine.causal_storage_identity().ok_or_else(|| { GraphqlServiceBindError( - "Projected commands require a GraphQL pool derived from the same repository handle" + "Atomic commands require a GraphQL pool derived from the same repository handle" .into(), ) })?; @@ -236,7 +236,7 @@ impl Service { .any(|identity| *identity != engine_identity) { return Err(GraphqlServiceBindError( - "Projected command repository and GraphQL query pool storage identities differ" + "Atomic command repository and GraphQL query pool storage identities differ" .into(), )); } diff --git a/src/microsvc/service/tests.rs b/src/microsvc/service/tests.rs index 3f621cf7..e2009625 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -16,12 +16,14 @@ use crate::command_ledger::{ use crate::graphql::command_contract::CommandConsistency; #[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; +#[cfg(all(feature = "graphql", feature = "sqlite"))] +use crate::graphql::Eventual; use crate::graphql::{ - typed_command, Causal, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, + typed_command, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, PreparedCommand, Succeeded, }; #[cfg(feature = "graphql")] -use crate::graphql::{Projected, SurfaceDirectProjection, SurfaceProjector}; +use crate::graphql::{Atomic, SurfaceDirectProjection, SurfaceProjector}; #[cfg(feature = "graphql")] use crate::microsvc::HasOutboxStore; use crate::microsvc::{ @@ -204,30 +206,23 @@ fn causal_direct_program( reason: e.to_string(), })?; let descriptor = crate::DomainEventDescriptor::state::(event_name, 1); - let handler = bind_state_body_to_mutation::( - &descriptor, - program, - "view", - ) - .map_err(|e| crate::ProjectionProgramError::InvalidOperation { - operation: name.into(), - reason: e.to_string(), - })?; - compile_projection(name, version, crate::ProjectionPartition::Unit, [handler]).map_err( - |e| crate::ProjectionProgramError::InvalidOperation { + let handler = + bind_state_body_to_mutation::(&descriptor, program, "view") + .map_err(|e| crate::ProjectionProgramError::InvalidOperation { + operation: name.into(), + reason: e.to_string(), + })?; + compile_projection(name, version, crate::ProjectionPartition::Unit, [handler]).map_err(|e| { + crate::ProjectionProgramError::InvalidOperation { operation: name.into(), reason: e.to_string(), - }, - ) + } + }) } #[cfg(feature = "graphql")] fn causal_direct_v1_program() -> Result { - causal_direct_program( - "project_causal_direct", - 1, - "causal.direct-recorded", - ) + causal_direct_program("project_causal_direct", 1, "causal.direct-recorded") } #[cfg(feature = "graphql")] @@ -331,15 +326,12 @@ fn causal_sibling_program() -> Result( - &descriptor, - program, - "view", - ) - .map_err(|e| crate::ProjectionProgramError::InvalidOperation { - operation: "project_causal_direct_sibling".into(), - reason: e.to_string(), - })?; + let handler = + bind_state_body_to_mutation::(&descriptor, program, "view") + .map_err(|e| crate::ProjectionProgramError::InvalidOperation { + operation: "project_causal_direct_sibling".into(), + reason: e.to_string(), + })?; compile_projection( "project_causal_direct_sibling", 1, @@ -1279,7 +1271,7 @@ fn causal_status_projection_failure_precedes_observed_and_pending_evidence() { item(0, CausalProjectionEvidenceState::Observed), item(1, CausalProjectionEvidenceState::Observed), ]), - CausalCommandPublicState::Projected + CausalCommandPublicState::Atomic ); assert_eq!( collapse_projection_evidence(&[ @@ -2191,7 +2183,7 @@ async fn graphql_terminal_replay_revalidates_after_active_projection_starts_drai .with_repo(repository.clone().aggregate::()) .with_read_model_store(repository.clone()) .typed_command( - typed_command::>("causal.lifecycle") + typed_command::>("causal.lifecycle") .roles(["user"]) .emits(crate::events![CausalLifecycleRecorded]), ) @@ -2206,7 +2198,7 @@ async fn graphql_terminal_replay_revalidates_after_active_projection_starts_drai context .publish_events() .commit(checkout)? - .causal(TypedOutput { id: input.id }) + .eventual(TypedOutput { id: input.id }) })(); async move { result } }, @@ -2456,7 +2448,7 @@ async fn engine_rejects_incompatible_direct_owner_before_typed_command_binding() .with_repo(repository.clone().aggregate::()) .typed_command(typed_command::< CausalProjectionInput, - Projected, + Atomic, >("causal.direct")) .handle( move |_context: &CausalCommandContext<'_, CausalDispatcherAggregate>, @@ -2549,11 +2541,11 @@ async fn projected_command_auto_binds_bootstraps_and_replays_exact_direct_eviden Routes::new() .with_repo(repository.clone().aggregate::()) // No direct-target/cache/projection call is present: the - // `Projected` output and Surface owner are the complete + // `Atomic` output and Surface owner are the complete // declaration. .typed_command(typed_command::< CausalProjectionInput, - Projected, + Atomic, >("causal.direct")) .handle( move |context: &CausalCommandContext<'_, CausalDispatcherAggregate>, @@ -2564,7 +2556,7 @@ async fn projected_command_auto_binds_bootstraps_and_replays_exact_direct_eviden let mut checkout = context.create(); checkout.record_direct(input.id.clone())?; // Placement-selected: registration owns CAUSAL_DIRECT_PROJECTION. - context.commit(checkout)?.projected() + context.commit(checkout)?.atomic() })(); async move { result } }, @@ -2642,7 +2634,7 @@ async fn projected_command_auto_binds_bootstraps_and_replays_exact_direct_eviden .service(&service) .client_projection_owners([projection.into()]) .build() - .expect("ordinary Projected declaration should auto-bind its unique owner"); + .expect("ordinary Atomic declaration should auto-bind its unique owner"); let service = service .try_with_graphql(engine) .expect("bound direct target should attach to its executable route"); @@ -2701,11 +2693,8 @@ async fn projected_command_auto_binds_bootstraps_and_replays_exact_direct_eviden .causal_command_status(&command_id, &Session::new(), causal_test_principal()) .await .expect("direct projected status should use ledger replay evidence"); - assert_eq!(direct_status.state, CausalCommandPublicState::Projected); - assert_eq!( - direct_status.consistency, - Some(CommandConsistency::Projected) - ); + assert_eq!(direct_status.state, CausalCommandPublicState::Atomic); + assert_eq!(direct_status.consistency, Some(CommandConsistency::Atomic)); assert!(direct_status.obligations.is_empty()); assert!(direct_status.evidence.is_empty()); let direct_status_evidence = direct_status @@ -2749,7 +2738,7 @@ async fn projected_command_auto_binds_bootstraps_and_replays_exact_direct_eviden let CommandLookup::Replay(first_replay) = lookup else { panic!("projected command should be terminally replayable"); }; - assert_eq!(first_replay.state, CommandLedgerState::Projected); + assert_eq!(first_replay.state, CommandLedgerState::Atomic); let evidence = first_replay .direct_projection .clone() diff --git a/src/microsvc/session.rs b/src/microsvc/session.rs index 246944ef..019e979c 100644 --- a/src/microsvc/session.rs +++ b/src/microsvc/session.rs @@ -107,16 +107,17 @@ impl Session { /// True when `role` is a member of the asserted set. pub fn has_role(&self, role: &str) -> bool { - self.roles().iter().any(|asserted| *asserted == role) + self.roles().contains(&role) } /// Singleton convenience: the sole asserted role, or `None` when the set /// is empty or multi-valued. Prefer [`roles`] / [`has_role`]. pub fn role(&self) -> Option<&str> { - let Some(raw) = self.get(ROLE_KEY).or_else(|| self.get("X-Roles")) else { - return None; - }; - let mut parts = raw.split(',').map(str::trim).filter(|part| !part.is_empty()); + let raw = self.get(ROLE_KEY).or_else(|| self.get("X-Roles"))?; + let mut parts = raw + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()); let first = parts.next()?; if parts.next().is_some() { None diff --git a/src/mutation/descriptor.rs b/src/mutation/descriptor.rs index 97045f8d..85e3b0e8 100644 --- a/src/mutation/descriptor.rs +++ b/src/mutation/descriptor.rs @@ -124,9 +124,7 @@ where { events .iter() - .map(|descriptor| { - bind_state_body_to_mutation::(descriptor, program.clone(), input_root) - }) + .map(|descriptor| bind_state_body_to_mutation::(descriptor, program.clone(), input_root)) .collect() } diff --git a/src/postgres_repo/mod.rs b/src/postgres_repo/mod.rs index 344b6ebb..c15c2183 100644 --- a/src/postgres_repo/mod.rs +++ b/src/postgres_repo/mod.rs @@ -49,6 +49,11 @@ static POSTGRES_MIGRATOR: LazyLock = LazyLock::new(|| { "projection protocol", include_str!("../../migrations/postgres/0003_projection_protocol.sql"), ), + ( + 4, + "command ledger atomic state", + include_str!("../../migrations/postgres/0004_command_ledger_atomic_state.sql"), + ), ]) }); const POSTGRES_BACKEND: &str = "postgres"; diff --git a/src/projection/executor.rs b/src/projection/executor.rs index 4cc65899..1a97efe2 100644 --- a/src/projection/executor.rs +++ b/src/projection/executor.rs @@ -751,10 +751,9 @@ mod tests { fn executor_multi_table_program( ) -> Result { use crate::mutation::{ - body_field_binding, compile_projection, MutationAssignment, - MutationConflictTarget, MutationEventBinding, MutationExpression, MutationField, - MutationKeyField, MutationKind, MutationOperation, MutationProgram, - ProjectionHandler, + body_field_binding, compile_projection, MutationAssignment, MutationConflictTarget, + MutationEventBinding, MutationExpression, MutationField, MutationKeyField, + MutationKind, MutationOperation, MutationProgram, ProjectionHandler, }; use crate::projection::{ ProjectionEventSelector, ProjectionPartition, ProjectionTarget, ProjectionValueType, diff --git a/src/projection/lower.rs b/src/projection/lower.rs index 6c2458f7..32030f04 100644 --- a/src/projection/lower.rs +++ b/src/projection/lower.rs @@ -503,6 +503,7 @@ pub struct ProjectionServerExecutorDescriptor { /// Canonical output inventory. pub outputs: ProjectionOutputInventory, selectors: Vec, + #[cfg_attr(not(feature = "graphql"), allow(dead_code))] unit_partition: bool, resolve: ProjectionResolver, lower: ProjectionLowerer, @@ -515,6 +516,7 @@ impl ProjectionServerExecutorDescriptor { .any(|selector| selector.matches(occurrence)) } + #[cfg(feature = "graphql")] pub(crate) fn has_unit_partition(&self) -> bool { self.unit_partition } @@ -536,7 +538,7 @@ impl ProjectionServerExecutorDescriptor { /// Process-wide registry of placement-selected direct projection executors. /// /// Service construction registers each direct modeled owner by output model -/// name. Command handlers then call `commit()?.projected()` without naming a +/// name. Command handlers then call `commit()?.atomic()` without naming a /// projection selector; the runtime looks up the executor by returned model. static PLACEMENT_SELECTED_DIRECT: OnceLock< RwLock>, @@ -572,7 +574,7 @@ pub fn placement_selected_direct_for_model( } /// Register every single-model output of a direct descriptor for placement -/// selection. Multi-model descriptors are not eligible for `Projected`. +/// selection. Multi-model descriptors are not eligible for `Atomic`. /// /// # Errors /// diff --git a/src/projection_protocol.rs b/src/projection_protocol.rs index 5fdfba5e..17293d24 100644 --- a/src/projection_protocol.rs +++ b/src/projection_protocol.rs @@ -260,6 +260,7 @@ impl ProjectorTopologyId { /// This remains crate-private: public callers select registered projector /// declarations and cannot mint topology authority from database-shaped /// bytes. + #[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) fn from_canonical_bytes( canonical_bytes: &[u8], ) -> Result { diff --git a/src/projection_protocol/codec/topology.rs b/src/projection_protocol/codec/topology.rs index a8f95f2e..a82cfdca 100644 --- a/src/projection_protocol/codec/topology.rs +++ b/src/projection_protocol/codec/topology.rs @@ -63,6 +63,7 @@ impl CompiledProjectionTopology { /// Rehydrate one generated modeled executor from its catalog-pinned /// physical topology and exact output schemas. + #[cfg(feature = "graphql")] pub(crate) fn from_modeled_binding<'a>( topology: ProjectorTopologyId, outputs: impl IntoIterator, diff --git a/src/sqlite_repo/mod.rs b/src/sqlite_repo/mod.rs index fc8b5f46..e06041d0 100644 --- a/src/sqlite_repo/mod.rs +++ b/src/sqlite_repo/mod.rs @@ -50,6 +50,11 @@ static SQLITE_MIGRATOR: LazyLock = LazyLock::new(|| { "projection protocol", include_str!("../../migrations/sqlite/0003_projection_protocol.sql"), ), + ( + 4, + "command ledger atomic state", + include_str!("../../migrations/sqlite/0004_command_ledger_atomic_state.sql"), + ), ]) }); const SQLITE_BACKEND: &str = "sqlite"; diff --git a/src/sqlx_repo/projection_protocol/postgres_tests.rs b/src/sqlx_repo/projection_protocol/postgres_tests.rs index f2559d08..9f2ff835 100644 --- a/src/sqlx_repo/projection_protocol/postgres_tests.rs +++ b/src/sqlx_repo/projection_protocol/postgres_tests.rs @@ -1594,7 +1594,7 @@ mod postgres_tests { let causation_id = attempt.causation_id().as_str().to_string(); let completion = attempt .complete( - TerminalCommandState::Projected, + TerminalCommandState::Atomic, serde_json::json!({"postgres_projected": true}), retention, ) diff --git a/src/sqlx_repo/projection_protocol/tests.rs b/src/sqlx_repo/projection_protocol/tests.rs index fd36acb1..c45833eb 100644 --- a/src/sqlx_repo/projection_protocol/tests.rs +++ b/src/sqlx_repo/projection_protocol/tests.rs @@ -2837,7 +2837,7 @@ mod tests { let causation_id = attempt.causation_id().as_str().to_string(); let completion = attempt .complete( - TerminalCommandState::Projected, + TerminalCommandState::Atomic, serde_json::json!({"projected": true}), retention, ) @@ -2907,7 +2907,7 @@ mod tests { let failed_causation = failed_attempt.causation_id().as_str().to_string(); let failed_completion = failed_attempt .complete( - TerminalCommandState::Projected, + TerminalCommandState::Atomic, serde_json::json!({"projected": "must-roll-back"}), retention, ) @@ -2925,7 +2925,7 @@ mod tests { sqlx::query( "CREATE TRIGGER fail_direct_ledger_completion \ BEFORE UPDATE OF state ON command_ledger \ - WHEN NEW.state = 'projected' \ + WHEN NEW.state = 'atomic' \ BEGIN SELECT RAISE(ABORT, 'forced direct ledger failure'); END", ) .execute(repository.pool()) @@ -2987,7 +2987,7 @@ mod tests { let fenced_causation = fenced_attempt.causation_id().as_str().to_string(); let fenced_completion = fenced_attempt .complete( - TerminalCommandState::Projected, + TerminalCommandState::Atomic, serde_json::json!({"projected": "must-not-run"}), retention, ) diff --git a/tests/domain_event_projection_server.rs b/tests/domain_event_projection_server.rs index 69f06bda..82848f22 100644 --- a/tests/domain_event_projection_server.rs +++ b/tests/domain_event_projection_server.rs @@ -4,6 +4,7 @@ use distributed::projection::lower::{ ProjectionLoweringError, }; use distributed::read_model::ReadModelWritePlanBuilder; +#[cfg(feature = "sqlite")] use distributed::table::TableSchemaRegistry; use distributed::{ Entity, InMemoryRepository, ProjectionArm, ProjectionEventSet, ProjectionExpression, diff --git a/tests/e2e-ui/PROJECTION_ROLLOUT.md b/tests/e2e-ui/PROJECTION_ROLLOUT.md index aaa95653..b3af01ef 100644 --- a/tests/e2e-ui/PROJECTION_ROLLOUT.md +++ b/tests/e2e-ui/PROJECTION_ROLLOUT.md @@ -17,7 +17,8 @@ epochs, and routes from its catalog. | `AuthUsers` query shape and read RBAC | Read-model owner | Schema and authorization tests | | Zitadel provider-event contract and `AuthUsers` mapping | Projection owner | Ingestor mapping tests | | Forward query relationships to `AuthUsers` | Read-model owner | Schema/storage-identity regression tests | -| Explicit modeled projector event handlers | Service owner | Handler-application and causal protocol tests | +| Explicit modeled projector event handlers (Eventual apply site) | Service owner | Handler-application and causal protocol tests | +| Handler-owned `Atomic` staging (Direct apply site; same mutation IR) | Service / command owner | Returning-row proof + client `confirmDirectProjection` | | Projection source, owner, physical topology, route, and activation | Service deployment owner | Catalog validation and exact active-binding handshake | | Generated manifest/TypeScript/SDL | Client compiler owner | Generate then byte-for-byte check mode | | Projector checkpoints/failure rows | Projector runtime owner | Drain and replay observations | diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index 381881a8..839f47c4 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -75,7 +75,7 @@ Command registration declares the domain events this command may emit and the hand-built cache path): ```rust -typed_command::>("todo.complete") +typed_command::>("todo.complete") .emits(events![TodoCompletedDomainEvent]) .applies(state_preview! { TodoCompletedDomainEvent => TodoState { @@ -112,7 +112,7 @@ repo.publish_events() ``` Blob uses a **handler-owned projected** commit: materialize the row from the -same mutation used for event bindings, stage it, and seal `Projected`: +same mutation used for event bindings, stage it, and seal `Atomic`: ```rust let repo = ctx.repo(); @@ -130,11 +130,42 @@ repo.readmodel(row) .projected() ``` -`Projected` means aggregate history, command ledger, read-model row, +`Atomic` means aggregate history, command ledger, read-model row, and response evidence commit atomically. Its deliberately narrow eligibility is one complete row upsert; patches, deletes, multi-row programs, and stateful relationship work remain eventual. +### Ship contract: same IR, two response proofs (agents) + +There is **one** portable mutation program (e.g. `save_blob_game` / +`save_todo`). Placement chooses *where* it runs. The **command response** +differs on purpose — do **not** collapse them into “always send a causal delta.” + +| Contract | Apply site | Mutation response (ship) | Client seal | +|---|---|---|---| +| **`Eventual`** + Eventual | Event handler after commit | Payload + **projection-delta** + `expects` | `.applies` preview; retire on obligations | +| **`Atomic`** + Direct | Command handler, same tx | **Typed row `M`** + direct **`records[]`**. No eventual modeled metadata, empty `expects` | Optional `.applies`; **`confirmDirectProjection(row, records)`** before `await` settles | + +Handler for Atomic — this *is* returning atomic read-model updates: + +```rust +let row = save_blob_game().from_state(&BlobGameState::from(&*game))?; +repo.readmodel(row).publish_events().commit(game)?.projected() +// GraphQL returns BlobGames; extensions.records from same-tx evidence. +``` + +Server enforces the split: same-transaction commands **do not** persist eventual +modeled projection metadata (`routes.rs`). The typed row + records *are* the +atomic proof — not a re-encoded causal delta. + +Do **not**: + +- reimplement domain rules in the UI (“board-sim”) for Atomic commands; +- require a causal projection-delta on Atomic responses (client bug / wrong API); +- treat Direct as “no client program” — Direct may export preview IR for `.applies` + (`is_preview_eligible`) without becoming Eventual; +- conflate `is_causally_eligible` (Eventual-only obligations) with preview eligibility. + ## Vocabulary - An **aggregate event** is write-side history used to replay the aggregate. @@ -162,9 +193,12 @@ optimism. Multi-model atomicity is expressed as multi-op mutation programs, not a public projector ORM workspace. Application commands declare `.emits` plus `.applies(...)` known mutation-input -mapping for client cache application. Eventual commands do not stage rows in -the handler; Blob stages the mutation-derived row with -`readmodel(row).commit()?.projected()`. +mapping for client cache application. Both Eventual and Direct surfaces may +export those previews from the portable program. Eventual commands do not stage +rows in the handler (the event handler applies the mutation later). Blob stages +the mutation-derived row with `readmodel(row).commit()?.projected()` so the +GraphQL response carries the authoritative row — possible only because apply +happens in the command handler, not in a later event handler. Query relationships are declared once on the referencing read model, without a second projection ORM. This fixture adds `Todos.owner`, `BlobGames.owner`, and @@ -182,8 +216,8 @@ declarations; `service.rs` does not recreate the grants. | Outcome | Guarantee | |---|---| | `Succeeded` | Command transaction succeeded; no projection wait is promised. | -| `Causal` | Actual emitted occurrences created durable obligations for the exact active causal projector bindings. Zero actual occurrences complete immediately as succeeded. | -| `Projected` | The eligible canonical read-model row committed in the command transaction and is returned as evidence. | +| `Eventual` | Actual emitted occurrences created durable obligations for the exact active causal projector bindings. Zero actual occurrences complete immediately as succeeded. | +| `Atomic` | Eligible canonical read-model row committed in the command transaction **and returned on the response** (handler-owned apply). Client normalizes that row before the call settles. | `Accepted` is reserved for genuine fire-and-forget transport acceptance, not the normal GraphQL command result. @@ -204,11 +238,25 @@ the normal GraphQL command result. Todo and Chat mount catalog-pinned local causal executors through explicit `modeled_projector(...).handle(...)` event handlers. Those handlers apply the -shared plan without repeating its mapping. Blob has a catalog-pinned direct -owner and no asynchronous event route or second writer. The Zitadel provider +shared plan without repeating its mapping — that is why the waiting client only +has `.applies` previews until obligations complete. Blob has a catalog-pinned +direct owner and no asynchronous event route or second writer: the command +handler applies the same mutation IR and returns the row. The Zitadel provider ingestor remains an integration adapter, while its provider-event-to-`AuthUsers` mapping also lives in `e2e-projections` and runs from an explicit event handler. +## Application composition (framework direction) + +e2e-ui is the Full-process reference. Long-term wiring should follow +[docs/application-composition.md](../../docs/application-composition.md): + +- **Logical:** command defs + projection mounts + surfaces (product graph) +- **Process role:** Full | CommandWriter | EventualProjector | QueryApi +- **Runtime:** store + locks + bus + workers + GraphQL (dialect / host) + +Atomic commands stay collocated with their write path; Eventual projectors +may run in another process on the same packages. + ## Generation and tests ```bash @@ -219,6 +267,27 @@ make test-live make test-browser ``` +### Optimism regression gates + +Client optimism is proven in two layers: + +1. **Offline artifacts** (`ui/tests/optimism-artifacts.test.mjs`, run via `make ui-test` / + UI `npm test`): every demo write command must export non-empty + `projection.preview.operations`; Atomic also needs `directProjection`; chat + `author` must be nullable and the live list must allow local first-page inserts. +2. **Browser paint-before-wire** (`e2e/optimism.user.spec.ts` + + `e2e/helpers/optimism.ts`): hold the GraphQL mutation response longer than the + assert deadline and require the UI to update first. Chat (including a full + first page), todos create/complete, and blob move continuity are covered. + +```bash +# offline +cd ui && npm test -- tests/optimism-artifacts.test.mjs + +# browser (stack up: make up && make run) +npx playwright test e2e/optimism.user.spec.ts --project=chromium-user +``` + `make test-live` needs the local Postgres/Zitadel stack. Browser tests also need the UI/API processes and a checked-in Playwright runtime. The always-on offline path uses SQLite plus `DevHeaders`; production-shaped identity uses diff --git a/tests/e2e-ui/crates/blob-domain/src/lib.rs b/tests/e2e-ui/crates/blob-domain/src/lib.rs index 22b8b7b2..ed98c039 100644 --- a/tests/e2e-ui/crates/blob-domain/src/lib.rs +++ b/tests/e2e-ui/crates/blob-domain/src/lib.rs @@ -9,7 +9,7 @@ pub mod models; pub use levels::{demo_map, generate_level, generate_level_with, is_hamiltonian_passable}; pub use models::tile; pub use models::{ - test_map_no_holes, test_map_with_hole, BlobError, BlobGame, BlobGameState, + simulate_move, test_map_no_holes, test_map_with_hole, BlobError, BlobGame, BlobGameState, BlobInitializedDomainEvent, BlobLevelStartedDomainEvent, BlobMovedDomainEvent, - BlobStartedDomainEvent, Direction, + BlobStartedDomainEvent, Direction, MovePreview, }; diff --git a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs index fb8ad6b6..df0748f3 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/blob_game.rs @@ -63,6 +63,108 @@ fn status_of(player_dead: bool, level_complete: bool) -> String { } } +/// Pure post-move board snapshot (no ownership / aggregate checks). +/// +/// Shared by the aggregate and client-side optimistic preview (TypeScript port +/// in e2e-ui must stay byte-identical for tile rules). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MovePreview { + pub map: Vec>, + pub score: i64, + pub player_dead: bool, + pub level_complete: bool, +} + +impl MovePreview { + pub fn status(&self) -> String { + status_of(self.player_dead, self.level_complete) + } +} + +/// Apply one direction to a map + score. Pure — used by [`BlobGame::move_dir`] +/// and mirrored in the e2e-ui optimistic board sim. +pub fn simulate_move( + map: &[Vec], + score: i64, + direction: Direction, +) -> Result { + if map.is_empty() || map[0].is_empty() { + return Err(BlobError::NoActiveLevel); + } + let (r, c) = player_pos_in(map)?; + let (nr, nc) = match direction { + Direction::Up => { + if r == 0 { + return Err(BlobError::CannotMove("row already 0".into())); + } + (r - 1, c) + } + Direction::Down => { + if r + 1 >= map.len() { + return Err(BlobError::CannotMove("already at bottom edge".into())); + } + (r + 1, c) + } + Direction::Left => { + if c == 0 { + return Err(BlobError::CannotMove("column already 0".into())); + } + (r, c - 1) + } + Direction::Right => { + if c + 1 >= map[r].len() { + return Err(BlobError::CannotMove("already at right edge".into())); + } + (r, c + 1) + } + }; + + let mut next_map = map.to_vec(); + let mut score = score; + let mut player_dead = false; + let mut level_complete = false; + + next_map[r][c] = tile::VISITED; + match next_map[nr][nc] { + tile::HOLE => next_map[nr][nc] = tile::DEAD_BY_HOLE, + tile::VISITED => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, + tile::UNVISITED | tile::PLAYER => { + score += 1; + next_map[nr][nc] = tile::PLAYER; + } + _ => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, + } + for row in &next_map { + if row.contains(&tile::DEAD_BY_HOLE) || row.contains(&tile::DEAD_BY_SUICIDE) { + player_dead = true; + level_complete = false; + break; + } + } + if !player_dead { + let any_u = next_map.iter().any(|row| row.contains(&tile::UNVISITED)); + level_complete = !any_u; + } + + Ok(MovePreview { + map: next_map, + score, + player_dead, + level_complete, + }) +} + +fn player_pos_in(map: &[Vec]) -> Result<(usize, usize), BlobError> { + for (r, row) in map.iter().enumerate() { + for (c, &t) in row.iter().enumerate() { + if t == tile::PLAYER { + return Ok((r, c)); + } + } + } + Err(BlobError::NoActiveLevel) +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BlobGame { #[serde(skip, default)] @@ -107,15 +209,9 @@ impl BlobGame { } } + #[cfg(test)] fn player_pos(&self) -> Result<(usize, usize), BlobError> { - for (r, row) in self.map.iter().enumerate() { - for (c, &t) in row.iter().enumerate() { - if t == tile::PLAYER { - return Ok((r, c)); - } - } - } - Err(BlobError::NoActiveLevel) + player_pos_in(&self.map) } } @@ -247,67 +343,12 @@ impl BlobGame { if self.current_level == 0 || self.map.is_empty() { return Err(BlobError::NoActiveLevel); } - let (r, c) = self.player_pos()?; - let (nr, nc) = match direction { - Direction::Up => { - if r == 0 { - return Err(BlobError::CannotMove("row already 0".into())); - } - (r - 1, c) - } - Direction::Down => { - if r + 1 >= self.map.len() { - return Err(BlobError::CannotMove("already at bottom edge".into())); - } - (r + 1, c) - } - Direction::Left => { - if c == 0 { - return Err(BlobError::CannotMove("column already 0".into())); - } - (r, c - 1) - } - Direction::Right => { - if c + 1 >= self.map[r].len() { - return Err(BlobError::CannotMove("already at right edge".into())); - } - (r, c + 1) - } - }; - // Capture post-move snapshot via event recorder. - let mut next_map = self.map.clone(); - let mut score = self.score; - let mut player_dead = self.player_dead; - let mut level_complete = self.current_level_completed; - - // Simulate on temps then record once. - next_map[r][c] = tile::VISITED; - match next_map[nr][nc] { - tile::HOLE => next_map[nr][nc] = tile::DEAD_BY_HOLE, - tile::VISITED => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, - tile::UNVISITED | tile::PLAYER => { - score += 1; - next_map[nr][nc] = tile::PLAYER; - } - _ => next_map[nr][nc] = tile::DEAD_BY_SUICIDE, - } - for row in &next_map { - if row.contains(&tile::DEAD_BY_HOLE) || row.contains(&tile::DEAD_BY_SUICIDE) { - player_dead = true; - level_complete = false; - break; - } - } - if !player_dead { - let any_u = next_map.iter().any(|row| row.contains(&tile::UNVISITED)); - level_complete = !any_u; - } - + let preview = simulate_move(&self.map, self.score, direction)?; self.record_moved( - score, - player_dead, - level_complete, - next_map, + preview.score, + preview.player_dead, + preview.level_complete, + preview.map, direction.as_str().to_string(), )?; Ok(()) @@ -353,6 +394,18 @@ mod tests { assert_eq!(g.player_pos().unwrap(), (0, 0)); } + #[test] + fn simulate_move_matches_move_dir() { + let mut g = BlobGame::default(); + g.start_with_map("g1", "alice", test_map_no_holes()).unwrap(); + let preview = simulate_move(&g.map, g.score, Direction::Right).unwrap(); + g.move_dir("alice", Direction::Right).unwrap(); + assert_eq!(g.map, preview.map); + assert_eq!(g.score, preview.score); + assert_eq!(g.player_dead, preview.player_dead); + assert_eq!(g.current_level_completed, preview.level_complete); + } + #[test] fn start_level_requires_complete() { let mut g = game_with_map(test_map_no_holes()); diff --git a/tests/e2e-ui/crates/blob-domain/src/models/mod.rs b/tests/e2e-ui/crates/blob-domain/src/models/mod.rs index ee771cb4..e0316c2c 100644 --- a/tests/e2e-ui/crates/blob-domain/src/models/mod.rs +++ b/tests/e2e-ui/crates/blob-domain/src/models/mod.rs @@ -8,7 +8,7 @@ pub mod tile; pub use blob_error::BlobError; pub use blob_game::{ - test_map_no_holes, test_map_with_hole, BlobGame, + simulate_move, test_map_no_holes, test_map_with_hole, BlobGame, MovePreview, BlobGameInitializedDomainEvent as BlobInitializedDomainEvent, BlobGameLevelStartedDomainEvent as BlobLevelStartedDomainEvent, BlobGameMovedDomainEvent as BlobMovedDomainEvent, diff --git a/tests/e2e-ui/crates/projections/src/blob.rs b/tests/e2e-ui/crates/projections/src/blob.rs index 377b171b..82ebc724 100644 --- a/tests/e2e-ui/crates/projections/src/blob.rs +++ b/tests/e2e-ui/crates/projections/src/blob.rs @@ -20,7 +20,7 @@ pub fn save_blob_game() -> Mutation<()> { // When these domain events fire, apply [`save_blob_game`] (body → `input.game`). // Event-first: on { events, mutation, input } — same shape as todos/chat. // Command path stages the row via `Mutation::from_state` + -// `readmodel(row).commit()?.projected()`. +// `readmodel(row).commit()?.atomic()`. // Macro is `projection!` (crate root); `distributed::projection` is the module. distributed::projection! { pub const BLOB_GAMES: ProjectionDescriptor = { diff --git a/tests/e2e-ui/crates/readmodels/src/lib.rs b/tests/e2e-ui/crates/readmodels/src/lib.rs index bcaba450..8454992f 100644 --- a/tests/e2e-ui/crates/readmodels/src/lib.rs +++ b/tests/e2e-ui/crates/readmodels/src/lib.rs @@ -89,9 +89,22 @@ mod tests { let tail = &sdl[start..]; &tail[..tail.find("\n}\n").unwrap()] }; - assert!(object("Todos").contains("\n owner: AuthUsers")); - assert!(object("BlobGames").contains("\n owner: AuthUsers")); - assert!(object("ChatMessages").contains("\n author: AuthUsers")); + // belongs_to is always GraphQL-nullable (Option join), even when the FK + // column is non-null — required for optimistic parent rows before the + // join edge is linked. (Last field may be truncated before the closing + // brace, so match without requiring a trailing newline.) + for name in ["Todos", "BlobGames", "ChatMessages"] { + let body = object(name); + let join = if name == "ChatMessages" { "author" } else { "owner" }; + assert!( + body.contains(&format!("\n {join}: AuthUsers")), + "{name} missing nullable AuthUsers join:\n{body}" + ); + assert!( + !body.contains(&format!("\n {join}: AuthUsers!")), + "{name} must not mark belongs_to non-null:\n{body}" + ); + } } #[test] diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs index b57f5805..482910cf 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_move.rs @@ -1,7 +1,12 @@ //! Command: `blob.move` — direction up|down|left|right. +//! +//! Optimistic outcome fields on the input are **client preview fill** (same +//! pattern as chat `created_at` / `message_id`): the generated `.applies` +//! preview maps them into the replica optimistic layer. Authority still comes +//! only from `game_id` + `direction` + domain `move_dir`. use blob_domain::{BlobGame, BlobGameState, Direction}; -use distributed::graphql::{PreparedCommand, Projected}; +use distributed::graphql::{Atomic, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; @@ -15,12 +20,20 @@ pub const COMMAND: &str = "blob.move"; pub struct BlobMoveInput { pub game_id: String, pub direction: String, + /// Optimistic board JSON (`number[][]`) for `.applies` preview only. + pub map_json: String, + pub score: i64, + pub player_dead: bool, + pub current_level: i64, + pub current_level_completed: bool, + /// `active` | `dead` | `level_complete` + pub status: String, } pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobMoveInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let dir = Direction::parse(&input.direction).ok_or_else(|| { HandlerError::Rejected(format!( @@ -34,14 +47,15 @@ pub async fn handle( .get(&input.game_id) .await? .ok_or_else(|| HandlerError::NotFound(input.game_id.clone()))?; + // Preview fields on `input` are not trusted for authority. game.move_dir(&owner, dir).map_err(rejected)?; - // Handler-owned projected: same mutation IR as event→mutation bindings. + // Handler-owned atomic: same mutation IR as event→mutation bindings. let row = save_blob_game() .from_state(&BlobGameState::from(&*game)) .map_err(|error| HandlerError::Other(Box::new(error)))?; repo.readmodel(row) .publish_events() .commit(game)? - .projected() + .atomic() } diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs index deaceca5..b92d8c56 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start.rs @@ -1,7 +1,7 @@ //! Command: `blob.start` — create game + demo level. Owner = session user. use blob_domain::{BlobGame, BlobGameState}; -use distributed::graphql::{PreparedCommand, Projected}; +use distributed::graphql::{PreparedCommand, Atomic}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; @@ -21,7 +21,7 @@ pub type BlobGamePayload = BlobGames; pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobStartInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); @@ -42,5 +42,5 @@ pub async fn handle( repo.readmodel(row) .publish_events() .commit(game)? - .projected() + .atomic() } diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs index bd621622..c4fba25b 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/blob_start_level.rs @@ -1,7 +1,7 @@ //! Command: `blob.start_level` — next level after complete (new generated map). use blob_domain::{BlobGame, BlobGameState}; -use distributed::graphql::{PreparedCommand, Projected}; +use distributed::graphql::{PreparedCommand, Atomic}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use e2e_projections::save_blob_game; use e2e_readmodels::BlobGames; @@ -19,7 +19,7 @@ pub struct BlobStartLevelInput { pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobStartLevelInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut game = repo @@ -35,5 +35,5 @@ pub async fn handle( repo.readmodel(row) .publish_events() .commit(game)? - .projected() + .atomic() } diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs b/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs index a8e48491..77088a5c 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/chat_post.rs @@ -1,7 +1,7 @@ //! Command: `chat.post` — author is always the authenticated session user. use chat_domain::{ChatMessage, ChatMessageState}; -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; @@ -31,7 +31,7 @@ pub struct ChatPostPayload { pub async fn handle( ctx: &CausalCommandContext<'_, ChatMessage>, input: ChatPostInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let author = ctx.user_id()?.to_string(); let created_at = canonical_near_unix_millis(&input.created_at)?; let repo = ctx.repo(); @@ -54,7 +54,7 @@ pub async fn handle( .map_err(rejected)?; let state = ChatMessageState::from(&*msg); - repo.publish_events().commit(msg)?.causal(ChatPostPayload { + repo.publish_events().commit(msg)?.eventual(ChatPostPayload { message_id: state.message_id, room_id: state.room_id, author_id: state.author_id, diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs index 54548076..4d199736 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_archive.rs @@ -1,6 +1,6 @@ //! Command: `todo.archive` — owner-only (aggregate enforces). -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::Deserialize; use todo_domain::{Todo, TodoState}; @@ -21,7 +21,7 @@ pub struct TodoArchiveInput { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoArchiveInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo @@ -33,7 +33,7 @@ pub async fn handle( let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? - .causal(TodoArchivePayload { + .eventual(TodoArchivePayload { todo_id: state.todo_id, status: state.status, }) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs index 854bf965..760d4e11 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_complete.rs @@ -1,6 +1,6 @@ //! Command: `todo.complete` — owner-only (aggregate enforces). -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::Deserialize; use todo_domain::{Todo, TodoState}; @@ -18,7 +18,7 @@ pub struct TodoCompleteInput { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoCompleteInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo @@ -30,7 +30,7 @@ pub async fn handle( let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? - .causal(TodoStatusPayload { + .eventual(TodoStatusPayload { todo_id: state.todo_id, status: state.status, }) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs index f1d2f65e..f7f8d035 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_create.rs @@ -3,7 +3,7 @@ //! GraphQL: exposed as mutation field `todos_create` (roles: user, admin). //! Owner cannot be spoofed via input — only `require_user(session)` is written. -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; @@ -31,7 +31,7 @@ pub struct TodoCreatePayload { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoCreateInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { // Owner is always the authenticated principal — not client-supplied. let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); @@ -50,7 +50,7 @@ pub async fn handle( let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? - .causal(TodoCreatePayload { + .eventual(TodoCreatePayload { todo_id: state.todo_id, owner_id: state.owner_id, title: state.title, diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs index 2c46e3dd..1e37e134 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_force_archive.rs @@ -4,7 +4,7 @@ //! trails can tell admin intervention from self-service archive. Projector //! still upserts the same read-model shape. -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; @@ -30,7 +30,7 @@ pub struct TodoForceArchivePayload { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoForceArchiveInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let admin = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo @@ -42,7 +42,7 @@ pub async fn handle( let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? - .causal(TodoForceArchivePayload { + .eventual(TodoForceArchivePayload { todo_id: state.todo_id, owner_id: state.owner_id, status: state.status, diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs index 636b72fd..44b20514 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_purge.rs @@ -1,6 +1,6 @@ //! Command: `todo.purge` — owner-only physical read-model deletion. -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::Todo; @@ -23,7 +23,7 @@ pub struct TodoPurgePayload { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoPurgeInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo @@ -34,7 +34,7 @@ pub async fn handle( repo.publish_events() .commit(todo)? - .causal(TodoPurgePayload { + .eventual(TodoPurgePayload { todo_id: input.todo_id, purged: true, }) diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs index 527d605c..946f1d71 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_rename.rs @@ -1,6 +1,6 @@ //! Command: `todo.rename` — owner-only (aggregate enforces). -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::{Deserialize, Serialize}; use todo_domain::{Todo, TodoState}; @@ -25,7 +25,7 @@ pub struct TodoRenamePayload { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoRenameInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo @@ -37,7 +37,7 @@ pub async fn handle( let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? - .causal(TodoRenamePayload { + .eventual(TodoRenamePayload { todo_id: state.todo_id, title: state.title, status: state.status, diff --git a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs b/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs index 7ea1ba4c..03623fc1 100644 --- a/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs +++ b/tests/e2e-ui/crates/service/src/handlers/commands/todo_reopen.rs @@ -1,6 +1,6 @@ //! Command: `todo.reopen` — owner-only (aggregate enforces). -use distributed::graphql::{Causal, PreparedCommand}; +use distributed::graphql::{Eventual, PreparedCommand}; use distributed::microsvc::{CausalCommandContext, HandlerError}; use serde::Deserialize; use todo_domain::{Todo, TodoState}; @@ -20,7 +20,7 @@ pub struct TodoReopenInput { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoReopenInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo @@ -32,7 +32,7 @@ pub async fn handle( let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? - .causal(TodoReopenPayload { + .eventual(TodoReopenPayload { todo_id: state.todo_id, status: state.status, }) diff --git a/tests/e2e-ui/crates/service/src/service.rs b/tests/e2e-ui/crates/service/src/service.rs index e9b1371b..c8ff0b44 100644 --- a/tests/e2e-ui/crates/service/src/service.rs +++ b/tests/e2e-ui/crates/service/src/service.rs @@ -7,9 +7,9 @@ use blob_domain::{ }; use chat_domain::{ChatMessage, ChatMessagePostedDomainEvent}; use distributed::graphql::{ - build_surface, typed_command, Causal, CommandProjectionPreview, + build_surface, typed_command, Eventual, CommandProjectionPreview, CommandProjectionPreviewSource, DistributedClientSurfaceExport, GraphqlEngine, - GraphqlPoolSource, IdentityConfig, OidcConfig, Projected, SurfaceDirectProjection, + GraphqlPoolSource, IdentityConfig, OidcConfig, Atomic, SurfaceDirectProjection, SurfaceModeledProjection, SurfaceOptions, SurfaceProjector, }; use distributed::microsvc::{ @@ -132,7 +132,7 @@ fn projection_owners() -> ProjectionOwners { ) .expect("Blob projection binding"); // Blob projected commands stage the mutation-derived row in the handler - // (`readmodel(row).commit()?.projected()`). Binding/catalog still own + // (`readmodel(row).commit()?.atomic()`). Binding/catalog still own // ownership, replay, and async projection for BLOB_GAMES. let catalog = ProjectionCatalog::try_new(vec![ @@ -302,7 +302,7 @@ where .with_repo(repo.clone().queued_with(locks.clone()).aggregate::()) .with_read_model_store(read_models.clone()) .typed_command( - typed_command::>( + typed_command::>( todo_create::COMMAND, ) .field_name("todos_create") @@ -324,7 +324,7 @@ where ) .handle(todo_create::handle) .typed_command( - typed_command::>( + typed_command::>( todo_rename::COMMAND, ) .field_name("todos_rename") @@ -340,7 +340,7 @@ where ) .handle(todo_rename::handle) .typed_command( - typed_command::>( + typed_command::>( todo_complete::COMMAND, ) .field_name("todos_complete") @@ -356,7 +356,7 @@ where ) .handle(todo_complete::handle) .typed_command( - typed_command::>( + typed_command::>( todo_reopen::COMMAND, ) .field_name("todos_reopen") @@ -372,7 +372,7 @@ where ) .handle(todo_reopen::handle) .typed_command( - typed_command::>( + typed_command::>( todo_archive::COMMAND, ) .field_name("todos_archive") @@ -390,7 +390,7 @@ where .typed_command( typed_command::< todo_force_archive::TodoForceArchiveInput, - Causal, + Eventual, >(todo_force_archive::COMMAND) .field_name("todos_force_archive") .roles(["admin"]) @@ -405,7 +405,7 @@ where ) .handle(todo_force_archive::handle) .typed_command( - typed_command::>(todo_purge::COMMAND) + typed_command::>(todo_purge::COMMAND) .field_name("todos_purge") .roles(app_roles) .emits(distributed::events![TodoPurgedDomainEvent]) @@ -430,7 +430,7 @@ where ) .with_read_model_store(read_models.clone()) .typed_command( - typed_command::>( + typed_command::>( chat_post::COMMAND, ) .field_name("chat_messages_post") @@ -471,26 +471,68 @@ where .with_repo(repo.queued_with(locks).aggregate::()) .with_read_model_store(read_models) .typed_command( - typed_command::>(blob_start::COMMAND) + typed_command::>(blob_start::COMMAND) .field_name("blob_games_start") .roles(app_roles) - .emits(distributed::events![BlobStartedDomainEvent]), + .emits(distributed::events![BlobStartedDomainEvent]) + // Same mutation IR as eventual. Atomic waits for the handler + // row and returns it (confirmDirectProjection). `.applies` is + // optional pre-network shell — map_json is RNG server-side. + .applies(distributed::state_preview! { + BlobStartedDomainEvent => blob_domain::BlobGameState { + game_id: input.game_id, + owner_id: trusted("x-user-id", "string"), + score: 0, + player_dead: unknown, + current_level: 1, + current_level_completed: unknown, + map_json: "[]", + status: "active", + } + }), ) .handle(blob_start::handle) .typed_command( - typed_command::>(blob_move::COMMAND) + typed_command::>(blob_move::COMMAND) .field_name("blob_games_move") .roles(app_roles) - .emits(distributed::events![BlobMovedDomainEvent]), + .emits(distributed::events![BlobMovedDomainEvent]) + // Same client path as todos/chat: `.applies` maps command input + // into the optimistic layer. Client fills board fields from the + // pure simulate_move twin; server recomputes via domain. + .applies(distributed::state_preview! { + BlobMovedDomainEvent => blob_domain::BlobGameState { + game_id: input.game_id, + owner_id: trusted("x-user-id", "string"), + score: input.score, + player_dead: input.player_dead, + current_level: input.current_level, + current_level_completed: input.current_level_completed, + map_json: input.map_json, + status: input.status, + } + }), ) .handle(blob_move::handle) .typed_command( - typed_command::>( + typed_command::>( blob_start_level::COMMAND, ) .field_name("blob_games_start_level") .roles(app_roles) - .emits(distributed::events![BlobLevelStartedDomainEvent]), + .emits(distributed::events![BlobLevelStartedDomainEvent]) + .applies(distributed::state_preview! { + BlobLevelStartedDomainEvent => blob_domain::BlobGameState { + game_id: input.game_id, + owner_id: trusted("x-user-id", "string"), + score: unknown, + player_dead: unknown, + current_level: unknown, + current_level_completed: unknown, + map_json: "[]", + status: "active", + } + }), ) .handle(blob_start_level::handle); diff --git a/tests/e2e-ui/crates/suite/tests/behavioral.rs b/tests/e2e-ui/crates/suite/tests/behavioral.rs index ce0fbe90..4e6ef5ef 100644 --- a/tests/e2e-ui/crates/suite/tests/behavioral.rs +++ b/tests/e2e-ui/crates/suite/tests/behavioral.rs @@ -195,7 +195,7 @@ async fn t1a_application_surface_returns_actual_todo_upsert_and_causal_obligatio ), "{response}" ); - assert_eq!(command["consistency"], "causal", "{response}"); + assert_eq!(command["consistency"], "eventual", "{response}"); assert!( command["projection"]["delta"]["operations"] .as_array() @@ -670,7 +670,7 @@ async fn t7_modeled_no_ops_succeed_without_projection_obligations() { assert!(response.get("errors").is_none(), "{response}"); let receipt = &response["extensions"]["distributed"]["command"]; assert_eq!(receipt["state"], "succeeded", "{response}"); - assert_eq!(receipt["consistency"], "causal", "{response}"); + assert_eq!(receipt["consistency"], "eventual", "{response}"); assert_eq!(receipt["expects"], serde_json::json!([]), "{response}"); let first_payload = response["data"]["todos_rename"].clone(); let first_receipt = receipt.clone(); diff --git a/tests/e2e-ui/e2e/blob.user.spec.ts b/tests/e2e-ui/e2e/blob.user.spec.ts index 75157fce..fddcd4f0 100644 --- a/tests/e2e-ui/e2e/blob.user.spec.ts +++ b/tests/e2e-ui/e2e/blob.user.spec.ts @@ -164,7 +164,7 @@ test.describe('blob game (alice)', () => { expect(await cells.count()).toBeGreaterThanOrEqual(16); }); - test('a revalidation started before a projected move cannot roll it back with later evidence', async ({ page }) => { + test('a revalidation started before an atomic move cannot roll it back with later evidence', async ({ page }) => { await page.goto('/blob'); await expect(page.locator('[data-blob-hydrated="1"]')).toBeVisible({ timeout: 15_000 }); const start = page.getByTestId('blob-start-game'); @@ -309,8 +309,21 @@ test.describe('blob game (alice)', () => { expectedLabel ); }; + const refetch = () => + page.evaluate(() => { + const refetchBlobGames = ( + globalThis as typeof globalThis & { + __distributedBlobRefetch?: () => Promise; + } + ).__distributedBlobRefetch; + if (refetchBlobGames === undefined) { + throw new Error('Blob refetch test hook is unavailable'); + } + return refetchBlobGames(); + }); await move('ArrowRight', 'r0 c1'); + const heldRefetch = refetch(); await heldQuery; expect( raisedHeldRevision, @@ -338,7 +351,8 @@ test.describe('blob game (alice)', () => { ); released = true; releaseHeldQuery(); - await newerQuery; + await heldRefetch; + await Promise.all([newerQuery, refetch()]); await expect(page.locator('.blob-board .tile-player')).toHaveAttribute( 'aria-label', nextMove.label diff --git a/tests/e2e-ui/e2e/chat.user.spec.ts b/tests/e2e-ui/e2e/chat.user.spec.ts index 096f36dc..cd91de46 100644 --- a/tests/e2e-ui/e2e/chat.user.spec.ts +++ b/tests/e2e-ui/e2e/chat.user.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { expectOptimisticPaint } from './helpers/optimism'; test.describe('chat (alice)', () => { test('post a lobby message and see it in the log', async ({ page }) => { @@ -123,33 +124,20 @@ test.describe('chat (alice)', () => { }); }); - // Delay the network past the optimistic paint window. Keep headroom on - // CI (large lobby after history seeds, main-thread churn) while still - // proving the row appears before the fulfilled mutation response. - const networkDelayMs = 1_500; - const optimisticVisibleMs = 1_000; - await page.route('**/graphql', async (route) => { - if (!(route.request().postData() ?? '').includes('chat_messages_post')) { - await route.continue(); - return; - } - const response = await route.fetch(); - await new Promise((resolve) => setTimeout(resolve, networkDelayMs)); - await route.fulfill({ response }); - }); - const body = `continuity message ${Date.now()}`; - await page.locator('#chat-body').fill(body); - const commandResponse = page.waitForResponse( - (response) => - (response.request().postData() ?? '').includes('chat_messages_post') - ); - await page.getByRole('button', { name: /send/i }).click(); - await expect(page.locator('.ch-msg', { hasText: body })).toBeVisible({ - timeout: optimisticVisibleMs + const msg = page.locator('.ch-msg', { hasText: body }); + await expectOptimisticPaint(page, { + needle: 'chat_messages_post', + holdMs: 1_500, + assertWithinMs: 1_000, + act: async () => { + await page.locator('#chat-body').fill(body); + await page.getByRole('button', { name: /send/i }).click(); + }, + assertOptimistic: async () => { + await expect(msg).toBeVisible({ timeout: 200 }); + } }); - await commandResponse; - await page.unrouteAll({ behavior: 'wait' }); const samples = await page.evaluate(() => { const state = globalThis as typeof globalThis & { diff --git a/tests/e2e-ui/e2e/helpers/optimism.ts b/tests/e2e-ui/e2e/helpers/optimism.ts new file mode 100644 index 00000000..1cafa88c --- /dev/null +++ b/tests/e2e-ui/e2e/helpers/optimism.ts @@ -0,0 +1,128 @@ +/** + * Browser proof that a command paints optimistically. + * + * Hold the GraphQL mutation response past the assert deadline. If the UI + * updates before the held response is fulfilled, the paint came from the + * client optimistic layer — not the wire. + */ +import type { Page, Response } from '@playwright/test'; +import { expect } from '@playwright/test'; + +export type MutationNeedle = + | 'chat_messages_post' + | 'todos_create' + | 'todos_complete' + | 'todos_reopen' + | 'todos_archive' + | 'todos_rename' + | 'blob_games_start' + | 'blob_games_move' + | 'blob_games_start_level'; + +export type HoldMutationOptions = { + /** Substring matched against the GraphQL POST body. */ + readonly needle: MutationNeedle | string; + /** How long to hold the fulfilled mutation response (ms). */ + readonly holdMs?: number; + /** + * Max wait for the optimistic paint. Must be strictly less than holdMs so + * the assert cannot pass from the delayed wire response. + */ + readonly assertWithinMs?: number; +}; + +const DEFAULT_HOLD_MS = 1_500; +const DEFAULT_ASSERT_MS = 1_000; + +function mutationMatches(postData: string | null, needle: string): boolean { + return (postData ?? '').includes(needle); +} + +/** + * Install a GraphQL route that continues non-matching requests and delays + * matching mutation responses by holdMs. Returns a disposer. + */ +export async function holdGraphqlMutation( + page: Page, + needle: string, + holdMs: number = DEFAULT_HOLD_MS +): Promise<() => Promise> { + await page.route('**/graphql', async (route) => { + if (!mutationMatches(route.request().postData(), needle)) { + await route.continue(); + return; + } + const response = await route.fetch(); + await new Promise((resolve) => setTimeout(resolve, holdMs)); + await route.fulfill({ response }); + }); + return async () => { + await page.unrouteAll({ behavior: 'wait' }); + }; +} + +export type OptimisticPaintOptions = HoldMutationOptions & { + /** Trigger the command (click/send/etc). */ + readonly act: () => Promise; + /** Assert the optimistic UI state (use short timeout internally or via assertWithinMs). */ + readonly assertOptimistic: () => Promise; + /** Optional assert after the held response arrives. */ + readonly assertConverged?: () => Promise; +}; + +/** + * Run act under a held mutation and require assertOptimistic before the + * response is fulfilled. + */ +export async function expectOptimisticPaint( + page: Page, + options: OptimisticPaintOptions +): Promise { + const holdMs = options.holdMs ?? DEFAULT_HOLD_MS; + const assertWithinMs = options.assertWithinMs ?? DEFAULT_ASSERT_MS; + if (assertWithinMs >= holdMs) { + throw new Error( + `assertWithinMs (${assertWithinMs}) must be < holdMs (${holdMs}) to prove paint-before-wire` + ); + } + + const dispose = await holdGraphqlMutation(page, options.needle, holdMs); + const pending = page.waitForResponse( + (response) => + response.url().includes('/graphql') && + mutationMatches(response.request().postData(), options.needle), + { timeout: holdMs + 20_000 } + ); + + try { + await options.act(); + // Bound the optimistic assert so a late paint from the delayed response + // cannot satisfy the expectation. + await expect + .poll(async () => { + try { + await options.assertOptimistic(); + return true; + } catch { + return false; + } + }, { timeout: assertWithinMs, intervals: [50, 100, 150, 200] }) + .toBe(true); + + const response = await pending; + if (options.assertConverged !== undefined) { + await options.assertConverged(); + } + return response; + } finally { + await dispose(); + } +} + +/** Convenience: expect a locator visible within the optimism window. */ +export async function expectVisibleSoon( + locator: { waitFor: (opts: { state: 'visible'; timeout: number }) => Promise }, + timeoutMs: number +): Promise { + await locator.waitFor({ state: 'visible', timeout: timeoutMs }); +} diff --git a/tests/e2e-ui/e2e/optimism.user.spec.ts b/tests/e2e-ui/e2e/optimism.user.spec.ts new file mode 100644 index 00000000..68d7c73e --- /dev/null +++ b/tests/e2e-ui/e2e/optimism.user.spec.ts @@ -0,0 +1,224 @@ +/** + * Cross-demo optimism proofs: UI must paint under a held GraphQL mutation. + * + * These are the regression gate for "it felt slow / lost optimism". Offline + * artifact checks live in ui/tests/optimism-artifacts.test.mjs. + */ +import { test, expect } from '@playwright/test'; +import { expectOptimisticPaint } from './helpers/optimism'; + +const HOLD_MS = 1_500; +const ASSERT_MS = 1_000; + +test.describe('demo optimism @optimism', () => { + test('chat: post paints before the held mutation response', async ({ page }) => { + const body = `optimism chat ${Date.now()}`; + + await page.goto('/chat'); + await expect(page.getByRole('heading', { name: /lobby/i })).toBeVisible({ + timeout: 20_000 + }); + + const msg = page.locator('.ch-msg', { hasText: body }); + const response = await expectOptimisticPaint(page, { + needle: 'chat_messages_post', + holdMs: HOLD_MS, + assertWithinMs: ASSERT_MS, + act: async () => { + await page.locator('#chat-body').fill(body); + await page.getByRole('button', { name: /send/i }).click(); + }, + assertOptimistic: async () => { + await expect(msg).toBeVisible({ timeout: 200 }); + await expect(msg.locator('.ch-body')).toHaveText(body); + }, + assertConverged: async () => { + await expect(msg).toBeVisible(); + } + }); + expect(response.ok(), `chat_messages_post HTTP ${response.status()}`).toBeTruthy(); + }); + + test('chat: full first page still paints an optimistic post', async ({ page }) => { + /** + * Full offset windows used to mark the index stale instead of inserting. + * Seed past the live page size, then prove a held send still appears. + */ + await page.goto('/chat'); + await expect(page.getByRole('heading', { name: /lobby/i })).toBeVisible({ + timeout: 20_000 + }); + + const log = page.locator('.ch-log'); + await expect(log).toHaveAttribute('data-chat-page-size', '25'); + const pageSize = Number(await log.getAttribute('data-chat-page-size')); + + const stamp = Date.now(); + // Ensure at least one full live page of rows exist before the held send. + const seedCount = Math.max(pageSize, 25); + for (let i = 0; i < seedCount; i += 1) { + const seed = `optimism seed ${stamp} #${String(i).padStart(2, '0')}`; + await page.locator('#chat-body').fill(seed); + await page.getByRole('button', { name: /send/i }).click(); + await expect(page.locator('.ch-msg', { hasText: seed })).toBeVisible({ + timeout: 15_000 + }); + } + + const body = `optimism full-page ${stamp}`; + const msg = page.locator('.ch-msg', { hasText: body }); + const response = await expectOptimisticPaint(page, { + needle: 'chat_messages_post', + holdMs: HOLD_MS, + assertWithinMs: ASSERT_MS, + act: async () => { + await page.locator('#chat-body').fill(body); + await page.getByRole('button', { name: /send/i }).click(); + }, + assertOptimistic: async () => { + await expect(msg).toBeVisible({ timeout: 200 }); + } + }); + expect(response.ok()).toBeTruthy(); + // Newest-first live window should still show the optimistic row. + await expect(msg).toBeVisible(); + }); + + test('todos: create paints in Open before the held mutation response', async ({ + page + }) => { + const title = `optimism todo ${Date.now()}`; + + await page.goto('/todos'); + await expect(page.getByRole('heading', { name: /todos/i })).toBeVisible({ + timeout: 20_000 + }); + + const openItem = page + .locator('.panel') + .filter({ has: page.getByRole('heading', { name: /^open$/i }) }) + .locator('.item', { hasText: title }); + + const response = await expectOptimisticPaint(page, { + needle: 'todos_create', + holdMs: HOLD_MS, + assertWithinMs: ASSERT_MS, + act: async () => { + await page.locator('#todo-title').fill(title); + await page.getByRole('button', { name: /^add$/i }).click(); + }, + assertOptimistic: async () => { + await expect(openItem).toBeVisible({ timeout: 200 }); + }, + assertConverged: async () => { + await expect(openItem).toBeVisible(); + expect( + await page.locator('.board button:disabled').count(), + 'create must not leave routine controls disabled' + ).toBe(0); + } + }); + expect(response.ok(), `todos_create HTTP ${response.status()}`).toBeTruthy(); + }); + + test('todos: complete paints in Done before the held mutation response', async ({ + page + }) => { + const title = `optimism complete ${Date.now()}`; + + await page.goto('/todos'); + await expect(page.getByRole('heading', { name: /todos/i })).toBeVisible({ + timeout: 20_000 + }); + + // Authoritative create first so complete targets a real open row. + await page.locator('#todo-title').fill(title); + const createDone = page.waitForResponse( + (r) => (r.request().postData() ?? '').includes('todos_create') + ); + await page.getByRole('button', { name: /^add$/i }).click(); + const openItem = page + .locator('.panel') + .filter({ has: page.getByRole('heading', { name: /^open$/i }) }) + .locator('.item', { hasText: title }); + await expect(openItem).toBeVisible({ timeout: 20_000 }); + await createDone; + + const doneItem = page + .locator('.panel') + .filter({ has: page.getByRole('heading', { name: /^done$/i }) }) + .locator('.item', { hasText: title }); + + const response = await expectOptimisticPaint(page, { + needle: 'todos_complete', + holdMs: HOLD_MS, + assertWithinMs: ASSERT_MS, + act: async () => { + await openItem.getByRole('button', { name: /^done$/i }).click(); + }, + assertOptimistic: async () => { + await expect(doneItem).toBeVisible({ timeout: 200 }); + } + }); + expect(response.ok()).toBeTruthy(); + }); + + test('blob: move paints the board before the held mutation response', async ({ + page + }) => { + /** + * Same client path as todos/chat: command input fills `.applies` + * preview (map_json etc from pure simulate_move twin). Hold the wire + * and require the player cell to move before the response fulfills. + */ + await page.goto('/blob'); + await expect(page.locator('[data-blob-hydrated="1"]')).toBeVisible({ + timeout: 15_000 + }); + await expect(page.getByTestId('blob-start-game')).toBeEnabled({ + timeout: 10_000 + }); + + const [startResp] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/graphql') && + (r.request().postData() ?? '').includes('blob_games_start'), + { timeout: 20_000 } + ), + page.getByTestId('blob-start-game').click() + ]); + expect(startResp.ok()).toBeTruthy(); + const board = page.locator('.blob-board'); + await expect(board).toBeVisible({ timeout: 15_000 }); + // Generated levels start at r0 c0. + await expect(board.locator('.tile-player')).toHaveAttribute( + 'aria-label', + 'r0 c0' + ); + + const response = await expectOptimisticPaint(page, { + needle: 'blob_games_move', + holdMs: HOLD_MS, + assertWithinMs: ASSERT_MS, + act: async () => { + await page.keyboard.press('ArrowRight'); + }, + assertOptimistic: async () => { + await expect(board.locator('.tile-player')).toHaveAttribute( + 'aria-label', + 'r0 c1', + { timeout: 200 } + ); + }, + assertConverged: async () => { + await expect(board.locator('.tile-player')).toHaveAttribute( + 'aria-label', + 'r0 c1' + ); + await expect(page.locator('.blob-empty')).toHaveCount(0); + } + }); + expect(response.ok(), `blob_games_move HTTP ${response.status()}`).toBeTruthy(); + }); +}); diff --git a/tests/e2e-ui/e2e/todos.user.spec.ts b/tests/e2e-ui/e2e/todos.user.spec.ts index e692150b..7fb67d24 100644 --- a/tests/e2e-ui/e2e/todos.user.spec.ts +++ b/tests/e2e-ui/e2e/todos.user.spec.ts @@ -285,11 +285,10 @@ test.describe('todos (alice)', () => { .locator('.panel') .filter({ has: page.getByRole('heading', { name: /^open$/i }) }) .locator('.item', { hasText: title }); - // Create upserts the record optimistically but list membership is - // authoritative (index write); the delayed route proves later complete/ - // reopen transitions paint from the optimistic layer before the wire. + // Create must paint list membership optimistically under the delayed route + // (first-page offset insert + truncate). Assert before the wire returns. + await expect(openItem).toBeVisible({ timeout: 400 }); await createResponse; - await expect(openItem).toBeVisible({ timeout: 5_000 }); expect( await page.locator('.board button:disabled').count(), 'routine command concurrency guards must not flash Todo row controls disabled' diff --git a/tests/e2e-ui/e2e/unauth.anon.spec.ts b/tests/e2e-ui/e2e/unauth.anon.spec.ts index f04ff733..92c93cdb 100644 --- a/tests/e2e-ui/e2e/unauth.anon.spec.ts +++ b/tests/e2e-ui/e2e/unauth.anon.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; test.describe('unauthenticated access', () => { test('protected routes redirect toward login / OIDC', async ({ page }) => { - for (const path of ['/todos', '/chat', '/blob', '/admin', '/session']) { + for (const path of ['/todos', '/blob', '/admin', '/session']) { await page.goto(path, { waitUntil: 'domcontentloaded' }); // hooks → /login?callbackUrl=… → may immediately start OIDC await page.waitForURL( @@ -17,6 +17,14 @@ test.describe('unauthenticated access', () => { } }); + test('public chat is reachable without a session', async ({ page }) => { + await page.goto('/chat'); + await expect(page).toHaveURL(/\/chat(?:[/?#]|$)/); + await expect(page.getByRole('heading', { name: 'Lobby' })).toBeVisible({ + timeout: 20_000 + }); + }); + test('home page is reachable without a session', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible({ diff --git a/tests/e2e-ui/ui/src/hooks.server.ts b/tests/e2e-ui/ui/src/hooks.server.ts index c528165d..99a3a133 100644 --- a/tests/e2e-ui/ui/src/hooks.server.ts +++ b/tests/e2e-ui/ui/src/hooks.server.ts @@ -1,30 +1,36 @@ -import { redirect, type Handle, type RequestEvent } from '@sveltejs/kit'; +import type { Handle, RequestEvent } from '@sveltejs/kit'; import { handle as authHandle } from './auth'; import { sequence } from '@sveltejs/kit/hooks'; +import { requireAuth } from '$lib/server/require-auth'; -async function authorizationHandle({ event, resolve }: { event: RequestEvent; resolve: (event: RequestEvent) => Response | Promise; }) { - // Protect admin (website) + fixture app routes - const path = event.url.pathname; - // /chat is intentionally public (anonymous GraphQL read on e2e-ui-public). - const protectedPrefix = - path.startsWith('/admin') || - path === '/todos' || - path.startsWith('/todos/') || - path === '/blob' || - path.startsWith('/blob/') || - path === '/session' || - path.startsWith('/session/'); +/** Paths that require a session (chat is public; home is public). */ +function isProtectedPath(path: string): boolean { + return ( + path.startsWith('/admin') || + path === '/todos' || + path.startsWith('/todos/') || + path === '/blob' || + path.startsWith('/blob/') || + path === '/session' || + path.startsWith('/session/') + ); +} - if (protectedPrefix) { - const session = await event.locals.auth(); - if (!session?.user) { - const callbackUrl = encodeURIComponent(event.url.pathname + event.url.search); - // Custom Login V2 pages at /login (not Zitadel-hosted UI) - throw redirect(303, `/login?callbackUrl=${callbackUrl}`); - } - } +async function authorizationHandle({ + event, + resolve +}: { + event: RequestEvent; + resolve: (event: RequestEvent) => Response | Promise; +}) { + const path = event.url.pathname; + // Full document loads always hit this. Client nav also needs +page.server + // requireAuth (root layout is cached and may skip a round-trip). + if (isProtectedPath(path)) { + await requireAuth(event); + } - return resolve(event); + return resolve(event); } export const handle: Handle = sequence(authHandle, authorizationHandle); diff --git a/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts new file mode 100644 index 00000000..a590c2b2 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/blob/simulate-move.ts @@ -0,0 +1,146 @@ +/** + * Pure move rules — byte-identical twin of `blob_domain::simulate_move`. + * + * Used only to fill command **input** fields for the same `.applies` / + * optimistic-layer path as chat/todos (not a page-local board overlay). + * The server still recomputes authoritatively from `game_id` + `direction`. + */ + +export const TILE = { + hole: 0, + unvisited: 1, + visited: 2, + deadBySuicide: 3, + deadByHole: 4, + player: 9 +} as const; + +export type Direction = 'up' | 'down' | 'left' | 'right'; + +export type MovePreview = { + readonly map: number[][]; + readonly score: number; + readonly player_dead: boolean; + readonly level_complete: boolean; + readonly status: string; + readonly map_json: string; +}; + +export class SimulateMoveError extends Error { + constructor(message: string) { + super(message); + this.name = 'SimulateMoveError'; + } +} + +function statusOf(playerDead: boolean, levelComplete: boolean): string { + if (playerDead) return 'dead'; + if (levelComplete) return 'level_complete'; + return 'active'; +} + +function playerPos(map: number[][]): { r: number; c: number } { + for (let r = 0; r < map.length; r += 1) { + const row = map[r]!; + for (let c = 0; c < row.length; c += 1) { + if (row[c] === TILE.player) return { r, c }; + } + } + throw new SimulateMoveError('no active level'); +} + +/** + * Apply one direction to a map + score. Mirrors `blob_domain::simulate_move`. + */ +export function simulateMove( + map: number[][], + score: number, + direction: Direction +): MovePreview { + if (map.length === 0 || (map[0]?.length ?? 0) === 0) { + throw new SimulateMoveError('no active level'); + } + const { r, c } = playerPos(map); + let nr: number; + let nc: number; + switch (direction) { + case 'up': + if (r === 0) throw new SimulateMoveError('row already 0'); + nr = r - 1; + nc = c; + break; + case 'down': + if (r + 1 >= map.length) throw new SimulateMoveError('already at bottom edge'); + nr = r + 1; + nc = c; + break; + case 'left': + if (c === 0) throw new SimulateMoveError('column already 0'); + nr = r; + nc = c - 1; + break; + case 'right': + if (c + 1 >= (map[r]?.length ?? 0)) { + throw new SimulateMoveError('already at right edge'); + } + nr = r; + nc = c + 1; + break; + default: { + const _exhaustive: never = direction; + throw new SimulateMoveError(`invalid direction: ${_exhaustive}`); + } + } + + const nextMap = map.map((row) => [...row]); + let nextScore = score; + let playerDead = false; + let levelComplete = false; + + nextMap[r]![c] = TILE.visited; + const landing = nextMap[nr]![nc]!; + if (landing === TILE.hole) { + nextMap[nr]![nc] = TILE.deadByHole; + } else if (landing === TILE.visited) { + nextMap[nr]![nc] = TILE.deadBySuicide; + } else if (landing === TILE.unvisited || landing === TILE.player) { + nextScore += 1; + nextMap[nr]![nc] = TILE.player; + } else { + nextMap[nr]![nc] = TILE.deadBySuicide; + } + + for (const row of nextMap) { + if (row.includes(TILE.deadByHole) || row.includes(TILE.deadBySuicide)) { + playerDead = true; + levelComplete = false; + break; + } + } + if (!playerDead) { + levelComplete = !nextMap.some((row) => row.includes(TILE.unvisited)); + } + + return Object.freeze({ + map: nextMap, + score: nextScore, + player_dead: playerDead, + level_complete: levelComplete, + status: statusOf(playerDead, levelComplete), + map_json: JSON.stringify(nextMap) + }); +} + +/** Parse a `map_json` board; throws if shape is not `number[][]`. */ +export function parseBoard(mapJson: string): number[][] { + const value = JSON.parse(mapJson || '[]') as unknown; + if ( + !Array.isArray(value) || + !value.every( + (row) => Array.isArray(row) && row.every((cell) => typeof cell === 'number') + ) + ) { + throw new SimulateMoveError('invalid map_json'); + } + return value as number[][]; +} diff --git a/tests/e2e-ui/ui/src/lib/components/shared/Footer.svelte b/tests/e2e-ui/ui/src/lib/components/shared/Footer.svelte index e7c74647..28b8eb36 100644 --- a/tests/e2e-ui/ui/src/lib/components/shared/Footer.svelte +++ b/tests/e2e-ui/ui/src/lib/components/shared/Footer.svelte @@ -10,8 +10,9 @@

Demos

diff --git a/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte b/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte index 388df6ad..c4323ed2 100644 --- a/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte +++ b/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte @@ -77,19 +77,20 @@ class:active={isActive('/chat')} onclick={() => (isMenuOpen = false)}>Chat + + (isMenuOpen = false)}>Todos + (isMenuOpen = false)}>Blob {#if isAuthenticated} - (isMenuOpen = false)}>Todos - (isMenuOpen = false)}>Blob = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -53,6 +59,22 @@ export const Command_blob_games_move: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -311,10 +561,187 @@ export const Command_blob_games_start: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -473,10 +906,187 @@ export const Command_blob_games_start_level: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_chat_messages_post($commandId: ID!, $input: ChatPostInput!) { chat_messages_post(commandId: $commandId, input: $input) { author_id body created_at message_id room_id } }", "input": { "definition": { @@ -780,7 +1396,7 @@ export const Command_chat_messages_post: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_archive($commandId: ID!, $input: TodoArchiveInput!) { todos_archive(commandId: $commandId, input: $input) { status todo_id } }", "input": { "definition": { @@ -1035,7 +1651,7 @@ export const Command_todos_archive: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_complete($commandId: ID!, $input: TodoCompleteInput!) { todos_complete(commandId: $commandId, input: $input) { status todo_id } }", "input": { "definition": { @@ -1284,7 +1900,7 @@ export const Command_todos_complete: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_create($commandId: ID!, $input: TodoCreateInput!) { todos_create(commandId: $commandId, input: $input) { owner_id status title todo_id } }", "input": { "definition": { @@ -1570,7 +2186,7 @@ export const Command_todos_create: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_force_archive($commandId: ID!, $input: TodoForceArchiveInput!) { todos_force_archive(commandId: $commandId, input: $input) { archived_by owner_id status todo_id } }", "input": { "definition": { @@ -1843,7 +2459,7 @@ export const Command_todos_force_archive: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_purge($commandId: ID!, $input: TodoPurgeInput!) { todos_purge(commandId: $commandId, input: $input) { purged todo_id } }", "input": { "definition": { @@ -2045,7 +2661,7 @@ export const Command_todos_purge: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_rename($commandId: ID!, $input: TodoRenameInput!) { todos_rename(commandId: $commandId, input: $input) { status title todo_id } }", "input": { "definition": { @@ -2311,7 +2927,7 @@ export const Command_todos_rename: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_reopen($commandId: ID!, $input: TodoReopenInput!) { todos_reopen(commandId: $commandId, input: $input) { status todo_id } }", "input": { "definition": { @@ -2560,7 +3176,7 @@ export const Command_todos_reopen: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -53,6 +59,22 @@ export const Command_blob_games_move: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -312,10 +562,187 @@ export const Command_blob_games_start: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -475,10 +908,187 @@ export const Command_blob_games_start_level: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_chat_messages_post($commandId: ID!, $input: ChatPostInput!) { chat_messages_post(commandId: $commandId, input: $input) { author_id body created_at message_id room_id } }", "input": { "definition": { @@ -783,7 +1399,7 @@ export const Command_chat_messages_post: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_archive($commandId: ID!, $input: TodoArchiveInput!) { todos_archive(commandId: $commandId, input: $input) { status todo_id } }", "input": { "definition": { @@ -1039,7 +1655,7 @@ export const Command_todos_archive: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_complete($commandId: ID!, $input: TodoCompleteInput!) { todos_complete(commandId: $commandId, input: $input) { status todo_id } }", "input": { "definition": { @@ -1289,7 +1905,7 @@ export const Command_todos_complete: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_create($commandId: ID!, $input: TodoCreateInput!) { todos_create(commandId: $commandId, input: $input) { owner_id status title todo_id } }", "input": { "definition": { @@ -1576,7 +2192,7 @@ export const Command_todos_create: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_purge($commandId: ID!, $input: TodoPurgeInput!) { todos_purge(commandId: $commandId, input: $input) { purged todo_id } }", "input": { "definition": { @@ -1785,7 +2401,7 @@ export const Command_todos_purge: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_rename($commandId: ID!, $input: TodoRenameInput!) { todos_rename(commandId: $commandId, input: $input) { status title todo_id } }", "input": { "definition": { @@ -2052,7 +2668,7 @@ export const Command_todos_rename: ReplicaCommandArtifact = { - "consistency": "causal", + "consistency": "eventual", "document": "mutation Client_todos_reopen($commandId: ID!, $input: TodoReopenInput!) { todos_reopen(commandId: $commandId, input: $input) { status todo_id } }", "input": { "definition": { @@ -2302,7 +2918,7 @@ export const Command_todos_reopen: ReplicaCommandArtifact Promise<{ user?: unknown } | null>; +}; + +export async function requireAuth( + event: { locals: AuthLocals; url: URL }, + options?: { fallbackPath?: string } +): Promise>>> { + const session = await event.locals.auth(); + if (session?.user) { + return session; + } + + const path = event.url.pathname + event.url.search; + const callbackUrl = encodeURIComponent(path || options?.fallbackPath || '/'); + redirect(303, `/login?callbackUrl=${callbackUrl}`); +} diff --git a/tests/e2e-ui/ui/src/lib/styles/home.css b/tests/e2e-ui/ui/src/lib/styles/home.css index 18462dcb..41683a2c 100644 --- a/tests/e2e-ui/ui/src/lib/styles/home.css +++ b/tests/e2e-ui/ui/src/lib/styles/home.css @@ -1278,6 +1278,226 @@ font-style: normal; } +/* Unidirectional flow — circle with dotted directional connectors */ +.dist-flow-diagram { + margin: 0.5rem 0 0; + padding: 0; + border: 0; + background: transparent; + color: var(--wf-ink); + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.dist-flow-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.48rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--wf-line-strong, #cdcabe); + background: #f6f5f2; + font-family: var(--wf-sans); + font-size: 0.86rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--wf-ink); + white-space: nowrap; + box-shadow: + 0 0 0 3px #f6f5f2, + 0 1px 2px rgba(28, 28, 26, 0.06); +} + +.dist-flow-chip-client { + border-color: rgba(61, 90, 128, 0.4); + background: #e8eef4; + box-shadow: + 0 0 0 3px #f6f5f2, + 0 1px 2px rgba(28, 28, 26, 0.06); +} + +.dist-flow-chip-hub { + border-color: rgba(61, 90, 128, 0.55); + background: #dce6f0; + box-shadow: + 0 0 0 3px #f6f5f2, + 0 1px 2px rgba(28, 28, 26, 0.06); +} + +.dist-flow-chip-rm { + border-color: rgba(70, 100, 80, 0.45); + background: #e4ebe4; + box-shadow: + 0 0 0 3px #f6f5f2, + 0 1px 2px rgba(28, 28, 26, 0.06); +} + +/* viewBox 360×420 */ +.dist-flow-circle { + --flow-size: min(36rem, 100%); + position: relative; + width: var(--flow-size); + aspect-ratio: 360 / 420; + margin: 0 auto; +} + +.dist-flow-circle-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + z-index: 0; +} + +.dist-flow-connector { + fill: none; + stroke: #6b6a64; + stroke-width: 1.5; + stroke-dasharray: 2.75 4; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.72; +} + +.dist-flow-arrowhead { + fill: #5c5b56; +} + +.dist-flow-circle-core { + position: absolute; + left: 50%; + top: calc(220 / 420 * 100%); + width: 4.75rem; + height: 4.75rem; + margin: -2.375rem 0 0 -2.375rem; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px dashed rgba(61, 90, 128, 0.4); + background: #f6f5f2; + z-index: 0; +} + +.dist-flow-core-label { + font-family: var(--wf-mono); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--wf-ink-muted, #6b6a64); +} + +.dist-flow-orbit { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + transform: translate(-50%, -50%); + z-index: 2; +} + +/* Label beside the client→gateway stem */ +.dist-flow-entry-meta { + position: absolute; + left: 50%; + top: calc(62 / 420 * 100%); + transform: translate(0.85rem, -50%); + font-family: var(--wf-mono); + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--wf-ink-muted, #6b6a64); + white-space: nowrap; + z-index: 1; + pointer-events: none; +} + +/* + Chips on the ring — center (180, 220) r=118 in 360×420 + p(θ) = (180 + 118·sinθ, 220 − 118·cosθ) +*/ +.dist-flow-orbit-client { + left: 50%; + top: calc(28 / 420 * 100%); +} + +/* Gateway 0° (180, 102) */ +.dist-flow-orbit-0 { + left: 50%; + top: calc(102 / 420 * 100%); +} + +/* Commands 60° (282.19, 161) */ +.dist-flow-orbit-1 { + left: calc(282.19 / 360 * 100%); + top: calc(161 / 420 * 100%); +} + +/* Aggregate 120° (282.19, 279) */ +.dist-flow-orbit-2 { + left: calc(282.19 / 360 * 100%); + top: calc(279 / 420 * 100%); +} + +/* Domain event 180° (180, 338) */ +.dist-flow-orbit-3 { + left: 50%; + top: calc(338 / 420 * 100%); +} + +/* Projection 240° (77.81, 279) */ +.dist-flow-orbit-4 { + left: calc(77.81 / 360 * 100%); + top: calc(279 / 420 * 100%); +} + +/* Read model 300° (77.81, 161) */ +.dist-flow-orbit-5 { + left: calc(77.81 / 360 * 100%); + top: calc(161 / 420 * 100%); +} + +/* Flow step: copy + diagram full width */ +.wf-story-step.dist-flow-step { + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +@media (min-width: 960px) { + .wf-story-step.dist-flow-step { + grid-template-columns: none; + display: flex; + flex-direction: column; + gap: 2rem; + } +} + +.dist-flow-step .wf-story-copy { + max-width: 48rem; +} + +.dist-flow-step .dist-flow-diagram { + width: 100%; + min-width: 0; +} + +@media (min-width: 640px) { + .dist-flow-chip { + font-size: 0.9rem; + padding: 0.52rem 0.95rem; + } + + .dist-flow-circle { + --flow-size: min(38rem, 92vw); + } +} + #sota-together .wf-section-head-wide p + p { margin-top: 1rem; } diff --git a/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts b/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts index 16cf14b0..0cb4ad9a 100644 --- a/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts +++ b/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts @@ -2,13 +2,23 @@ import type { DemoWalkthrough } from './types'; /** * Tab order is browser-first teaching order for every demo: - * 1. Query / live subscription (+ read RBAC) - * 2. Commands (optimistic cache vs atomic Projected) (+ write RBAC) + * 1. Query / live subscription (+ ReadModel shape + read RBAC) + * 2. Commands (optimistic cache vs Atomic) (+ write RBAC) * 3. Command handlers (repo → aggregate → commit) * 4. Domain model (plain Rust + macros) * 5. Domain events + projections * * Samples should be real, pasteable shapes from the fixture — not comment-only stubs. + * Query tabs should include the read model definition (#[derive(ReadModel)] struct), not only + * permissions snippets or GraphQL selection. + * + * Consistency teaching (same mutation IR; apply site differs): + * - Eventual (placement + command): event handler applies IR async → client + * `.applies` previews until obligations complete (no response row). + * - Atomic (Direct placement + Atomic command): command handler applies IR + * same-tx → wait and return the row; confirm before await settles. + * Same `.applies` path when input carries known fields (blob move fills + * board fields from the pure simulate_move twin of the domain). */ export const todosWalkthrough: DemoWalkthrough = { @@ -17,12 +27,12 @@ export const todosWalkthrough: DemoWalkthrough = { title: 'Todos', kicker: 'Browser → command → domain → projection', summary: - 'Start on the page: one @load query feeds the replica. Commands update a client-side cache optimistically; the server commits Causal and projectors catch up.', + 'Start on the page: one @load query feeds the replica. Commands are Eventual: `.applies` paints a safe optimistic preview; the event handler applies the same mutation IR later, so the client cannot wait for a response row — only obligations.', tabs: [ { id: 'query', label: '1 · Query', - lede: 'The browser reads through a co-located GraphQL document. @load seeds SSR; the generated operation binds the same document to the replica. Row filters are model RBAC — not ad-hoc WHERE in the UI.', + lede: 'The browser reads through a co-located GraphQL document over a declared read model. @load seeds SSR; the generated operation binds the same document to the replica. Shape and row filters live on the model — not ad-hoc WHERE in the UI.', principle: 'One replica story for user data.', samples: [ { @@ -35,6 +45,23 @@ export const todosWalkthrough: DemoWalkthrough = { title status } +}` + }, + { + file: 'readmodels/models/todos.rs · Todos', + caption: 'Query-oriented row shape. Plural name infers table `todos`; belongs_to joins the directory.', + code: `#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ReadModel)] +#[readmodel(primary_key = ["todo_id"])] +pub struct Todos { + #[readmodel(id)] + pub todo_id: String, + pub owner_id: String, + pub title: String, + /// open | completed | archived + pub status: String, + pub assignee_id: Option, + #[readmodel(belongs_to = "AuthUsers", foreign_key = "owner_id")] + pub owner: Option, }` }, { @@ -66,7 +93,7 @@ const todos = $derived($query.complete ? $query.data.todos : []);` { id: 'commands', label: '2 · Commands', - lede: 'Writes go through generated commands. Todos are Causal: the client applies a safe optimistic preview into the replica cache that feeds the UI, then confirms when the projection obligation completes.', + lede: 'Writes go through generated commands. Todos are Eventual: the client applies a safe optimistic preview into the replica cache that feeds the UI, then confirms when the projection obligation completes.', principle: 'Let the Service declare how the UI catches up.', samples: [ { @@ -80,7 +107,7 @@ await commands.todo.complete({ todo_id });` { file: 'service.rs · todos_create (roles + preview)', caption: 'Write RBAC on the inventory; owner is a trusted claim, not input.', - code: `typed_command::>( + code: `typed_command::>( todo_create::COMMAND, ) .field_name("todos_create") @@ -103,7 +130,7 @@ await commands.todo.complete({ todo_id });` { file: 'service.rs · todos_force_archive roles', caption: 'Elevated mutation: admin only; not on the user client tree.', - code: `typed_command::>( + code: `typed_command::>( todo_force_archive::COMMAND, ) .field_name("todos_force_archive") @@ -115,7 +142,7 @@ await commands.todo.complete({ todo_id });` { id: 'handlers', label: '3 · Handlers', - lede: 'Command handlers use the repository pattern: get or create the aggregate, call a domain method, commit. Todos choose eventual consistency (Causal + projector).', + lede: 'Command handlers use the repository pattern: get or create the aggregate, call a domain method, commit. Todos choose eventual consistency (Eventual + projector).', principle: 'Commands change the world; tables are for reading.', samples: [ { @@ -124,14 +151,14 @@ await commands.todo.complete({ todo_id });` code: `pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoCreateInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo.create(); todo.create(&input.todo_id, &owner, &input.title) .map_err(rejected)?; let state = TodoState::from(&*todo); - repo.publish_events().commit(todo)?.causal(TodoCreatePayload { + repo.publish_events().commit(todo)?.eventual(TodoCreatePayload { todo_id: state.todo_id, owner_id: state.owner_id, title: state.title, @@ -147,7 +174,7 @@ await commands.todo.complete({ todo_id });` .ok_or_else(|| HandlerError::NotFound(input.todo_id.clone()))?; todo.complete(&owner).map_err(rejected)?; let state = TodoState::from(&*todo); -repo.publish_events().commit(todo)?.causal(TodoStatusPayload { +repo.publish_events().commit(todo)?.eventual(TodoStatusPayload { todo_id: state.todo_id, status: state.status, })` @@ -157,11 +184,35 @@ repo.publish_events().commit(todo)?.causal(TodoStatusPayload { { id: 'domain', label: '4 · Domain', - lede: 'The model is a plain Rust struct with Distributed macros. Public methods enforce rules; private #[event] helpers record history. Unit-testable with no HTTP or SQL.', + lede: 'The write model is a plain Rust aggregate — fields are the consistency boundary. Public methods enforce rules; private #[event] helpers record history. Unit-testable with no HTTP or SQL.', principle: 'Start with the domain, not the database.', samples: [ + { + file: 'todo-domain · Todo', + caption: 'Aggregate shape — the consistency boundary.', + code: `#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Todo { + pub entity: Entity, + pub todo_id: String, + pub owner_id: String, + pub title: String, + pub status: TodoStatus, // open | completed | archived + pub assignee_id: Option, + purged: bool, + snapshot_generation: u64, +} + +#[sourced( + entity, + events = "TodoEvent", + aggregate_type = "todo", + domain_state = TodoState, +)] +impl Todo { /* create, complete, rename, archive, … */ }` + }, { file: 'todo-domain · Todo::complete', + caption: 'One command path: validate → record domain event.', code: `pub fn complete(&mut self, owner_id: &str) -> Result<(), TodoError> { self.ensure_owner(owner_id)?; self.require_mutable()?; @@ -183,12 +234,12 @@ fn record_completed(&mut self) { { id: 'events', label: '5 · Events', - lede: 'Domain methods emit events. Projections are event handlers that upsert (or delete) read-model rows. The UI never dual-writes the todos table.', + lede: 'Domain methods emit events. Projections map those events onto syntax-only GraphQL mutations that become MutationProgram IR — upsert or delete read-model rows. The UI never dual-writes the todos table.', principle: 'Know which side of the fence you are on.', samples: [ { file: 'projections/todos.rs', - caption: 'Domain events → projection (apply mutation).', + caption: 'Domain events → projection arms that name mutation programs.', code: `projection! { pub const TODOS: ProjectionDescriptor = { name: "project_todos", @@ -214,6 +265,22 @@ fn record_completed(&mut self) { input: { todo_id: aggregate_id }, }, }; +}` + }, + { + file: 'projections/mutations/save_todo.mutation.graphql', + caption: 'Not a public schema field — compiles to MutationProgram IR for the projector.', + code: `# Syntax-only read-model mutation → MutationProgram IR. +mutation SaveTodo { + upsert_Todos(object: $input.todo) +}` + }, + { + file: 'projections/mutations/delete_todo.mutation.graphql', + caption: 'Purge path: delete by primary key from the event aggregate id.', + code: `# Syntax-only read-model mutation → MutationProgram IR. +mutation DeleteTodo { + delete_Todos_by_pk(todo_id: $input.todo_id) }` }, { @@ -234,14 +301,14 @@ export const chatWalkthrough: DemoWalkthrough = { id: 'chat', href: '/chat', title: 'Lobby chat', - kicker: 'Browser → live query → Causal post', + kicker: 'Browser → live query → Eventual post', summary: - 'Start with the document: @load seeds HTML and @live continues the same query over WebSocket. Posts are optimistic Causal commands into the shared replica. Guests read via e2e-ui-public.', + 'Start with the document: @load seeds HTML and @live continues the same query over WebSocket. Posts are optimistic Eventual commands into the shared replica. Guests read via e2e-ui-public.', tabs: [ { id: 'query', label: '1 · Query / live', - lede: 'One GraphQL operation is both the SSR seed and the live subscription. Read RBAC allows user, admin, and anonymous (guests open e2e-ui-public).', + lede: 'One GraphQL operation is both the SSR seed and the live subscription over a declared ChatMessages read model. Read RBAC allows user, admin, and anonymous (guests open e2e-ui-public).', principle: 'Register once, ship everywhere.', samples: [ { @@ -261,6 +328,22 @@ export const chatWalkthrough: DemoWalkthrough = { created_at author { user_id display_name email } } +}` + }, + { + file: 'readmodels/models/chat_messages.rs · ChatMessages', + caption: 'Insert-shaped row; author is a belongs_to join onto AuthUsers.', + code: `#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ReadModel)] +#[readmodel(primary_key = ["message_id"])] +pub struct ChatMessages { + #[readmodel(id)] + pub message_id: String, + pub room_id: String, + pub author_id: String, + pub body: String, + pub created_at: String, + #[readmodel(belongs_to = "AuthUsers", foreign_key = "author_id")] + pub author: Option, }` }, { @@ -290,7 +373,7 @@ const livePage = $derived.by(() => { { id: 'commands', label: '2 · Commands', - lede: 'Post is a generated command for signed-in surfaces only. The client replica cache applies a modeled optimistic message; Causal confirmation follows the projector.', + lede: 'Post is a generated command for signed-in surfaces only. The client replica cache applies a modeled optimistic message; Eventual confirmation follows the projector.', principle: 'One replica story for user data.', samples: [ { @@ -310,7 +393,7 @@ if (receipt.projected !== undefined) { { file: 'service.rs · chat_messages_post roles', caption: 'Write RBAC: user + admin only. Public client has zero commands.', - code: `typed_command::>( + code: `typed_command::>( chat_post::COMMAND, ) .field_name("chat_messages_post") @@ -328,7 +411,7 @@ export type GeneratedCommands = Readonly>;` { id: 'handlers', label: '3 · Handlers', - lede: 'Handler creates the chat aggregate through the repository, applies the domain post, commits Causal (eventual path). Author is always the session principal.', + lede: 'Handler creates the chat aggregate through the repository, applies the domain post, commits Eventual (projector path). Author is always the session principal.', principle: 'Trust the signed-in person, not the request body.', samples: [ { @@ -336,7 +419,7 @@ export type GeneratedCommands = Readonly>;` code: `pub async fn handle( ctx: &CausalCommandContext<'_, ChatMessage>, input: ChatPostInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let author = ctx.user_id()?.to_string(); let created_at = canonical_near_unix_millis(&input.created_at)?; let repo = ctx.repo(); @@ -359,7 +442,7 @@ export type GeneratedCommands = Readonly>;` .map_err(rejected)?; let state = ChatMessageState::from(&*msg); - repo.publish_events().commit(msg)?.causal(ChatPostPayload { + repo.publish_events().commit(msg)?.eventual(ChatPostPayload { message_id: state.message_id, room_id: state.room_id, author_id: state.author_id, @@ -373,11 +456,35 @@ export type GeneratedCommands = Readonly>;` { id: 'domain', label: '4 · Domain', - lede: 'Chat domain is plain Rust: posting rules and event recording, testable without GraphQL.', + lede: 'Chat domain is a plain Rust aggregate — one message is one consistency boundary. Public methods enforce rules; private #[event] helpers record history. No GraphQL in the model.', principle: 'Start with the domain, not the database.', samples: [ + { + file: 'chat-domain · ChatMessage', + caption: 'Aggregate shape — the consistency boundary.', + code: `#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChatMessage { + pub entity: Entity, + pub message_id: String, + pub room_id: String, + pub author_id: String, + pub body: String, + /// RFC3339 timestamp (string for portable projections / SQLite text). + pub created_at: String, + snapshot_delivery_generation: u64, +} + +#[sourced( + entity, + events = "ChatMessageEvent", + aggregate_type = "chat_message", + domain_state = ChatMessageState, +)] +impl ChatMessage { /* post, … */ }` + }, { file: 'chat-domain · ChatMessage::post', + caption: 'Validate, then record the domain event that becomes history.', code: `pub fn post( &mut self, message_id: impl Into, @@ -426,7 +533,7 @@ fn record_posted( { id: 'events', label: '5 · Events', - lede: 'Domain events drive the chat_messages projection. ChangeHub wakes @live subscribers when rows land.', + lede: 'Domain events drive the chat_messages projection via a named GraphQL mutation program. ChangeHub wakes @live subscribers when rows land.', principle: 'Commands change the world; tables are for reading.', samples: [ { @@ -443,6 +550,14 @@ fn record_posted( input: { message: body }, }, }; +}` + }, + { + file: 'projections/mutations/save_chat_message.mutation.graphql', + caption: 'Syntax-only upsert — not a browser-facing mutation.', + code: `# Syntax-only read-model mutation → MutationProgram IR. +mutation SaveChatMessage { + upsert_ChatMessages(object: $input.message) }` } ] @@ -454,14 +569,14 @@ export const blobWalkthrough: DemoWalkthrough = { id: 'blob', href: '/blob', title: 'Blob game', - kicker: 'Browser → Projected (atomic) · no lag', + kicker: 'Browser → Atomic · no lag', summary: - 'Still start in the browser: one @load query owns the board. Moves return Projected — the replica applies the authoritative board from the mutation payload before the call resolves (atomic, not eventual optimism).', + 'Still start in the browser: one @load query owns the board. Moves are Atomic — same save_blob_game mutation IR as eventual, applied in the command handler so the response row can update the replica before await resolves.', tabs: [ { id: 'query', label: '1 · Query', - lede: 'One operation lists games (and map JSON). URL selects which game is active; the board derives from the replica. Row RBAC scopes lists to the owner (unless admin).', + lede: 'One operation lists games (and map JSON) from the BlobGames read model. URL selects which game is active; the board derives from the replica. Row RBAC scopes lists to the owner (unless admin).', principle: 'One replica story for user data.', samples: [ { @@ -473,6 +588,26 @@ export const blobWalkthrough: DemoWalkthrough = { map_json owner { user_id display_name } } +}` + }, + { + file: 'readmodels/models/blob_games.rs · BlobGames', + caption: 'Query-oriented board row; map_json is the serialized level; owner joins AuthUsers.', + code: `#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ReadModel)] +#[readmodel(primary_key = ["game_id"])] +pub struct BlobGames { + #[readmodel(id)] + pub game_id: String, + pub owner_id: String, + pub score: i64, + pub player_dead: bool, + pub current_level: i64, + pub current_level_completed: bool, + pub map_json: String, + /// active | dead | level_complete + pub status: String, + #[readmodel(belongs_to = "AuthUsers", foreign_key = "owner_id")] + pub owner: Option, }` }, { @@ -503,32 +638,53 @@ const games = $derived( { id: 'commands', label: '2 · Commands', - lede: 'Moves are Projected commands. Unlike todos, the UI does not guess the next board — it applies atomic results from the server into the client cache that feeds the UI.', + lede: 'Moves are Atomic commands but use the same client optimism path as Eventual: fill command input, `.applies` paints the replica, then the network seals. Server-side the mutation IR runs in the command handler (not an event projector) so we can also wait for the authoritative row and return it.', principle: 'Let the Service declare how the UI catches up.', samples: [ { file: 'routes/blob/[[gameId]]/+page.svelte', - caption: 'consistency: "projected" — authoritative delta before await returns.', - code: `const receipt = await commands.blob.move({ + caption: 'Pure simulate_move fills input (like chat body); `.applies` paints before the wire.', + code: `const preview = simulateMove(board, score, direction); +await commands.blob.move({ game_id, - direction: 'up', -});` + direction, + map_json: preview.map_json, + score: preview.score, + player_dead: preview.player_dead, + current_level, + current_level_completed: preview.level_complete, + status: preview.status, +}); +// optimistic layer already has the board; atomic row seals on response` }, { - file: 'service.rs · blob.move roles', - caption: 'Write RBAC: user + admin on the portable surface.', - code: `typed_command::>( + file: 'service.rs · blob.move', + caption: 'Same client `.applies` path; apply site is the handler (Atomic).', + code: `typed_command::>( blob_move::COMMAND, ) .field_name("blob_games_move") -.roles(app_roles) // ["user", "admin"]` +.roles(app_roles) +.emits(events![BlobMovedDomainEvent]) +.applies(state_preview! { + BlobMovedDomainEvent => BlobGameState { + game_id: input.game_id, + owner_id: trusted("x-user-id", "string"), + score: input.score, + player_dead: input.player_dead, + current_level: input.current_level, + current_level_completed: input.current_level_completed, + map_json: input.map_json, + status: input.status, + } +})` } ] }, { id: 'handlers', label: '3 · Handlers', - lede: 'Same repo pattern: get aggregate, domain move, commit — but the row is staged in-handler and commit returns Projected so aggregate, ledger, and query row share one transaction.', + lede: 'Get aggregate, domain move, stage the mutation-derived row, commit Atomic — one transaction for aggregate, ledger, and query row. Because we are still in the command handler we can wait and return that row; an event handler cannot.', principle: 'Commands change the world; tables are for reading.', samples: [ { @@ -536,7 +692,7 @@ const games = $derived( code: `pub async fn handle( ctx: &CausalCommandContext<'_, BlobGame>, input: BlobMoveInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let dir = Direction::parse(&input.direction).ok_or_else(|| { HandlerError::Rejected(format!( @@ -558,7 +714,7 @@ const games = $derived( repo.readmodel(row) .publish_events() .commit(game)? - .projected() + .atomic() }` } ] @@ -566,45 +722,47 @@ const games = $derived( { id: 'domain', label: '4 · Domain', - lede: 'Movement and scoring live on a plain Rust aggregate with #[event] history.', + lede: 'The game is a plain Rust aggregate — score, map, and level live on the write model. Public methods enforce rules; private #[event] helpers record history.', principle: 'Start with the domain, not the database.', samples: [ { - file: 'blob-domain · BlobGame::move_dir', - code: `pub fn move_dir( - &mut self, - owner_id: &str, - direction: Direction, -) -> Result<(), BlobError> { - self.ensure_owner(owner_id)?; - if self.player_dead { - return Err(BlobError::PlayerDead); - } - if self.current_level == 0 || self.map.is_empty() { - return Err(BlobError::NoActiveLevel); - } - let (r, c) = self.player_pos()?; - let (nr, nc) = match direction { - Direction::Up => { - if r == 0 { - return Err(BlobError::CannotMove("row already 0".into())); - } - (r - 1, c) - } - // Down / Left / Right … - _ => /* … */ (r, c), - }; - // Simulate tiles → score / dead flags, then: - self.record_moved( - score, - player_dead, - level_complete, - next_map, - direction.as_str().to_string(), - )?; - Ok(()) + file: 'blob-domain · BlobGame', + caption: 'Aggregate shape — the consistency boundary.', + code: `#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BlobGame { + pub entity: Entity, + pub game_id: String, + pub owner_id: String, + pub score: i64, + pub player_dead: bool, + /// 0 = no level yet; 1+ = active level index. + pub current_level: i64, + pub current_level_completed: bool, + /// Current level map only. + pub map: Vec>, } +#[sourced( + entity, + events = "BlobGameEvent", + aggregate_type = "blob", + domain_state = BlobGameState, +)] +impl BlobGame { /* start, move_dir, … */ }` + }, + { + file: 'blob-domain · BlobGame::move_dir (event)', + caption: 'After rules and tile sim, the move is one domain event.', + code: `// … ensure_owner, bounds, tile simulation → score / dead / map … + +self.record_moved( + score, + player_dead, + level_complete, + next_map, + direction.as_str().to_string(), +)?; + #[event("blob.moved", version = 1, domain)] fn record_moved( &mut self, @@ -625,7 +783,7 @@ fn record_moved( { id: 'events', label: '5 · Events', - lede: 'Domain events still exist for history. For blob, the direct projection path writes the read model in the same commit as the event — not a later eventual handler for the board.', + lede: 'Domain events still exist for history. For blob, the same save_blob_game mutation program runs direct in the command handler (same commit as the event) — so the response can carry the row. Eventual placement would run that IR in an event handler instead, with no response channel to the waiting client.', principle: 'Know which side of the fence you are on.', samples: [ { @@ -647,6 +805,14 @@ fn record_moved( input: { game: body }, }, }; +}` + }, + { + file: 'projections/mutations/save_blob_game.mutation.graphql', + caption: 'Syntax-only upsert used by direct and eventual projection paths.', + code: `# Syntax-only read-model mutation → MutationProgram IR. +mutation SaveBlobGame { + upsert_BlobGames(object: $input.game) }` }, { @@ -659,7 +825,7 @@ fn record_moved( vec![projection_output::()], /* … */, )?; -// Handler stages the row via readmodel(row).commit()?.projected()` +// Handler stages the row via readmodel(row).commit()?.atomic()` } ] } @@ -672,12 +838,12 @@ export const adminWalkthrough: DemoWalkthrough = { title: 'Admin surface', kicker: 'Browser → second client → elevated command', summary: - 'Start with the elevated query on e2e-ui-admin. Force-archive is a Causal command on that surface only — still optimistic cache on the admin replica, still repo → aggregate → commit on the server.', + 'Start with the elevated query on e2e-ui-admin. Force-archive is an Eventual command on that surface only — still optimistic cache on the admin replica, still repo → aggregate → commit on the server.', tabs: [ { id: 'query', label: '1 · Query', - lede: 'Admin list is a different generated client and route registry. Same GraphQL engine, different surface privilege — admin grant sees every owner’s todos.', + lede: 'Admin list is a different generated client and route registry over the same Todos read model. Same GraphQL engine, different surface privilege — admin grant sees every owner’s todos.', principle: 'Roles and surfaces are real.', samples: [ { @@ -687,6 +853,22 @@ export const adminWalkthrough: DemoWalkthrough = { const query = AdminAllTodos.use(); const commands = useCommands(); const todos = $derived($query.complete ? $query.data.todos : []);` + }, + { + file: 'readmodels/models/todos.rs · Todos', + caption: 'Same query model as /todos — elevated surface, not a second table.', + code: `#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, 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, + pub assignee_id: Option, + #[readmodel(belongs_to = "AuthUsers", foreign_key = "owner_id")] + pub owner: Option, +}` }, { file: 'readmodels/models/todos.rs · admin grant', @@ -731,7 +913,7 @@ const todos = $derived($query.complete ? $query.data.todos : []);` caption: 'Write RBAC: admin only; absent from user client inventory.', code: `typed_command::< TodoForceArchiveInput, - Causal, + Eventual, >(todo_force_archive::COMMAND) .field_name("todos_force_archive") .roles(["admin"]) @@ -760,7 +942,7 @@ const todos = $derived($query.complete ? $query.data.todos : []);` { id: 'handlers', label: '3 · Handlers', - lede: 'Handler still uses repo.get → domain force_archive → Causal commit. Authorization is role + surface, not a special HTTP path.', + lede: 'Handler still uses repo.get → domain force_archive → Eventual commit. Authorization is role + surface, not a special HTTP path.', principle: 'Trust the signed-in person, not the request body.', samples: [ { @@ -768,7 +950,7 @@ const todos = $derived($query.complete ? $query.data.todos : []);` code: `pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoForceArchiveInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let admin = ctx.user_id()?.to_string(); let repo = ctx.repo(); let mut todo = repo @@ -780,7 +962,7 @@ const todos = $derived($query.complete ? $query.data.todos : []);` let state = TodoState::from(&*todo); repo.publish_events() .commit(todo)? - .causal(TodoForceArchivePayload { + .eventual(TodoForceArchivePayload { todo_id: state.todo_id, owner_id: state.owner_id, status: state.status, @@ -793,12 +975,36 @@ const todos = $derived($query.complete ? $query.data.todos : []);` { id: 'domain', label: '4 · Domain', - lede: 'Same Todo aggregate — elevated methods live on the domain type, not in the GraphQL layer.', + lede: 'Same Todo aggregate as /todos — elevated methods live on the domain type, not in the GraphQL layer. One write model, many surfaces.', principle: 'Start with the domain, not the database.', samples: [ + { + file: 'todo-domain · Todo', + caption: 'Same aggregate shape as the user surface.', + code: `#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Todo { + pub entity: Entity, + pub todo_id: String, + pub owner_id: String, + pub title: String, + pub status: TodoStatus, + pub assignee_id: Option, + purged: bool, + snapshot_generation: u64, +} + +#[sourced( + entity, + events = "TodoEvent", + aggregate_type = "todo", + domain_state = TodoState, +)] +impl Todo { /* create, complete, force_archive, … */ }` + }, { file: 'todo-domain · Todo::force_archive', - code: `/// Record an administrator intervention separately from owner archival. + caption: 'Admin path is still a domain event on the same aggregate.', + code: `/// Administrator intervention — separate event from owner archival. pub fn force_archive(&mut self) -> Result<(), TodoError> { if !self.is_created() { return Err(TodoError::NotCreated); @@ -818,7 +1024,7 @@ fn record_force_archived(&mut self) { { id: 'events', label: '5 · Events', - lede: 'Force-archive emits TodoForceArchivedDomainEvent into the same todos projection path as owner archive — every surface’s replica converges on one query model.', + lede: 'Force-archive emits TodoForceArchivedDomainEvent into the same todos projection path (and same save_todo mutation) as owner archive — every surface’s replica converges on one query model.', principle: 'Register once, ship everywhere.', samples: [ { @@ -845,6 +1051,22 @@ fn record_force_archived(&mut self) { input: { todo_id: aggregate_id }, }, }; +}` + }, + { + file: 'projections/mutations/save_todo.mutation.graphql', + caption: 'Same mutation program as owner complete/archive — force-archive is just another event on this arm.', + code: `# Syntax-only read-model mutation → MutationProgram IR. +mutation SaveTodo { + upsert_Todos(object: $input.todo) +}` + }, + { + file: 'projections/mutations/delete_todo.mutation.graphql', + caption: 'Purge arm (if ever elevated) still uses the same delete program.', + code: `# Syntax-only read-model mutation → MutationProgram IR. +mutation DeleteTodo { + delete_Todos_by_pk(todo_id: $input.todo_id) }` } ] @@ -992,9 +1214,28 @@ IdentityConfig::oidc_bearer(oidc)` { id: 'events', label: '5 · Directory', - lede: 'People still appear as auth_users via Zitadel ingest/scrape domain events — joins for chat author and blob owner, not a second display-name source.', + lede: 'People still appear as AuthUsers via Zitadel ingest/scrape domain events — joins for chat author and blob owner, not a second display-name source.', principle: 'Know which side of the fence you are on.', samples: [ + { + file: 'readmodels/models/auth_users.rs · AuthUsers', + caption: 'Imported IdP directory row. PK is OIDC sub / session x-user-id. Filled by ingest, never by commands.', + code: `#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ReadModel)] +#[table("auth_users")] +pub struct AuthUsers { + #[id("user_id")] + pub user_id: String, + pub email: String, + pub display_name: String, + /// human | machine + pub user_kind: String, + /// pending | approved | rejected + pub approval_status: String, + /// active | deactivated + pub status: String, + pub updated_at: String, +}` + }, { file: 'handlers/events/project_auth_user.rs', code: `pub const EVENTS: &[&str] = &[ @@ -1034,181 +1275,12 @@ pub async fn handle( ] }; -export const publicWalkthrough: DemoWalkthrough = { - id: 'public', - href: '/public', - title: 'Public surface', - kicker: 'Browser → anonymous surface · no session', - summary: - 'Start with the request the browser would send: empty identity + named e2e-ui-public surface. No optimistic commands here — read-only anonymous privilege pack.', - tabs: [ - { - id: 'query', - label: '1 · Query', - lede: 'The teaching point is opening a surface with no session. Protocol extensions name e2e-ui-public; privilege pack is anonymous (chat + directory joins only).', - principle: 'Roles and surfaces are real.', - samples: [ - { - file: 'routes/public/+page.svelte · request body', - code: `{ - "query": "{ chat_messages(limit: 10, offset: 0) { message_id body room_id created_at } }", - "extensions": { - "distributed": { - "client": { - "surface": { - "kind": "application", - "name": "e2e-ui-public", - "roles": ["anonymous"] - }, - "schemaHash": "" - } - } - } -}` - }, - { - file: 'readmodels · anonymous grants', - caption: 'Only models granted to anonymous appear on this surface.', - code: `// ChatMessages -.grant("anonymous", read().all_columns()) - -// AuthUsers (author display joins) -.grant("anonymous", read().all_columns()) - -// Todos / BlobGames: no anonymous grant -// → absent from the public client schema` - }, - { - file: 'service.rs · public surface registration', - code: `.client_application_surface( - "e2e-ui-public", - ["anonymous"], -)` - } - ] - }, - { - id: 'commands', - label: '2 · No writes', - lede: 'Public surface is read-shaped. There is no optimistic command cache on this page — command RBAC simply does not expose mutations to anonymous.', - principle: 'Simplest DX is the goal.', - samples: [ - { - file: 'generated/public/commands.ts', - code: `export const COMMAND_ARTIFACTS = [] as const; - -export const COMMANDS = {} as const; - -export type GeneratedCommands = - Readonly>;` - }, - { - file: 'generated/public/sveltekit.ts', - code: `export function provideDistributed( - options: Omit< - CreateDistributedSvelteKitOptions, - 'createCommands' - >, -): DistributedSvelteKitClient { - return provideDistributedSvelteKitClient( - createDistributedSvelteKit({ - ...options, - }), - ); -}` - } - ] - }, - { - id: 'handlers', - label: '3 · Authority', - lede: 'resolve_execution_authority: empty asserted roles + eligible anonymous → privilege pack for e2e-ui-public. No synthetic x-roles=anonymous header.', - principle: 'Set-only identity; surface privilege for execution.', - samples: [ - { - file: 'src/graphql/engine/protocol.rs', - code: `fn principal_may_open_application( - asserted: &[String], - eligible: &[String], -) -> bool { - // Unauthenticated principals may open surfaces that list \`anonymous\`. - if asserted.is_empty() { - return eligible.iter().any(|role| role == "anonymous"); - } - asserted.iter().any(|role| { - eligible - .binary_search_by(|c| c.as_str().cmp(role.as_str())) - .is_ok() - }) -}` - }, - { - file: 'src/graphql/identity/resolve.rs · empty Bearer', - code: `None => { - if oidc.require_auth { - Err(AuthError::Unauthorized) - } else { - Ok(ResolvedIdentity::unverified(Session::new())) - } -}` - } - ] - }, - { - id: 'domain', - label: '4 · Chat model', - lede: 'Reads still hit the same chat_messages query model that authenticated clients use — only the surface privilege differs.', - principle: 'Register once, ship everywhere.', - samples: [ - { - file: 'readmodels/models/chat_messages.rs', - code: `impl ChatMessages { - pub fn permissions() -> ModelPermissions { - ModelPermissions::new() - .grant("user", read().all_columns()) - .grant("admin", read().all_columns()) - .grant("anonymous", read().all_columns()) - } -}` - } - ] - }, - { - id: 'events', - label: '5 · Events', - lede: 'Messages still arrive from chat domain events + projection. Public clients only observe what anonymous RLS allows — they cannot post.', - principle: 'Commands change the world; tables are for reading.', - samples: [ - { - file: 'projections/chat.rs', - code: `projection! { - pub const CHAT_MESSAGES: ProjectionDescriptor = { - name: "project_chat_messages", - version: 1, - epoch: "e2e-ui-chat-v2", - model: ChatMessages, - on { - events: [ChatMessagePostedDomainEvent], - mutation: save_chat_message, - input: { message: body }, - }, - }; -} -// ChatMessagePostedDomainEvent → upsert chat_messages -// Public surface has no chat.post command inventory` - } - ] - } - ] -}; - export const allWalkthroughs: DemoWalkthrough[] = [ todosWalkthrough, chatWalkthrough, blobWalkthrough, adminWalkthrough, - sessionWalkthrough, - publicWalkthrough + sessionWalkthrough ]; export function walkthroughById(id: string): DemoWalkthrough | undefined { diff --git a/tests/e2e-ui/ui/src/lib/walkthrough/index.ts b/tests/e2e-ui/ui/src/lib/walkthrough/index.ts index 41daa090..7025f3ed 100644 --- a/tests/e2e-ui/ui/src/lib/walkthrough/index.ts +++ b/tests/e2e-ui/ui/src/lib/walkthrough/index.ts @@ -4,7 +4,6 @@ export { allWalkthroughs, blobWalkthrough, chatWalkthrough, - publicWalkthrough, sessionWalkthrough, todosWalkthrough, walkthroughById diff --git a/tests/e2e-ui/ui/src/lib/walkthrough/types.ts b/tests/e2e-ui/ui/src/lib/walkthrough/types.ts index 6ec5733f..d46e839f 100644 --- a/tests/e2e-ui/ui/src/lib/walkthrough/types.ts +++ b/tests/e2e-ui/ui/src/lib/walkthrough/types.ts @@ -22,7 +22,7 @@ export type WalkthroughTab = { /** Full walkthrough for one demo route. */ export type DemoWalkthrough = { - /** Stable key (chat | todos | blob | admin | session | public) */ + /** Stable key (chat | todos | blob | admin | session) */ id: string; /** Route path */ href: string; diff --git a/tests/e2e-ui/ui/src/routes/+page.svelte b/tests/e2e-ui/ui/src/routes/+page.svelte index c762b87f..a07875f1 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -29,11 +29,10 @@ 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: 'Causal', blurb: 'Ownership rules, optimistic commands, projector fill.' }, - { href: '/blob', tag: 'Projected', title: 'Blob game', blurb: 'Game moves with an atomic board in the response.' }, + { 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: '/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.' }, - { href: '/public', title: 'Public', tag: 'Anonymous', blurb: 'What an open surface can show without a user.' } + { href: '/session', title: 'Session', tag: 'OIDC', blurb: 'Who you are to the app: tokens, groups, roles.' } ]; // —— Code samples from the living playground (trimmed for teaching) —— @@ -123,7 +122,7 @@ mutation SaveTodo { pub async fn handle( ctx: &CausalCommandContext<'_, Todo>, input: TodoArchiveInput, -) -> Result>, HandlerError> { +) -> Result>, HandlerError> { let owner = ctx.user_id()?.to_string(); let mut todo = ctx.repo() .get(&input.todo_id).await? @@ -131,7 +130,7 @@ pub async fn handle( todo.archive(&owner).map_err(rejected)?; let state = TodoState::from(&*todo); - ctx.repo().publish_events().commit(todo)?.causal(TodoArchivePayload { + ctx.repo().publish_events().commit(todo)?.eventual(TodoArchivePayload { todo_id: state.todo_id, status: state.status, }) @@ -180,15 +179,6 @@ query Todos @load { todos { todo_id title status } }`; - const codeFlow = `// One direction. Order matters. The cycle closes at the client. - -client - → commands - → aggregate state change - → domain event - → projection - → read model - → client`;
@@ -208,7 +198,7 @@ client

Distributed is a state-of-the-art framework - for building distributed systems and applications. + for building distributed systems and realtime applications.

Not a partial toolkit. An end-to-end stack — domain, service, query edge, and live client — @@ -535,7 +525,7 @@ client cycle, each section below a stage with real code from this playground.

-
+
01 · Unidirectional

Changes go one way. There is order.

@@ -550,15 +540,104 @@ client optimistic UI is how the front end meets that honestly.

-
-
-
- system flow - unidirectional -
-
{@html highlightCode(codeFlow)}
+
+
+ + + + + Client + + + + GraphQL gateway + + + Commands + + + Aggregate + + + Domain event + + + Projection + + + Read model + + +
-
+
diff --git a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.server.ts b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.server.ts new file mode 100644 index 00000000..543b775f --- /dev/null +++ b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from './$types'; +import { requireAuth } from '$lib/server/require-auth'; + +export const load: PageServerLoad = async (event) => { + await requireAuth(event); + return {}; +}; diff --git a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte index 89410623..b9a78735 100644 --- a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte @@ -3,28 +3,27 @@ * Blob Game — URL-routed selection from one generated replica operation. * * - URL (`/blob` | `/blob/{gameId}`) selects which game is active. - * - Board and history derive directly from `BlobGames.use()`. - * - Projected command payloads enter that same replica before calls resolve. + * - Board and history derive from `BlobGames.use()`. + * - Commands are Atomic (same client optimism path as Eventual): `.applies` + * paints from command input; the handler applies the same mutation IR and + * the response row seals before await resolves. */ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; import { BlobGames, useCommands } from '$distributed'; + import { + parseBoard, + simulateMove, + SimulateMoveError, + TILE, + type Direction + } from '$lib/blob/simulate-move'; import { Button } from '$lib/components/shared/ui'; import { AppPage, InlineAlert, PageHeader } from '$lib/components/product'; import { HowItsBuilt } from '$lib/components/walkthrough'; import { blobWalkthrough } from '$lib/walkthrough'; - const TILE = { - player: 9, - hole: 0, - unvisited: 1, - visited: 2, - deadBySuicide: 3, - deadByHole: 4 - } as const; - type Direction = 'up' | 'down' | 'left' | 'right'; - let { data } = $props(); let actionError = $state(null); let commandPending = $state(false); @@ -50,13 +49,7 @@ const board = $derived.by(() => { if (!selected) return []; try { - const value = JSON.parse(selected.map_json || '[]') as unknown; - return Array.isArray(value) && - value.every( - (row) => Array.isArray(row) && row.every((cell) => typeof cell === 'number') - ) - ? (value as number[][]) - : []; + return parseBoard(selected.map_json || '[]'); } catch { return []; } @@ -110,6 +103,7 @@ const game_id = newGameId(); try { const receipt = await commands.blob.start({ game_id }); + // Atomic response row is already in the replica before resolve. navigateToGame(receipt.result.game_id, true); } catch (e) { actionError = e instanceof Error ? e.message : 'Start failed'; @@ -140,16 +134,36 @@ async function move(direction: Direction) { if (!selected || playerDead || levelComplete || !hasBoard || commandPending) return; - // The replica already contains enough board state to recognize an edge. - // Treat it as a game no-op instead of dispatching a predictably rejected command. + // Edge no-op: don't dispatch a predictably rejected command. if (!canMove(direction)) { actionError = null; return; } + let preview; + try { + // Pure domain twin → command input (same pattern as chat body / + // created_at). `.applies` maps these into the optimistic layer. + preview = simulateMove(board, score, direction); + } catch (error) { + if (error instanceof SimulateMoveError) { + actionError = null; + return; + } + throw error; + } commandPending = true; actionError = null; try { - await commands.blob.move({ game_id: selected.game_id, direction }); + await commands.blob.move({ + game_id: selected.game_id, + direction, + map_json: preview.map_json, + score: preview.score, + player_dead: preview.player_dead, + current_level: currentLevel, + current_level_completed: preview.level_complete, + status: preview.status + }); } catch (error) { actionError = error instanceof Error ? error.message : 'Move failed'; } finally { @@ -200,8 +214,15 @@ onMount(() => { hydrated = true; + const testWindow = window as Window & { + __distributedBlobRefetch?: () => Promise; + }; + testWindow.__distributedBlobRefetch = () => query.refetch(); window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); + return () => { + window.removeEventListener('keydown', onKey); + delete testWindow.__distributedBlobRefetch; + }; }); @@ -213,11 +234,13 @@
Board and history render from the same generated BlobGames - operation. Typed projected commands update that replica before they resolve. + operation. Moves use the same client optimism path as todos/chat + (.applies from command input); the handler applies the + same mutation IR and seals the atomic row before await resolves.