From b2008756c1ae448f046a2cfc5b38ebacd68d477d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 1 Aug 2026 17:41:34 -0500 Subject: [PATCH 01/10] feat(e2e-ui): walkthrough read models + unidirectional flow diagram Expand How-it's-built overlays with ReadModel structs, aggregate shapes, domain event samples, and projection GraphQL mutations. Replace the home system-flow monospace list with a full-width circular dotted diagram. --- tests/e2e-ui/ui/src/lib/styles/home.css | 220 +++++++++++++ tests/e2e-ui/ui/src/lib/walkthrough/demos.ts | 326 ++++++++++++++++--- tests/e2e-ui/ui/src/routes/+page.svelte | 116 ++++++- 3 files changed, 595 insertions(+), 67 deletions(-) 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..62cb6813 100644 --- a/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts +++ b/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts @@ -2,13 +2,15 @@ import type { DemoWalkthrough } from './types'; /** * Tab order is browser-first teaching order for every demo: - * 1. Query / live subscription (+ read RBAC) + * 1. Query / live subscription (+ ReadModel shape + read RBAC) * 2. Commands (optimistic cache vs atomic Projected) (+ 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. */ export const todosWalkthrough: DemoWalkthrough = { @@ -22,7 +24,7 @@ export const todosWalkthrough: DemoWalkthrough = { { 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 +37,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, }` }, { @@ -157,11 +176,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 +226,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 +257,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) }` }, { @@ -241,7 +300,7 @@ export const chatWalkthrough: DemoWalkthrough = { { 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 +320,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, }` }, { @@ -373,11 +448,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 +525,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 +542,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) }` } ] @@ -461,7 +568,7 @@ export const blobWalkthrough: DemoWalkthrough = { { 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 +580,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, }` }, { @@ -566,45 +693,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 +754,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 can run direct (same commit as the event) — not only as a later eventual handler.', principle: 'Know which side of the fence you are on.', samples: [ { @@ -647,6 +776,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) }` }, { @@ -677,7 +814,7 @@ export const adminWalkthrough: DemoWalkthrough = { { 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 +824,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', @@ -793,12 +946,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 +995,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 +1022,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 +1185,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] = &[ @@ -1064,6 +1276,22 @@ export const publicWalkthrough: DemoWalkthrough = { } } } +}` + }, + { + file: 'readmodels/models/chat_messages.rs · ChatMessages', + caption: 'Same lobby model as signed-in chat — surface privilege, not a second shape.', + 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, }` }, { diff --git a/tests/e2e-ui/ui/src/routes/+page.svelte b/tests/e2e-ui/ui/src/routes/+page.svelte index c762b87f..e60863d5 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -180,15 +180,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`;
@@ -535,7 +526,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 +541,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 + + +
-
+
From ad53d297a9e0daac8bd402d224bc39d4261d9f87 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 1 Aug 2026 17:53:26 -0500 Subject: [PATCH 02/10] fix(e2e-ui): gate todos/blob on login; drop redundant /public page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show Todos and Blob in nav for guests; requireAuth on page loads so client-side navigation redirects to /login?callbackUrl=… and returns after sign-in. Remove the standalone /public demo (lobby chat already covers anonymous). Honor callbackUrl on login/signup when already signed in; hero copy mentions realtime applications. --- tests/e2e-ui/ui/src/hooks.server.ts | 50 ++--- .../src/lib/components/shared/Footer.svelte | 3 +- .../components/shared/header/Navbar.svelte | 25 +-- .../e2e-ui/ui/src/lib/server/require-auth.ts | 24 +++ tests/e2e-ui/ui/src/lib/walkthrough/demos.ts | 187 +----------------- tests/e2e-ui/ui/src/lib/walkthrough/index.ts | 1 - tests/e2e-ui/ui/src/lib/walkthrough/types.ts | 2 +- tests/e2e-ui/ui/src/routes/+page.svelte | 5 +- .../routes/blob/[[gameId]]/+page.server.ts | 7 + .../ui/src/routes/login/+page.server.ts | 6 +- .../e2e-ui/ui/src/routes/public/+page.svelte | 78 -------- .../ui/src/routes/session/+page.server.ts | 9 +- .../ui/src/routes/signup/+page.server.ts | 5 +- .../ui/src/routes/todos/+page.server.ts | 7 + 14 files changed, 96 insertions(+), 313 deletions(-) create mode 100644 tests/e2e-ui/ui/src/lib/server/require-auth.ts create mode 100644 tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.server.ts delete mode 100644 tests/e2e-ui/ui/src/routes/public/+page.svelte create mode 100644 tests/e2e-ui/ui/src/routes/todos/+page.server.ts 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/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 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/walkthrough/demos.ts b/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts index 62cb6813..3a934e06 100644 --- a/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts +++ b/tests/e2e-ui/ui/src/lib/walkthrough/demos.ts @@ -1246,197 +1246,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/models/chat_messages.rs · ChatMessages', - caption: 'Same lobby model as signed-in chat — surface privilege, not a second shape.', - 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, -}` - }, - { - 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 e60863d5..9a4577ae 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -32,8 +32,7 @@ { 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: '/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) —— @@ -199,7 +198,7 @@ query Todos @load {

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 — 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/login/+page.server.ts b/tests/e2e-ui/ui/src/routes/login/+page.server.ts index f42eb1bf..ad29d05b 100644 --- a/tests/e2e-ui/ui/src/routes/login/+page.server.ts +++ b/tests/e2e-ui/ui/src/routes/login/+page.server.ts @@ -1,12 +1,14 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { loginWithPassword, ZitadelAuthError } from '$lib/server/zitadel-session'; -import { startOidcSignIn } from '$lib/server/oidc-start'; +import { safeCallbackUrl, startOidcSignIn } from '$lib/server/oidc-start'; export const load: PageServerLoad = async (event) => { const session = await event.locals.auth(); if (session?.user) { - redirect(303, '/todos'); + // Prefer the destination that sent them to login (e.g. /blob, /todos). + const dest = safeCallbackUrl(event.url); + redirect(303, dest === event.url.origin ? '/todos' : dest); } const authRequest = event.url.searchParams.get('authRequest')?.trim() ?? ''; diff --git a/tests/e2e-ui/ui/src/routes/public/+page.svelte b/tests/e2e-ui/ui/src/routes/public/+page.svelte deleted file mode 100644 index 4c9c7e43..00000000 --- a/tests/e2e-ui/ui/src/routes/public/+page.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - - -

- This route is intentionally unauthenticated. It documents the bare protocol path for the - e2e-ui-public surface (eligible + privilege - anonymous): open with no session, read lobby messages only. -

-
    -
  • No Auth.js session required (not under the protected-prefix list).
  • -
  • - GraphQL must send the application surface extension above; multi-role authed clients use - e2e-ui / e2e-ui-admin instead. -
  • -
  • - Automated proof: Rust service test - public_surface_opens_and_queries_chat_without_identity (empty Session + - chat_messages query). -
  • -
-
{SAMPLE}
- - - - - diff --git a/tests/e2e-ui/ui/src/routes/session/+page.server.ts b/tests/e2e-ui/ui/src/routes/session/+page.server.ts index 0510e6d1..0f6095c3 100644 --- a/tests/e2e-ui/ui/src/routes/session/+page.server.ts +++ b/tests/e2e-ui/ui/src/routes/session/+page.server.ts @@ -1,8 +1,7 @@ import type { PageServerLoad } from './$types'; +import { requireAuth } from '$lib/server/require-auth'; -export const load: PageServerLoad = async ({ locals }) => { - const session = await locals.auth(); - return { - session - }; +export const load: PageServerLoad = async (event) => { + const session = await requireAuth(event); + return { session }; }; diff --git a/tests/e2e-ui/ui/src/routes/signup/+page.server.ts b/tests/e2e-ui/ui/src/routes/signup/+page.server.ts index 85362e0e..32f2b32c 100644 --- a/tests/e2e-ui/ui/src/routes/signup/+page.server.ts +++ b/tests/e2e-ui/ui/src/routes/signup/+page.server.ts @@ -5,12 +5,13 @@ import { registerHuman, ZitadelAuthError } from '$lib/server/zitadel-session'; -import { startOidcSignIn } from '$lib/server/oidc-start'; +import { safeCallbackUrl, startOidcSignIn } from '$lib/server/oidc-start'; export const load: PageServerLoad = async (event) => { const session = await event.locals.auth(); if (session?.user) { - redirect(303, '/todos'); + const dest = safeCallbackUrl(event.url); + redirect(303, dest === event.url.origin ? '/todos' : dest); } // Optional: when coming from /login mid-OIDC, preserve authRequest to finalize without a second password entry. diff --git a/tests/e2e-ui/ui/src/routes/todos/+page.server.ts b/tests/e2e-ui/ui/src/routes/todos/+page.server.ts new file mode 100644 index 00000000..543b775f --- /dev/null +++ b/tests/e2e-ui/ui/src/routes/todos/+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 {}; +}; From 24ce2591758dc8134255f2e45d34bbc4ac7f4c6e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sun, 2 Aug 2026 01:38:07 -0500 Subject: [PATCH 03/10] feat: Eventual/Atomic command semantics + restore chat optimism Ship one mutation IR with two proofs: Eventual (async projector + delta/expects) and Atomic (handler row + records). Rename wire/protocol states and APIs from causal/projected to eventual/atomic with no aliases. Direct placements export .applies previews for client optimism while still sealing from the atomic response. Command ledger migrations use atomic state (0004 + CHECK updates). Fix chat list optimism regressions: belongs_to joins are GraphQL/client nullable so missing author edges materialize, and full first-page offset indexes accept local optimistic inserts (re-sort + truncate). Regenerate e2e-ui clients, demos/docs, and JS tests for the new contract. --- README.md | 6 +- .../skills/distributed-graphql/SKILL.md | 36 +- .../skills/distributed-usage/SKILL.md | 8 +- .../command_manifest/confirmations.rs | 8 +- .../client_compiler/command_manifest/shape.rs | 2 +- .../client_compiler/command_manifest_tests.rs | 2 +- .../client_compiler/manifest/projections.rs | 26 +- .../client_compiler/manifest/projectors.rs | 4 +- .../src/client_compiler/manifest/types.rs | 4 +- .../projection_delta/preview.rs | 8 +- .../src/client_compiler/render/commands.rs | 4 +- distributed_cli/src/client_compiler/tests.rs | 10 +- js/README.md | 16 +- js/src/protocol.ts | 14 +- js/src/replica/command-runtime/create.ts | 58 +- .../replica/command-runtime/lib/projection.ts | 2 +- js/src/replica/command-runtime/lib/status.ts | 8 +- js/src/replica/command-runtime/types.ts | 4 +- js/src/replica/commands/receipt.ts | 11 +- js/src/replica/commands/validate.ts | 14 +- js/src/replica/diagnostics/types.ts | 8 +- js/src/replica/distributed-replica/helpers.ts | 2 +- .../distributed-replica/impl-diagnostics.ts | 2 +- .../distributed-replica/impl-optimistic.ts | 4 +- js/src/replica/distributed-replica/impl.ts | 4 +- js/src/replica/index-maintenance/engine.ts | 13 +- js/src/replica/query-plan/pagination.ts | 28 +- js/src/replica/types.ts | 2 +- js/tests/cache-engine-conformance.test.mjs | 6 +- js/tests/fixtures/adapter-conformance.mjs | 8 +- js/tests/protocol-transport.test.mjs | 2 +- js/tests/replica-command-artifacts.test.mjs | 10 +- js/tests/replica-command-runtime.test.mjs | 128 +++- js/tests/replica-diagnostics.test.mjs | 4 +- js/tests/replica-index-maintenance.test.mjs | 46 +- js/tests/replica-protocol.test.mjs | 12 +- js/tests/replica-query-plan.test.mjs | 22 +- js/tests/sveltekit-adapter.test.mjs | 2 +- migrations/postgres/0002_command_ledger.sql | 4 +- .../0004_command_ledger_atomic_state.sql | 74 +++ migrations/sqlite/0002_command_ledger.sql | 4 +- .../0004_command_ledger_atomic_state.sql | 88 +++ src/command_ledger/record.rs | 4 +- src/command_ledger/reservation.rs | 6 +- src/command_ledger/state.rs | 15 +- src/command_ledger/tests.rs | 20 +- src/graphql/client_manifest/build.rs | 4 +- src/graphql/client_manifest/commands.rs | 2 +- src/graphql/client_manifest/projections.rs | 39 +- src/graphql/client_manifest/tests.rs | 10 +- src/graphql/client_manifest/types.rs | 2 +- .../command_contract/direct_projection.rs | 4 +- src/graphql/command_contract/mod.rs | 4 +- src/graphql/command_contract/outcomes.rs | 55 +- .../command_contract/projection_proof.rs | 2 +- src/graphql/command_contract/tests.rs | 14 +- src/graphql/command_contract/typed_command.rs | 20 +- src/graphql/commands.rs | 4 +- src/graphql/mod.rs | 4 +- src/graphql/naming.rs | 4 +- src/graphql/projection_delta/tests.rs | 18 +- src/graphql/protocol/accumulator.rs | 10 +- src/graphql/protocol/tests.rs | 16 +- src/graphql/protocol/types.rs | 7 +- src/graphql/surface/application.rs | 5 + src/graphql/surface/build.rs | 20 +- src/graphql/surface/commands.rs | 14 +- src/graphql/surface/projections.rs | 55 +- src/graphql/surface/tests.rs | 6 +- src/graphql/surface/types.rs | 6 +- .../projection_protocol/tests.rs | 2 +- src/microsvc/causal.rs | 62 +- src/microsvc/service/causal.rs | 13 +- src/microsvc/service/handlers.rs | 24 +- src/microsvc/service/routes.rs | 14 +- src/microsvc/service/tests.rs | 26 +- src/projection/lower.rs | 4 +- .../projection_protocol/postgres_tests.rs | 2 +- src/sqlx_repo/projection_protocol/tests.rs | 8 +- tests/e2e-ui/PROJECTION_ROLLOUT.md | 3 +- tests/e2e-ui/README.md | 54 +- tests/e2e-ui/crates/blob-domain/src/lib.rs | 4 +- .../blob-domain/src/models/blob_game.rs | 189 +++--- .../crates/blob-domain/src/models/mod.rs | 2 +- tests/e2e-ui/crates/projections/src/blob.rs | 2 +- tests/e2e-ui/crates/readmodels/src/lib.rs | 19 +- .../src/handlers/commands/blob_move.rs | 6 +- .../src/handlers/commands/blob_start.rs | 6 +- .../src/handlers/commands/blob_start_level.rs | 6 +- .../src/handlers/commands/chat_post.rs | 6 +- .../src/handlers/commands/todo_archive.rs | 6 +- .../src/handlers/commands/todo_complete.rs | 6 +- .../src/handlers/commands/todo_create.rs | 6 +- .../handlers/commands/todo_force_archive.rs | 6 +- .../src/handlers/commands/todo_purge.rs | 6 +- .../src/handlers/commands/todo_rename.rs | 6 +- .../src/handlers/commands/todo_reopen.rs | 6 +- tests/e2e-ui/crates/service/src/service.rs | 74 ++- .../ui/src/lib/generated/admin/commands.ts | 573 +++++++++++++++++- .../ui/src/lib/generated/admin/manifest.json | 2 +- .../admin/operations/admin-all-todos.ts | 2 +- .../ui/src/lib/generated/admin/protocol.ts | 4 +- .../ui/src/lib/generated/public/manifest.json | 2 +- .../public/operations/chat-messages.ts | 6 +- .../ui/src/lib/generated/public/protocol.ts | 2 +- .../ui/src/lib/generated/user/commands.ts | 569 ++++++++++++++++- .../ui/src/lib/generated/user/manifest.json | 2 +- .../generated/user/operations/blob-games.ts | 6 +- .../user/operations/chat-messages.ts | 6 +- .../lib/generated/user/operations/todos.ts | 2 +- .../ui/src/lib/generated/user/protocol.ts | 4 +- tests/e2e-ui/ui/src/lib/walkthrough/demos.ts | 88 +-- .../src/routes/blob/[[gameId]]/+page.svelte | 17 +- .../generated-draining-command-v2.json | 2 +- 114 files changed, 2286 insertions(+), 657 deletions(-) create mode 100644 migrations/postgres/0004_command_ledger_atomic_state.sql create mode 100644 migrations/sqlite/0004_command_ledger_atomic_state.sql diff --git a/README.md b/README.md index 5952216e..bf470666 100644 --- a/README.md +++ b/README.md @@ -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 `Projected` (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 `Projected` 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 | diff --git a/distributed_cli/skills/distributed-graphql/SKILL.md b/distributed_cli/skills/distributed-graphql/SKILL.md index 30dd9acc..23735634 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. @@ -134,22 +134,34 @@ 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)?.projected() +``` 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()?.projected()`). 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 Projected 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..d1d45ee7 100644 --- a/distributed_cli/skills/distributed-usage/SKILL.md +++ b/distributed_cli/skills/distributed-usage/SKILL.md @@ -289,10 +289,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 + `Projected` 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** (`Causal` / `Projected`) 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..10ae4d73 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 { diff --git a/distributed_cli/src/client_compiler/manifest/projections.rs b/distributed_cli/src/client_compiler/manifest/projections.rs index 19c2ead9..e844723a 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 (Projected 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 Projected 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..3217016d 100644 --- a/distributed_cli/src/client_compiler/projection_delta/preview.rs +++ b/distributed_cli/src/client_compiler/projection_delta/preview.rs @@ -535,8 +535,12 @@ 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 +1855,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..e6548a87 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"}] @@ -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/js/README.md b/js/README.md index b7fc727c..244d70dc 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: + - **Causal / Eventual** — wait for projection obligations (event handler ran + async; there is no authoritative row on the command response); + - **Projected / 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..040183fd 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') { + // Projected 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). Causal 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..6a636e43 100644 --- a/js/src/replica/command-runtime/types.ts +++ b/js/src/replica/command-runtime/types.ts @@ -138,7 +138,7 @@ export interface ReplicaCommandTransport { export type ReplicaCommandProjectedOutcome = Readonly<{ commandId: string; - state: 'projected'; + state: 'atomic'; /** Present for same-transaction Projected, 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; diff --git a/js/src/replica/commands/receipt.ts b/js/src/replica/commands/receipt.ts index 277ef4e7..3ae4afcd 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: Projected never persists eventual + * modeled projection metadata): + * - Causal: response carries projection-delta (+ expects) when modeled. + * - Projected: 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..7f78d204 100644 --- a/js/tests/fixtures/adapter-conformance.mjs +++ b/js/tests/fixtures/adapter-conformance.mjs @@ -464,13 +464,13 @@ 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', + 'atomic', 'Projected confirmation must atomically replace the pending layer' ); @@ -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..a2fc05bc 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(); }); @@ -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); @@ -2499,6 +2499,80 @@ for (const scenario of ['older-row', 'newer-row', 'newer-tombstone']) { }); } +test('Projected with portable preview IR does not require a causal 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/0002_command_ledger.sql b/migrations/postgres/0002_command_ledger.sql index 74e200b6..62fe006f 100644 --- a/migrations/postgres/0002_command_ledger.sql +++ b/migrations/postgres/0002_command_ledger.sql @@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS command_ledger ( 'retryable_unknown', 'succeeded', 'succeeded_pending_projection', - 'projected', + 'atomic', 'rejected', 'projection_failed', 'expired' @@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS command_ledger ( (state IN ( 'succeeded', 'succeeded_pending_projection', - 'projected', + 'atomic', 'rejected', 'projection_failed' ) 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..4a4ceaba --- /dev/null +++ b/migrations/postgres/0004_command_ledger_atomic_state.sql @@ -0,0 +1,74 @@ +-- 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 + JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'command_ledger' + AND c.contype = 'c' + AND ( + pg_get_constraintdef(c.oid) LIKE '%projected%' + OR pg_get_constraintdef(c.oid) LIKE '%state IN%' + OR c.conname LIKE 'command_ledger%check%' + ) + 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/0002_command_ledger.sql b/migrations/sqlite/0002_command_ledger.sql index 0af81291..3f5cfc50 100644 --- a/migrations/sqlite/0002_command_ledger.sql +++ b/migrations/sqlite/0002_command_ledger.sql @@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS command_ledger ( 'retryable_unknown', 'succeeded', 'succeeded_pending_projection', - 'projected', + 'atomic', 'rejected', 'projection_failed', 'expired' @@ -58,7 +58,7 @@ CREATE TABLE IF NOT EXISTS command_ledger ( (state IN ( 'succeeded', 'succeeded_pending_projection', - 'projected', + 'atomic', 'rejected', 'projection_failed' ) 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..0f393ac7 --- /dev/null +++ b/migrations/sqlite/0004_command_ledger_atomic_state.sql @@ -0,0 +1,88 @@ +-- SQLite cannot ALTER CHECK constraints in place. Rebuild command_ledger with +-- atomic as the same-tx terminal state (was projected). + +UPDATE command_ledger +SET state = 'atomic' +WHERE state = '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 text, + outcome text, + created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + completed_at text, + retention_expires_at text NOT NULL, + compacted_at text, + 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 (length(command_contract_hash) = 32), + CHECK (length(input_hash) = 32), + CHECK (causation_id <> ''), + CHECK (attempt_number > 0), + 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 SELECT * 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..5933e38b 100644 --- a/src/command_ledger/tests.rs +++ b/src/command_ledger/tests.rs @@ -467,8 +467,8 @@ where CommandLedgerState::SucceededPendingProjection, ), ( - TerminalCommandState::Projected, - CommandLedgerState::Projected, + TerminalCommandState::Atomic, + CommandLedgerState::Atomic, ), (TerminalCommandState::Rejected, CommandLedgerState::Rejected), ]; @@ -498,7 +498,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 +1073,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 +1099,7 @@ fn completion_rejects_inconsistent_projection_obligation_states() { for state in [ TerminalCommandState::Succeeded, - TerminalCommandState::Projected, + TerminalCommandState::Atomic, TerminalCommandState::Rejected, ] { assert!(fresh_attempt() @@ -1159,7 +1159,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 +1345,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 +1720,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 +1736,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 +1771,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/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..90e575b2 100644 --- a/src/graphql/client_manifest/tests.rs +++ b/src/graphql/client_manifest/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::graphql::{ build_surface, claim, col, rel, surface_for_application, surface_for_role, typed_command, - Causal, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, PreparedCommand, + Eventual, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, PreparedCommand, RoleGrant, Succeeded, SurfaceCommand, SurfaceOptions, SurfaceProjector, SurfaceTypeField, }; use crate::microsvc::{CausalCommandContext, HandlerError, Routes, Service}; @@ -253,9 +253,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,7 +304,7 @@ 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]) @@ -516,7 +516,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(), 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..048377dc 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, + Eventual, CommandConsistency, CommandOutcome, PrepareCommandError, PreparedCommand, Atomic, 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..6ea8f9d2 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, "\"causal\""), + (CommandConsistency::Atomic, "\"projected\""), ]; for (consistency, encoded) in cases { @@ -656,8 +656,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..db618e81 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::{CommandConsistency, CommandOutcome, Atomic}; 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 { @@ -677,7 +677,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/mod.rs b/src/graphql/mod.rs index 7651f23a..03a80293 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, Eventual, CommandConsistency, CommandProjectionEventSet, CommandProjectionPreview, CommandProjectionPreviewSource, CompiledDirectProjectionTarget, CompiledInputDefaults, PrepareCommandError, PreparedCommand, - Projected, Succeeded, TypedCommand, TypedEffectExpression, TypedEffectKey, + Atomic, Succeeded, TypedCommand, TypedEffectExpression, TypedEffectKey, TypedEffectRelationship, }; pub use naming::{ 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..0d7dbbab 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(), @@ -3526,7 +3526,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..599d2bdc 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 { @@ -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..45e01791 100644 --- a/src/graphql/surface/projections.rs +++ b/src/graphql/surface/projections.rs @@ -262,13 +262,31 @@ 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. pub(crate) fn is_causal_evidence_eligible(&self) -> bool { @@ -399,24 +417,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(), 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/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/microsvc/causal.rs b/src/microsvc/causal.rs index 7bbc4acf..3e4fe58b 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::{PrepareCommandError, PreparedCommand, Atomic}; 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,14 +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") + crate::graphql::typed_command::>("test.project") .into_contract(); parts.validate_prepared(&contract, &mut prepared).unwrap(); } @@ -1303,14 +1303,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") + crate::graphql::typed_command::>("test.project") .into_contract(); assert!(matches!( @@ -1324,7 +1324,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(), }) @@ -1342,7 +1342,7 @@ mod tests { workspace.stage_read_models(conflicting).unwrap(); let mut parts = workspace.into_parts().unwrap(); let contract = - crate::graphql::typed_command::>("test.project") + crate::graphql::typed_command::>("test.project") .into_contract(); assert!(matches!( @@ -1356,7 +1356,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(), }) @@ -1372,7 +1372,7 @@ mod tests { .values .insert("title", RowValue::String("different".into())); let contract = - crate::graphql::typed_command::>("test.project") + crate::graphql::typed_command::>("test.project") .into_contract(); assert!(matches!( @@ -1388,7 +1388,7 @@ mod tests { } fn modeled_direct_contract() -> TypedCommandContract { - crate::graphql::typed_command::>( + crate::graphql::typed_command::>( "test.modeled-direct", ) .into_contract() @@ -1408,7 +1408,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 +1431,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 +1479,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 +1511,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 +1539,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 +1562,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 +1586,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 +1617,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/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..f7dc9a4c 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::{Eventual, GraphqlOutputType, PreparedCommand, Atomic, 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/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/tests.rs b/src/microsvc/service/tests.rs index 3f621cf7..a6f3e383 100644 --- a/src/microsvc/service/tests.rs +++ b/src/microsvc/service/tests.rs @@ -17,11 +17,11 @@ use crate::graphql::command_contract::CommandConsistency; #[cfg(feature = "graphql")] use crate::graphql::identity::VerifiedPrincipal; use crate::graphql::{ - typed_command, Causal, GraphqlInputType, GraphqlOutputType, GraphqlTypeDef, GraphqlTypeField, + typed_command, Eventual, 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::{ @@ -1279,7 +1279,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 +2191,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 +2206,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 +2456,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 +2549,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 +2564,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 +2642,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,10 +2701,10 @@ 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.state, CausalCommandPublicState::Atomic); assert_eq!( direct_status.consistency, - Some(CommandConsistency::Projected) + Some(CommandConsistency::Atomic) ); assert!(direct_status.obligations.is_empty()); assert!(direct_status.evidence.is_empty()); @@ -2749,7 +2749,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/projection/lower.rs b/src/projection/lower.rs index 6c2458f7..0739a3af 100644 --- a/src/projection/lower.rs +++ b/src/projection/lower.rs @@ -536,7 +536,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 +572,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/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/e2e-ui/PROJECTION_ROLLOUT.md b/tests/e2e-ui/PROJECTION_ROLLOUT.md index aaa95653..d01106ae 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 `Projected` 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..df393c1f 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -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,8 +238,10 @@ 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. 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..aca53884 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,7 @@ //! Command: `blob.move` — direction up|down|left|right. use blob_domain::{BlobGame, BlobGameState, Direction}; -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; @@ -20,7 +20,7 @@ pub struct BlobMoveInput { 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!( @@ -43,5 +43,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.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..b1e8eef9 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,66 @@ 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. Projected 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]) + // Identity for preview binding; board fields come back Projected. + .applies(distributed::state_preview! { + BlobMovedDomainEvent => 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: unknown, + status: unknown, + } + }), ) .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/ui/src/lib/generated/admin/commands.ts b/tests/e2e-ui/ui/src/lib/generated/admin/commands.ts index f689639a..fe31eb3c 100644 --- a/tests/e2e-ui/ui/src/lib/generated/admin/commands.ts +++ b/tests/e2e-ui/ui/src/lib/generated/admin/commands.ts @@ -36,7 +36,7 @@ export type Command_blob_games_move_Output = { /** Exact typed causal command descriptor and full mutation bytes. */ export const Command_blob_games_move: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -149,10 +149,167 @@ export const Command_blob_games_move: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -311,10 +474,187 @@ export const Command_blob_games_start: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -473,10 +819,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 +1309,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 +1564,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 +1813,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 +2099,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 +2372,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 +2574,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 +2840,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 +3089,7 @@ export const Command_todos_reopen: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -149,10 +149,167 @@ export const Command_blob_games_move: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -312,10 +475,187 @@ export const Command_blob_games_start: ReplicaCommandArtifact = { - "consistency": "projected", + "consistency": "atomic", "directProjection": { "changeEpoch": "e2e-ui-blob-v2", "identityFields": [ @@ -475,10 +821,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 +1312,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 +1568,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 +1818,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 +2105,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 +2314,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 +2581,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 +2831,7 @@ export const Command_todos_reopen: ReplicaCommandArtifact>( + code: `typed_command::>( todo_create::COMMAND, ) .field_name("todos_create") @@ -122,7 +129,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") @@ -134,7 +141,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: [ { @@ -143,14 +150,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, @@ -166,7 +173,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, })` @@ -293,9 +300,9 @@ 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', @@ -365,7 +372,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: [ { @@ -385,7 +392,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") @@ -403,7 +410,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: [ { @@ -411,7 +418,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(); @@ -434,7 +441,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, @@ -561,9 +568,9 @@ 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', @@ -630,32 +637,41 @@ 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. Same mutation IR as an eventual projector — but applied in the command handler, so we wait for the row and put it on the response. That is not possible when projection is an event handler: there you only have `.applies` previews until the async path catches up. The client writes the returned row into the replica before await resolves.', 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: 'Atomic response row is already in the replica when this settles.', + code: `await commands.blob.move({ game_id, direction: 'up', -});` +}); +// confirmDirectProjection applied the returned BlobGames row` }, { - 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 IR as eventual; 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"), + ..unknown + } +})` } ] }, { 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: [ { @@ -663,7 +679,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!( @@ -685,7 +701,7 @@ const games = $derived( repo.readmodel(row) .publish_events() .commit(game)? - .projected() + .atomic() }` } ] @@ -754,7 +770,7 @@ fn record_moved( { id: 'events', label: '5 · Events', - lede: 'Domain events still exist for history. For blob, the same save_blob_game mutation program can run direct (same commit as the event) — not only as a later eventual handler.', + 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: [ { @@ -796,7 +812,7 @@ mutation SaveBlobGame { vec![projection_output::()], /* … */, )?; -// Handler stages the row via readmodel(row).commit()?.projected()` +// Handler stages the row via readmodel(row).commit()?.atomic()` } ] } @@ -809,7 +825,7 @@ 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', @@ -884,7 +900,7 @@ pub struct 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"]) @@ -913,7 +929,7 @@ pub struct 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: [ { @@ -921,7 +937,7 @@ pub struct 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 @@ -933,7 +949,7 @@ pub struct 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, 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..c706bb4c 100644 --- a/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/blob/[[gameId]]/+page.svelte @@ -3,8 +3,10 @@ * 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 mutation IR as eventual, applied in the + * command handler; the response row is written into the replica before + * await resolves (`.applies` previews paint when known fields allow). */ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; @@ -110,6 +112,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,8 +143,7 @@ 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; @@ -149,6 +151,8 @@ commandPending = true; actionError = null; try { + // Same mutation IR as eventual projection; applied in the handler. + // Response row is written into the replica before this await settles. await commands.blob.move({ game_id: selected.game_id, direction }); } catch (error) { actionError = error instanceof Error ? error.message : 'Move failed'; @@ -213,11 +217,12 @@
Board and history render from the same generated BlobGames - operation. Typed projected commands update that replica before they resolve. + operation. Atomic commands apply the same mutation IR in the handler + and return the row — the replica updates before the call resolves.